Choose a hosting model
The three hosting models of a generated site (static, server and split), how the kit decides which one a site is in, and how to check and change it. For developers.
On this page
A generated site starts as plain static pages. Most sites never need more. When a site needs code that runs on a server (a contact form, a webhook, a database), the kit moves it to one of two other hosting models. This page explains the three models, how the kit tells which one a site is in, and what to run when you want to change.
The hosting model decides what the build produces, which Azure resources you pay for and which pipeline files the kit writes. Azure is the only deployment target the kit generates files for today.
The three models
| Static | Server | Split | |
|---|---|---|---|
| What it is | Pages only. The build writes plain files to dist/. | Pages plus on-demand routes in src/pages/api. Astro itself is the server. | Static pages plus a separate backend in api/ (Node or .NET). |
| Runs on | Azure Static Web Apps | A Linux App Service running Node 22 (node dist/server/entry.mjs) | Static Web Apps (Standard plan) for the pages, and a second Linux App Service for the backend, linked so /api/* goes to it |
| Build output | dist/ | dist/client (pages) and dist/server (the Node server) | dist/ for the site, api/dist (Node) or a dotnet publish folder for the backend |
| Server code lives in | Nowhere. There is none. | src/pages/api/** and src/server/** | api/ only |
| Typical use | Marketing sites, docs, handbooks | A contact form, one or two small endpoints | Several routes, background work, a database, or an API a team owns and tests on its own |
| Cost shape | Free plan for staging, Standard plan in the generated production parameters | One App Service plan (B1 by default) | Static Web Apps Standard plus one App Service plan (B1 by default) plus a Key Vault |
| How you get there | It is the default | npx mdlake add-form or npx mdlake add-api-route | npx mdlake backend init --runtime node (or dotnet) |
Important
Do not add a server route to solve something a static site can already do (a link, a mailto:, an embed the client asked for). A server route changes the hosting and its cost. The skills ask for your approval before they switch a site to a more expensive model.
When static is enough
Choose static when every page can be built ahead of time from the lake. Search on a docs site is still static: Pagefind writes its index into dist/pagefind during the build.
When server is the right step
Choose server when you need a handful of endpoints and want to stay in one project and one language. Forms are the common case: the kit’s form handler runs as an Astro route. Server pages are still built ahead of time. Only the files in src/pages/api run on every request.
When to split
Choose split when the server needs outgrow what a few Astro routes should carry. That means background work, a database, many routes, or an API that a team maintains and tests separately. The backend is a normal project (Hono on Node, or an ASP.NET Core minimal API on .NET 10) with its own tests, and the site calls it through a typed client generated from its OpenAPI document. See A separate backend.
Split is always a decision. The kit never moves a site to split because of what the code looks like.
How the kit decides
The kit looks at the routes first, then at one recorded decision.
- It scans
src/pagesfor files that containexport const prerender = false. That line marks a route as on-demand: it runs on the server for each request. - If there is at least one such route, the site is server. If there is none, the site is static.
- A site is split only when
.mdlake/hosting.jsonsays so. The file gets that value fromnpx mdlake backend init. Routes never make a site split.
Server code is allowed in two places only:
src/pages/api/**for on-demand routes.src/server/**for helpers that those routes import.
An on-demand route anywhere else, or a route file in src/pages/api without export const prerender = false, fails the check. Pages, layouts and components never run on the server per request.
The record in .mdlake/hosting.json
npx mdlake prepare writes the tier to .mdlake/hosting.json. You do not run it by hand: npm run dev, npm run build and the checks run prepare first. Commit the file. A static site that never had a server route has no file.
A server site:
{
"tier": "server",
"adapter": "node",
"onDemand": [
"src/pages/api/forms/[name].ts",
"src/pages/api/newsletter.ts"
]
}A split site:
{
"tier": "split",
"onDemand": [],
"backend": {
"runtime": "node",
"path": "api"
}
}| Field | Meaning |
|---|---|
tier | static, server or split. |
adapter | node on the server tier. The kit adds @astrojs/node in standalone mode itself. |
onDemand | The route files that opt out of prerendering. Empty on a split site. |
backend.runtime | node or dotnet. Split only. |
backend.path | The folder of the backend project. It is api. |
The file belongs to the kit. Do not edit it by hand. If it is out of date, run npx mdlake prepare and commit the result.
See which model a site is in
npx mdlake hostingTier: split (Node backend in api/)
.mdlake/hosting.json: split
present .github/workflows/deploy.yml
present azure-pipelines.yml
present infra/main.bicepOn a server site the output also lists the on-demand routes. present or absent tells you which pipeline and Bicep files exist.
Check that everything agrees
npx mdlake check-hosting is the check:hosting step of npm run check. It compares the tier with everything that depends on it and prints what to run when something does not match.
npx mdlake check-hostingFor every tier it checks that:
- the tier recorded in
.mdlake/hosting.jsonmatches the routes (a missing or outdated file is an error that tells you to runnpx mdlake prepare); - no on-demand route is outside
src/pages/api, and no file insrc/pages/apiwould be built as a static file; astro.config.*sets no adapter of its own that fights the tier (a static or split site must not setadapter; a server site must use@astrojs/node);- the GitHub workflow, the Azure DevOps pipeline and
infra/main.bicep, when they exist, are written for the same tier (and, on split, the same backend runtime). A site without pipelines yet gets a note, not an error.
For a split site it also checks that:
- there are no on-demand Astro routes and nothing under
src/pages/api, because Static Web Apps sends every/api/*request to the backend; - the backend project exists (
api/package.jsonfor Node, anapi/*.csprojfor .NET); api/openapi.jsonexists and is valid JSON;- the generated client in
src/api/generatedexists and was generated from the currentapi/openapi.json; - no file in
srccalls/apiwith a hand-writtenfetch; - every form in
src/formsis served by the backend.
Here is real output for a split site that has a new form the backend does not know yet:
error api/openapi.json does not exist. It is the contract between the backend and the site: run `npm run api:openapi` (it builds the backend and writes it), commit it, then `npm run api:generate`.
error The form "newsletter-signup" (src/forms/newsletter-signup.ts) is not served by the Node backend, so POST /api/forms/newsletter-signup/ would answer 404. add an import and an entry to api/src/forms.ts.
Hosting: split tier (Node backend in api/), 2 errorsThe command exits with 1 when there is at least one error, and 0 otherwise. Warnings and notes do not fail it.
Change the model
| From | To | How |
|---|---|---|
| Static | Server | npx mdlake add-form <name> or npx mdlake add-api-route <name>. Then npx mdlake hosting init --ci ... to write the server pipelines and Bicep. |
| Server | Split | npx mdlake backend init --runtime node --from-server (or dotnet). Move your own routes to api/. Then npx mdlake hosting init --ci .... |
| Server | Static | Delete the routes in src/pages/api, run npx mdlake prepare, commit .mdlake/hosting.json, and run npx mdlake hosting init --ci ... so the pipelines and Bicep are static again. |
| Split | Anything else | There is no command to undo backend init. A site has one backend, and the split record in .mdlake/hosting.json is never derived from the routes, so leaving split is manual. Remove api/, src/api/generated/ and the record in .mdlake/hosting.json. Remove what backend init added to the site’s package.json: the scripts api:openapi, api:generate, check:api and dev:all, check:api in the check script, the dependency zod and the dev dependency orval (keep them if your site uses them), and for a Node backend the api entry in workspaces. Remove api from exclude in tsconfig.json. Then run npx mdlake prepare and npx mdlake hosting init --ci ..., and let npm run check list what is left. |
After any change, npm run check tells you what is left. The pipelines and Bicep are generated files for one tier, so they must be written again. hosting init also names modules from the other tier that are left behind in infra/modules. Delete them when you have not edited them. See Azure and Bicep.
Note
Moving from server to split is explicit. backend init refuses on a site with server routes unless you pass --from-server, and it prints what would change first.
What’s next
- Forms for the most common reason to leave static.
- API routes for other on-demand endpoints.
- A separate backend for the split model.
- Deploy overview for what gets deployed where.
