API routes
Add your own server endpoint to a site with add-api-route, how on-demand routes work with the Node adapter, and where server code may live. For developers.
On this page
An API route is a file in src/pages/api that runs on the server for every request. Use one when a form is not the right shape: a newsletter sign-up that calls a mailing service, a webhook from a payment provider, a small lookup that needs a secret. A route makes the site a server site, so it is hosted on an App Service instead of Static Web Apps. Read Choose a hosting model first.
Forms do not need this page. The forms module brings its own route. See Forms. When you need many routes, background work or a database, use a separate backend instead.
Before you start
- Check whether a static solution would do (a link, a
mailto:, a client-side call to a hosted service). A server route adds an App Service plan to the bill. Theadd-api-routeskill asks you to confirm this before it writes anything. - The site must not be split. On a split site the command refuses, because Static Web Apps sends every
/api/*request to the backend and an Astro route there would never be reached. Add the endpoint to the backend instead.
Add a route
npx mdlake add-api-route newsletterThe name is lower case words separated by hyphens, optionally in folders (newsletter, webhooks/stripe). The name forms is reserved for the forms module.
The command:
- Creates
src/pages/api/newsletter.ts. The file is site-owned: the kit never overwrites it. If it exists, the command leaves it alone. - Adds
@astrojs/nodetopackage.json. Runnpm install. - Records the server tier in
.mdlake/hosting.json. Commit the file. - Reminds you that the pipelines and Bicep must match the server model (
npx mdlake hosting init, or thesetup-hostingskill).
The file it writes:
import type { APIRoute } from 'astro'
// Server code. This route runs on the server for every request (the site becomes the "server" hosting tier). Rules:
// - server code lives only in src/pages/api/** and src/server/**;
// - secrets come from the environment (process.env.NAME), set in App Service settings, Key Vault or the pipeline, never in the repository;
// - validate input with Zod (import { z } from '@mdlake/site-kit/forms') before using it.
// It answers at /api/newsletter/ (the site uses trailing slashes).
export const prerender = false
export const POST: APIRoute = async ({ request }) => {
const input: unknown = await request.json().catch(() => undefined)
if (input === undefined) return json(400, { ok: false, error: 'bad-request' })
// TODO: validate `input`, do the work.
return json(200, { ok: true })
}
const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } })export const prerender = false is what makes the route run per request. Without it, Astro would build the route into a static file at build time. npm run check fails for a file in src/pages/api without that line.
Write the handler
Fill in the TODO. The rules:
- Validate input with Zod. Import
zfrom@mdlake/site-kit/forms. Never trust the body. - Read secrets from the environment with
process.env.NAME. Name each variable in.env.examplewithout a value, and tell the developer which ones to set in App Service settings or Key Vault. - Return JSON and set
cache-control: no-storeon answers that depend on the request. - Do not copy lake text into code. Content comes from the lake.
- Answer at the address with the trailing slash. The site uses
trailingSlash: 'always', and a redirect turns a POST into a GET.
A route that validates, limits and calls a service:
import type { APIRoute } from 'astro'
import { z } from '@mdlake/site-kit/forms'
import { clientAddress, createRateLimiter } from '@mdlake/site-kit/server'
export const prerender = false
const body = z.object({ email: z.email().max(200) })
const limiter = createRateLimiter({ max: 5, windowSeconds: 600 })
export const POST: APIRoute = async ({ request, clientAddress: address }) => {
const ip = clientAddress(request.headers, address, Number(process.env.TRUSTED_PROXY_HOPS ?? 1))
const blocked = limiter.blocked(ip)
if (blocked) return json(429, { ok: false, error: 'rate-limited' }, { 'retry-after': String(blocked.retryAfterSeconds) })
limiter.hit(ip)
const parsed = body.safeParse(await request.json().catch(() => undefined))
if (!parsed.success) return json(422, { ok: false, error: 'invalid' })
const key = process.env.NEWSLETTER_API_KEY
if (!key) {
console.error('NEWSLETTER_API_KEY is not set')
return json(500, { ok: false, error: 'not-configured' })
}
// Call the mailing service with `key` here.
return json(200, { ok: true })
}
const json = (status: number, data: unknown, headers: Record<string, string> = {}) =>
new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...headers } })Notes on that example:
@mdlake/site-kit/serveralso exports the mail providers,verifyTurnstileandcreateFormHandler. Import it only fromsrc/pages/api/**andsrc/server/**.- The rate limiter lives in memory, per server process. See the note about limits in Forms.
- Never log the key or the request body. Log the name of the missing variable.
Dynamic paths and other methods
add-api-route makes a file for one name. For a path with a parameter, create the file yourself, for example src/pages/api/items/[id].ts:
import type { APIRoute } from 'astro'
export const prerender = false
export const GET: APIRoute = ({ params }) => {
return new Response(JSON.stringify({ id: params.id }), { headers: { 'content-type': 'application/json; charset=utf-8' } })
}Export one function per method (GET, POST, PUT, DELETE). The kit’s check finds every file in src/pages/api that has the prerender = false line, so hand-made routes are part of the tier like generated ones.
Helpers
Put code that several routes share in src/server/, for example src/server/newsletter.ts, and import it from the routes. That folder is the other place where server code is allowed. Pages and components must not import from it.
The Node adapter
The kit adds @astrojs/node in standalone mode itself when the site is a server site. You do not edit astro.config.mjs for that. astro.config.mjs stays:
// @ts-check
import { defineMdlakeConfig } from '@mdlake/site-kit/integration'
export default defineMdlakeConfig()What changes in the build:
| Static | Server | |
|---|---|---|
| Output | dist/ | dist/client (the pages and assets the browser gets) and dist/server (the server, started with node dist/server/entry.mjs) |
| Pages | Files | Still built ahead of time. Only the on-demand routes run per request. |
| What the checks read | dist/ | dist/client (accessibility check, route snapshot, asset copy) |
If you set an adapter of your own in astro.config.mjs, the kit leaves it alone and check-hosting reports an error unless it is @astrojs/node. The generated pipelines and Bicep deploy the Node standalone server. To use another adapter, adopt the hosting files and drop check:hosting from npm run check.
Run the production server locally
npm run build
node dist/server/entry.mjsnpm run build
$env:HOST = '127.0.0.1'; $env:PORT = '4321'; node dist/server/entry.mjsThe server reads HOST and PORT. App Service supplies the port itself. The generated Bicep sets HOST=0.0.0.0 and NODE_ENV=production. HOST and PORT are the variables that the standalone mode of @astrojs/node reads. That server also serves the files in dist/client itself, so one process answers both the pages and the routes.
Set the environment variables your routes read in the same shell, because the production server does not read .env. The kit loads .env in its commands (mdlake prepare, mdlake preview, dev-all) and in its Astro integration, which runs in astro dev and astro build. node dist/server/entry.mjs is Astro’s own server entry and does not start any of them. Do not rely on .env there: set the variables in the shell, or as app settings on App Service.
Try it
-
Run
npm run dev. Astro serves the route. -
Send a request:
curl -i -X POST http://localhost:4321/api/newsletter/ \ -H "content-type: application/json" -d '{"email":"anna@example.com"}'Invoke-RestMethod -Method Post -Uri http://localhost:4321/api/newsletter/ ` -ContentType 'application/json' -Body '{"email":"anna@example.com"}' -
Run
npm run check. Expect a hosting error until the pipelines and Bicep are written for the server model.
What can go wrong
| Symptom | Cause and fix |
|---|---|
check-hosting: “is in src/pages/api/ but is built as a static file” | The route lacks export const prerender = false. Add it, or move the file out of src/pages/api if it is meant to be static. |
check-hosting: “is an on-demand route outside src/pages/api/” | Server code is only allowed in src/pages/api/**. Move the route there. |
check-hosting: “.mdlake/hosting.json … does not exist” or “says static” | Run npx mdlake prepare and commit the file. |
| The route works in dev but answers 404 after deployment | You deployed a static pipeline. Run npx mdlake hosting init --ci ... and deploy the server pipeline. |
| The command says the site is split | An Astro route would never be reached. Add the endpoint to the backend. |
| The error “This site has on-demand routes … but no adapter is configured” | astro.config.mjs does not pass the result of defineMdlakeConfig() to Astro unchanged. Restore the default file. |
What’s next
- Azure and Bicep to host the server.
- A separate backend when routes are not enough.
