Skip to content
mdlake

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-backend skill 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 install done for the site. For a .NET backend, the .NET 10 SDK too (dotnet --version prints 10.x).
  • Choose the runtime. Pick the one the team prefers to maintain.
Node.NET
StackHono with Zod and @hono/zod-openapi, bundled by esbuild.NET 10 minimal API with the built-in OpenAPI generator
Where schemas liveOne Zod schema validates requests, types handlers and describes the endpointC# records and endpoint metadata
FormsImports the site’s own src/forms/*.ts definitions as they areA FormCatalog.cs that repeats each form in C#
Local port30015088
Project fileapi/package.json (an npm workspace of the site)api/Api.csproj

Add the backend

  1. Run the command. In the site folder:

    Bash
    npx mdlake backend init --runtime node

    Use --runtime dotnet for .NET. The command scaffolds api/, 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.

  2. Install.

    Bash
    npm install

    For Node, api is an npm workspace, so this one command installs the site and the backend.

  3. Write the contract.

    Bash
    npm run api:openapi

    This builds the backend and writes api/openapi.json. Commit it. The first .NET run restores packages and can take a minute.

  4. Generate the client.

    Bash
    npm run api:generate

    This runs Orval on api/openapi.json and writes src/api/generated/api.ts (typed fetch functions) and src/api/generated/zod.ts (Zod schemas). Commit both.

  5. Run everything.

    Bash
    npm run dev:all

    You see the output of both processes, prefixed with [api] and [site]. Open the site and call http://localhost:4321/api/health. The answer is {"status":"ok"}.

  6. Write the pipelines and Bicep for the split model:

    Bash
    npx mdlake hosting init --ci github,azure-devops

    See Azure and Bicep.

  7. Run the checks.

    Bash
    npm run check

What backend init changes

WhereWhat
api/The backend project. It is yours: the kit never rewrites it.
package.jsonScripts 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.
.gitignoreNode: api/dist/. .NET: api/bin/, api/obj/, api/publish/, api-publish/.
tsconfig.jsonapi is excluded, so astro check does not read the backend.
.env.exampleAPI_PORT, MAIL_PROVIDER, RESEND_API_KEY and MAIL_FROM, if they are not there yet.
.mdlake/hosting.jsontier: 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.cs

The 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:

CommandWhat 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:

Bash
npm run api:openapi
npm run api:generate

Commit 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:

Astro
---
// 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:

TypeScript
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:

C#
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

Bash
npm run api:openapi
npm run api:generate

and 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:

TypeScript
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.

C#
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: true is 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 commandnpm --prefix api run dev (tsx watch src/server.ts)dotnet watch run --project api/Api.csproj --no-launch-profile
PortAPI_PORT, default 3001API_PORT, default 5088
Address127.0.0.1127.0.0.1
EnvironmentProduction-like NODE_ENV is not set, so Turnstile test keys applyASPNETCORE_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.

VariableRuntimeMeaning
API_PORTbothThe local port. Used by dev:all and the dev proxy.
PORT, HOSTNodeSet by App Service and by dev:all. Default host 0.0.0.0.
MAIL_PROVIDER, RESEND_API_KEY, MAIL_FROMbothSee Mail.
<RECIPIENT> (for example CONTACT_FORM_TO)bothWhere each form’s mail goes.
TURNSTILE_SECRET_KEYbothSee Turnstile.
TURNSTILE_VERIFY_URLbothTests only.
TRUSTED_PROXY_HOPSbothHow many proxies to trust in X-Forwarded-For. The generated Bicep sets 2. Default 1.
FORMS_RATE_LIMIT_MAX.NETSubmissions per address and window. Default 5.
FORMS_RATE_LIMIT_WINDOW_SECONDS.NETDefault 600.
FORMS_MAX_BODY_BYTES.NETLargest 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:

Bash
npx mdlake backend init --runtime node --from-server

It then:

  • scaffolds api/ and records the split model;
  • removes the kit’s own forms route src/pages/api/forms/[name].ts when you never changed it, and removes @astrojs/node from package.json;
  • leaves every other route in src/pages/api where it is and lists them. npm run check fails until you have moved each one to api/ 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/health would be refused once the backend is linked.
  • Node starts with node server.mjs. .NET is published with dotnet publish.

See Azure and Bicep for the resources.

What can go wrong

SymptomFix
backend init says the site is on the server tierDecide, 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 3A 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 404The 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 useSet API_PORT to a free port.

What’s next