A separate backend
Add a Node or .NET backend to a site with add-backend, keep it in step with the site through an OpenAPI document and a generated client, run both together, and serve forms from it. For developers.
On this page
Most sites do not need a separate backend. When the server needs outgrow a few Astro routes (background work, a database, many routes, an API that a team owns and tests on its own), the kit offers the split model. The site stays static on Azure Static Web Apps. The backend lives in an api/ folder, runs on its own Linux App Service, and is linked to the Static Web App so the browser calls /api/* on the site’s own domain. There is no CORS to configure.
The two halves meet at one contract: the backend’s OpenAPI document, committed as api/openapi.json. The site never calls the backend by hand. A generated client in src/api/generated gives it typed functions and Zod schemas, and npm run check fails when the client and the backend disagree.
This page takes you from a static site to a backend that runs locally, serves a form and is ready to deploy. You can follow it with only this guide.
Before you start
- You have read Choose a hosting model and decided on split. The
add-backendskill asks the developer to approve it, because it changes the hosting and its cost: Static Web Apps Standard plus an App Service plan. - Node 22 and
npm installdone for the site. For a .NET backend, the .NET 10 SDK too (dotnet --versionprints 10.x). - Choose the runtime. Pick the one the team prefers to maintain.
| Node | .NET | |
|---|---|---|
| Stack | Hono with Zod and @hono/zod-openapi, bundled by esbuild | .NET 10 minimal API with the built-in OpenAPI generator |
| Where schemas live | One Zod schema validates requests, types handlers and describes the endpoint | C# records and endpoint metadata |
| Forms | Imports the site’s own src/forms/*.ts definitions as they are | A FormCatalog.cs that repeats each form in C# |
| Local port | 3001 | 5088 |
| Project file | api/package.json (an npm workspace of the site) | api/Api.csproj |
Add the backend
-
Run the command. In the site folder:
npx mdlake backend init --runtime nodeUse
--runtime dotnetfor .NET. The command scaffoldsapi/, adds scripts and dependencies to the site, and records the split model. It ends with the next steps. It never overwrites a file that exists. -
Install.
npm installFor Node,
apiis an npm workspace, so this one command installs the site and the backend. -
Write the contract.
npm run api:openapiThis builds the backend and writes
api/openapi.json. Commit it. The first .NET run restores packages and can take a minute. -
Generate the client.
npm run api:generateThis runs Orval on
api/openapi.jsonand writessrc/api/generated/api.ts(typedfetchfunctions) andsrc/api/generated/zod.ts(Zod schemas). Commit both. -
Run everything.
npm run dev:allYou see the output of both processes, prefixed with
[api]and[site]. Open the site and callhttp://localhost:4321/api/health. The answer is{"status":"ok"}. -
Write the pipelines and Bicep for the split model:
npx mdlake hosting init --ci github,azure-devopsSee Azure and Bicep.
-
Run the checks.
npm run check
What backend init changes
| Where | What |
|---|---|
api/ | The backend project. It is yours: the kit never rewrites it. |
package.json | Scripts api:openapi, api:generate, check:api and dev:all; npm run check:api added to check after check:hosting; the dev dependency orval; the dependency zod; for Node, api in workspaces. |
.gitignore | Node: api/dist/. .NET: api/bin/, api/obj/, api/publish/, api-publish/. |
tsconfig.json | api is excluded, so astro check does not read the backend. |
.env.example | API_PORT, MAIL_PROVIDER, RESEND_API_KEY and MAIL_FROM, if they are not there yet. |
.mdlake/hosting.json | tier: split and the backend’s runtime and path. Commit it. |
If package.json has a shape the command cannot edit (for example workspaces as an object), it prints a line starting with By hand: that says what to add.
The Node project
The Node backend imports @mdlake/site-kit and @mdlake/lake-loader. api/package.json depends on the same vendored tarballs as the site, with paths relative to api/: file:../vendor/mdlake-site-kit-<version>.tgz and file:../vendor/mdlake-lake-loader-<version>.tgz. A plain * would only resolve while the site’s own install provides the package, so the backend would not install on its own, for example with npm ci in api/. vendor-packages.mjs (step 2 of upgrade-ruleset) keeps both entries, and the api entry in package-lock.json, in step with the site on every upgrade, and it converts an existing *. Commit api/package.json and the lockfile after an upgrade.
api/
package.json scripts: dev (tsx watch), build (esbuild), start, openapi, typecheck
tsconfig.json
README.md
scripts/build.mjs bundles to dist/server.mjs plus a package.json ({"type":"module"})
scripts/emit-openapi.ts writes the OpenAPI document
src/server.ts the entry: PORT on App Service, API_PORT locally, HOST (default 0.0.0.0)
src/app.ts the OpenAPIHono app, the 404 and error handlers, the OpenAPI document
src/schemas.ts Zod schemas of the answers (.openapi('Name') names them in the document)
src/forms.ts the forms this backend serves
src/routes/health.ts GET /api/health
src/routes/hello.ts GET /api/hello (a sample to replace)
src/routes/forms.ts POST /api/forms/{name}The .NET project
api/
Api.csproj net10.0, nullable on, warnings as errors
Program.cs options, OpenAPI, rate limiting, forwarded headers, endpoint mapping
appsettings.json
README.md
Endpoints/ HealthEndpoints.cs, SampleEndpoints.cs, FormEndpoints.cs
Forms/ FormCatalog.cs, FormDefinition.cs, FormValidator.cs, FormMail.cs,
FormRateLimiting.cs, FormResponses.cs, TurnstileVerifier.cs
Mail/ IMailSender.cs, ResendMailSender.cs, LogMailSender.cs, MailSenderFactory.cs
Options/ MailOptions.cs, FormsOptions.cs, TurnstileOptions.csThe options classes use data annotations and ValidateOnStart, so a bad setting stops the app at startup with a clear message instead of failing on the first request.
The contract
api/openapi.json describes every endpoint the backend serves. The commands that keep it honest:
| Command | What it does |
|---|---|
npm run api:openapi (npx mdlake api openapi) | Builds the backend and writes api/openapi.json. Node runs api/scripts/emit-openapi.ts. .NET runs dotnet build -p:MdlakeOpenApi=true, which starts the app once at build time to read its endpoints. |
npm run api:generate (npx mdlake api generate) | Runs Orval on api/openapi.json and writes src/api/generated/api.ts and zod.ts. Add --overwrite to replace a generated file you edited by hand. |
npm run check:api (npx mdlake api check) | Builds the backend and fails when it emits a different document than the committed one. |
Each generated file starts with a line like this, which records which document it came from:
// mdlake-api: openapi-sha256=3f2a... (written by `npm run api:generate` from the backend's openapi.json; do not edit)The hash is of the document’s content, not its formatting. npm run check (through check-hosting) compares it with the committed document, so a client older than the document is an error:
error src/api/generated/api.ts is stale: api/openapi.json changed after the client was generated. Run `npm run api:generate` and commit src/api/generated/.The workflow after any backend change is always the same:
npm run api:openapi
npm run api:generateCommit api/, api/openapi.json and src/api/generated/ together.
Call the backend from the site
Use the generated functions. Never write fetch('/api/...') by hand. npm run check fails with “calls /api with a hand-written fetch” for any file in src that does, and also for hand-typed response types in the spirit of the ruleset.
Orval names each function after the endpoint’s operationId (getHello, submitForm) and each returns { data, status, headers }. The Zod schemas are in zod.ts. A page script:
---
// src/pages/hello.astro
import Layout from '@mdlake/site-kit/layouts/Layout.astro'
---
<Layout title="Hello">
<button id="greet" type="button">Greet</button>
<p id="out" role="status"></p>
<script>
import { getHello } from '../api/generated/api'
import { GetHelloResponse } from '../api/generated/zod'
document.querySelector('#greet')?.addEventListener('click', async () => {
const { data, status } = await getHello({ name: 'Anna' })
const out = document.querySelector('#out')
if (out) out.textContent = status === 200 ? GetHelloResponse.parse(data).message : 'Something went wrong.'
})
</script>
</Layout>The generated functions use relative addresses such as /api/hello. They work in the browser, where the dev proxy and Static Web Apps both answer. They do not work in Astro frontmatter at build time, because there is no server to call then. Content for pages comes from the lake; the backend is for behaviour.
Add an endpoint
Give every endpoint a name (operationId). It becomes the function name in the client.
Node
Create api/src/routes/items.ts:
import { createRoute, z, type OpenAPIHono } from '@hono/zod-openapi'
const Item = z.object({ id: z.string(), name: z.string() }).openapi('Item')
const NotFound = z.object({ error: z.string() }).openapi('NotFound')
const route = createRoute({
method: 'get',
path: '/api/items/{id}',
operationId: 'getItem',
tags: ['Items'],
summary: 'One item.',
request: { params: z.object({ id: z.string().min(1).openapi({ param: { name: 'id', in: 'path' } }) }) },
responses: {
200: { description: 'OK', content: { 'application/json': { schema: Item } } },
404: { description: 'Not found', content: { 'application/json': { schema: NotFound } } },
},
})
export const items = (app: OpenAPIHono) =>
app.openapi(route, (c) => {
const { id } = c.req.valid('param')
if (id !== 'demo') return c.json({ error: 'not-found' }, 404)
return c.json({ id, name: 'Demo item' }, 200)
})Register it in api/src/app.ts next to the others (import { items } from './routes/items.ts' and items(app)). Routes live under /api because Static Web Apps forwards only /api/* to the backend.
.NET
Create api/Endpoints/ItemEndpoints.cs:
using Microsoft.AspNetCore.Http.HttpResults;
namespace Site.Api.Endpoints;
public sealed record Item(string Id, string Name);
public static class ItemEndpoints
{
public static void MapItemEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/items/{id}", Results<Ok<Item>, NotFound> (string id) =>
id == "demo" ? TypedResults.Ok(new Item(id, "Demo item")) : TypedResults.NotFound())
.WithName("getItem")
.WithTags("Items")
.WithSummary("One item.");
}
}Call app.MapItemEndpoints(); in Program.cs after the other Map... calls. Use typed results (TypedResults.Ok(...)) so the document knows the answer shapes. Nullable reference types are on and warnings are errors, so the build tells you about sloppy code.
Then
npm run api:openapi
npm run api:generateand use getItem from src/api/generated/api.ts.
Forms on a split site
The Form component on the page posts to /api/forms/<name>/, and the backend answers. The page and the browser-side validation use src/forms/<name>.ts exactly as on the server model. The backend must serve the same form, and npm run check fails until it does (The form "contact" (src/forms/contact.ts) is not served by the Node backend, so POST /api/forms/contact/ would answer 404.).
npx mdlake add-form <name> on a split site writes the definition and the page and prints the line to add to the backend.
Node
api/src/forms.ts imports the site’s definitions as they are, so the schema is the one the browser uses. backend init fills it with the forms that exist at that moment. Add the rest by hand:
import type { FormDefinition } from '@mdlake/site-kit/forms'
import contact from '../../src/forms/contact.ts'
import newsletterSignup from '../../src/forms/newsletter-signup.ts'
// The forms the backend serves.
export const forms: Record<string, FormDefinition> = {
contact,
'newsletter-signup': newsletterSignup,
}The key is the form’s name, which is the file name. The handler is the kit’s createFormHandler, so the honeypot, rate limit, Turnstile, mail and answers are the same as in Forms.
.NET
api/Forms/FormCatalog.cs repeats each form in C#. Keep every entry in step with src/forms/<name>.ts: the same name, fields, limits and messages.
public static readonly FormDefinition Contact = new(
Name: "contact",
RecipientEnv: "CONTACT_FORM_TO",
Subject: "New message from the website",
SuccessMessage: "Thank you. Your message has been sent.",
ReplyToField: "email",
Turnstile: true,
Fields:
[
new FormField("name", "Your name", MaxLength: 120, RequiredMessage: "Enter your name", TooLongMessage: "Use at most 120 characters"),
new FormField("email", "Email address", FieldKind.Email, MaxLength: 200, RequiredMessage: "Enter a valid email address", InvalidMessage: "Enter a valid email address"),
new FormField("message", "Message", MinLength: 10, MaxLength: 4000, RequiredMessage: "Write a few words more", TooShortMessage: "Write a few words more", TooLongMessage: "Use at most 4000 characters"),
]);
public static IReadOnlyDictionary<string, FormDefinition> All { get; } = new[] { Contact }.ToDictionary(form => form.Name);The check looks only for a Name = "<form>" (or Name: "<form>") in the C# files. It does not compare the fields. Two things follow:
- The C# validator knows required fields, minimum and maximum length, the shape of an email address and checkboxes. A Zod rule of any other kind (an enum for a
select, a pattern, a number range) is enforced by the browser only. If the server must enforce it, write it in C#. Turnstile: trueis set separately in C#. If you forget it, the server skips the bot check even though the widget is on the page.
Run it locally
npm run dev:all (npx mdlake dev-all) starts the backend and the site’s dev server together. Output lines are prefixed [api] and [site]. When one process exits, the other is stopped.
| Node | .NET | |
|---|---|---|
| Backend command | npm --prefix api run dev (tsx watch src/server.ts) | dotnet watch run --project api/Api.csproj --no-launch-profile |
| Port | API_PORT, default 3001 | API_PORT, default 5088 |
| Address | 127.0.0.1 | 127.0.0.1 |
| Environment | Production-like NODE_ENV is not set, so Turnstile test keys apply | ASPNETCORE_ENVIRONMENT=Development |
The site’s dev server proxies /api to the backend’s port, so the browser always talks to one origin, exactly as in production. The site’s .env is loaded and handed to the backend, so MAIL_PROVIDER, the recipient variable and the others work for both.
You can run the two apart in two terminals (npm --prefix api run dev or dotnet watch run --project api/Api.csproj, and npm run dev). Set the same API_PORT for both or leave it unset.
If you need to call the API from another origin during development, add Hono’s cors() middleware (Node) or a CORS policy (.NET) in development only. The site itself never needs it. The README in api/ has the .NET snippet.
Settings the backend reads
These come from the environment: your .env locally, App Service settings and Key Vault references in Azure.
| Variable | Runtime | Meaning |
|---|---|---|
API_PORT | both | The local port. Used by dev:all and the dev proxy. |
PORT, HOST | Node | Set by App Service and by dev:all. Default host 0.0.0.0. |
MAIL_PROVIDER, RESEND_API_KEY, MAIL_FROM | both | See Mail. |
<RECIPIENT> (for example CONTACT_FORM_TO) | both | Where each form’s mail goes. |
TURNSTILE_SECRET_KEY | both | See Turnstile. |
TURNSTILE_VERIFY_URL | both | Tests only. |
TRUSTED_PROXY_HOPS | both | How many proxies to trust in X-Forwarded-For. The generated Bicep sets 2. Default 1. |
FORMS_RATE_LIMIT_MAX | .NET | Submissions per address and window. Default 5. |
FORMS_RATE_LIMIT_WINDOW_SECONDS | .NET | Default 600. |
FORMS_MAX_BODY_BYTES | .NET | Largest body. Default 65536. |
On Node, the rate limit comes from the form definition (rateLimit), and the body limit is 64 KB.
Moving from the server model
If the site already has Astro routes in src/pages/api, backend init refuses and explains. Run it again with --from-server to go ahead:
npx mdlake backend init --runtime node --from-serverIt then:
- scaffolds
api/and records the split model; - removes the kit’s own forms route
src/pages/api/forms/[name].tswhen you never changed it, and removes@astrojs/nodefrompackage.json; - leaves every other route in
src/pages/apiwhere it is and lists them.npm run checkfails until you have moved each one toapi/or deleted it.
A site has one backend. Running backend init with the other runtime refuses.
In production
- Static Web Apps (Standard plan) proxies every
/api/*request to the API’s App Service. The browser sees one domain. - Linking adds an identity provider to the App Service so that it only accepts traffic coming through the Static Web App. To call the API directly (a test, another client) you have to change its Authentication settings in the portal.
- Linked backends do not work in pull request environments.
- The App Service has no health check path, because a probe of
/api/healthwould be refused once the backend is linked. - Node starts with
node server.mjs. .NET is published withdotnet publish.
See Azure and Bicep for the resources.
What can go wrong
| Symptom | Fix |
|---|---|
backend init says the site is on the server tier | Decide, then re-run with --from-server. |
api:openapi fails: “Did you run npm install?” (Node) | Run npm install at the site root. |
api:openapi fails: “The .NET SDK (dotnet, version 10) is not installed” | Install the .NET 10 SDK and open a new terminal. |
api:generate fails: “Orval is not installed in this site” | Run npm install. backend init added orval as a dev dependency. |
check-hosting: “src/api/generated/api.ts is stale” | Run npm run api:generate and commit. |
check-hosting: “does not say which api/openapi.json it was generated from” | The file was edited by hand or written by Orval directly. Run npm run api:generate. |
check:api: “api/openapi.json is out of date” | The backend changed. Run npm run api:openapi, commit, then npm run api:generate. |
check-hosting: “calls /api with a hand-written fetch” | Import the function from src/api/generated/api instead. |
check-hosting: “is an on-demand Astro route, but this site is split” | Move the endpoint to api/ or delete it. |
check-hosting: “is under src/pages/api/” | Static Web Apps sends every /api/* request to the backend, so nothing there is ever served. Move or remove the file. |
api generate exits with 3 | A generated file was edited by hand. Read what it would change, then adopt the file (npx mdlake adopt src/api/generated/api.ts) or use --overwrite. Files under src/api/generated/ are in the generated zone, so adopt accepts them. |
| The form answers 404 | The backend does not serve that form. Add it to api/src/forms.ts or FormCatalog.cs. |
npm run dev:all says the site has no separate backend | .mdlake/hosting.json is not split. Run backend init. |
| The port is in use | Set API_PORT to a free port. |
What’s next
- Azure and Bicep for the split resources.
- GitHub Actions and Azure DevOps for the two deploy jobs.
- CLI reference for every command.
