Options
server(options) creates a server instance, and the options allow you to customize server behavior and integrate essential features. These are all the option available:
export default server({ cache: "1h", public: "./public", ... })
.get(...);| Option | Env Variable | Description |
|---|---|---|
auth | AUTH | Set up the authentication system to add login/logout/etc. |
cache | Add Cache-Control and ETag for GET responses. | |
cors | CORS | Enable the Cross-Origin Resource Sharing settings. |
log | LOG_LEVEL | Enable the internal startup, connection and request logs. |
onError | Handler for uncaught errors during the request lifecycle. | |
onResponse | Hook over every outgoing response just before it's sent. | |
openapi | Serve the OpenAPI spec generated from your routes. | |
parser | How request bodies are parsed into ctx.body. | |
port | PORT | Port the server listens on. |
public | PUBLIC | Directory or bucket to serve static public assets. |
secrets | SECRETS | Key (or keys) used to sign cookies and tokens. |
security | A variety of security headers and options. | |
uploads | A directory or Bucket where uploaded files are stored. |
Example of how to use some of them:
import server from '@server/next';
export default server({
auth: 'cookie:github',
cors: true,
port: 3000,
public: './public',
secrets: 'your-secret-key',
uploads: './uploads',
});Environment variables
Several options fall back to environment variables, so you can configure them without touching code. Anything in your .env (or the platform's environment) is read at startup.
An explicit option passed to server() always wins over its environment variable. Auth providers read their own credentials from the environment too (GITHUB_ID / GITHUB_SECRET, etc.); see the auth option for the per-provider list.
| Variable | Option | Notes |
|---|---|---|
PORT | port | Port to listen on. Defaults to 3000. |
SECRETS | secrets | Comma-separated signing keys; the first signs, all verify. Set a long, random value in production. |
PUBLIC | public | Directory of static assets to serve. |
CORS | cors | Allowed origin(s), same values as the option. |
AUTH | auth | Auth string, e.g. cookie:github. |
LOG_LEVEL | log | Set to info to turn on logging. |
NODE_ENV | (none) | production enables production behavior, such as Secure cookies. |
options.auth
Authentication: resolves the signed-in person onto ctx.user on every request, and mounts the login routes when it owns the flow. Defaults to the AUTH environment variable, or off:
server({ auth: 'cookie:github' }); // a login flow, no database
server({ auth: { providers: 'github', onLogin, getUser } });
server({ auth: 'jwt:clerk' }); // a token a vendor issued
server({ auth: { issuer: ISSUER, audience: 'my-api' } }); // ...or any issuer by URL
server({ auth: (ctx) => db.users.byApiKey(...) }); // anything else
server({ auth: betterAuth({ database }) }); // a library that does it allIt takes one of those shapes: one method per app, with several login options as several providers inside it. This option only ever loads the user, it never gates a route: protect routes by checking ctx.user yourself, as shown in Protecting routes.
The string form is '<strategy>:<name>'. With a provider it mounts a login and takes no callbacks, so there is no database: the profile itself is signed into the cookie. With a vendor that runs its own login (clerk, supabase, firebase, gcip) it mounts nothing and only checks their token, reading <NAME>_ISSUER and <NAME>_AUDIENCE from the environment; see Checking a token minted elsewhere. The object form is the one with a database:
| Field | Type | Description |
|---|---|---|
providers | string, string[], or an object | Required. A known name, or any OIDC issuer by URL |
strategy | 'session' | 'cookie' | 'token' | 'jwt' | How the credential is carried and what it holds; 'session' by default |
expires | string | How long a credential lasts; '30d' by default |
onLogin | (profile, ctx) => id | Store whoever logged in, and return the id the credential points at |
getUser | (id, ctx) => user | That id back into the user. This is what ctx.user becomes |
toPublicUser | (user) => publicUser | What gets signed in, for cookie and jwt |
onLogout | (id, ctx) => void | Anything of yours to clean up |
redirect | string, function, or an object | Where people land after login, logout and failure |
export default server({
auth: {
providers: 'google',
onLogin: async (profile) => (await db.users.upsert({ email: profile.email })).id,
getUser: (id) => db.users.find(id),
},
}).get('/me', (ctx) => ctx.user || 401);
// visit /auth/login/google to sign inNo store, no schema and no tables of ours: onLogin and getUser are the only places auth touches your data, so where it lives is entirely up to you. See Authentication for the full picture, or the tutorials for a worked setup per approach.
For machine-to-machine access with a shared secret, a function is the whole integration; see the API keys tutorial.
Providers
63 providers ship, so a name and two environment variables (<NAME>_ID and <NAME>_SECRET) is usually the whole integration:
providers: 'github', // GITHUB_ID, GITHUB_SECRET
providers: ['github', 'google'],github, google, entra, discord, facebook, apple, twitter, reddit, spotify, notion, linear, figma, gitlab, bitbucket, twitch, slack, linkedin, dropbox, patreon, strava, okta, auth0, keycloak, and 40 more.
Anything not on that list takes an issuer URL, which is also how the tenant-specific ones work (a Keycloak realm, a self-hosted Authentik, your own Okta domain):
providers: {
github: { scope: ['repo'] },
work: 'https://dev-12345.okta.com/oauth2/default', // WORK_ID, WORK_SECRET
},The key name is yours, and it names the route (/auth/login/work), the environment variables, and profile.provider.
Every provider exposes GET /auth/login/<name> to start the flow and GET /auth/callback/<name> for the redirect back. Point each provider's OAuth redirect URI at https://<your-host>/auth/callback/<name>.
Session cookies are set HttpOnly and SameSite=Lax (plus Secure in production), and the OAuth flows are CSRF-protected with a state token kept in a short-lived cookie, so the browser must accept cookies for login to complete. Invalid credentials respond with 401 (a failed OAuth state check is 403).
See also
- Authentication guide: every shape, strategy and callback in full.
ctx.user: what handlers actually read.secrets: what signs the credential, and how to rotate it.
options.cache
The default Cache-Control for GET responses. Off by default:
server({ cache: '1h' }); // Cache-Control: public, max-age=3600
server({ cache: 600 }); // a number of seconds
server({ cache: false }); // Cache-Control: no-storeThe value is a duration ('1h', '7d'), a number of seconds, or false/0 for no-store, and becomes Cache-Control: public, max-age=<seconds> on GET responses with a 200 status only, so mutations and errors are never cached. Separately from this header, every buffered GET 200 response also gets an automatic strong ETag, and a request with a matching If-None-Match is answered 304 Not Modified with no body (streaming responses are skipped, since they can't be hashed without buffering).
// global default: Cache-Control: public, max-age=3600 on GET responses
export default server({ cache: '1h' })
.get('/posts', () => Post.list())
// a route's `cache` overrides the global default (local wins, like `parser`)
.get('/me', { cache: false }, (ctx) => ctx.user)
// per-request, when the value is only known at runtime
.get('/report', (ctx) => cache(ctx.user ? false : '1h').json(build(ctx)));Precedence is cache() (in the handler) over the route's cache option over the global one. A route that sets Cache-Control itself is never overridden, and false/0 emits no-store to punch through a global default.
Static assets from public follow the global value too, in place of the hour they are served with otherwise.
For anything more specific (private, s-maxage, immutable, ...), set the header yourself:
.get('/avatar', () => headers('cache-control', 'private, max-age=300').file('./me.png'));See also
cache(): the reply helper, when the value is only known per request.headers(): full control over theCache-Controlvalue.
options.cors
Cross-Origin Resource Sharing settings, letting a browser on another origin call your server. Off by default:
server({ cors: true }) // allow any origin
server({ cors: 'https://app.example.com' }) // a single origin
server({ cors: { origin: '...', credentials: true } }) // full controlAn allowed origin is reflected back in Access-Control-Allow-Origin (requests from localhost are always allowed, so local development just works), preflight OPTIONS requests are answered automatically, and the CORS environment variable can hold the origin(s) instead of the option. The object form takes:
| Field | Type | Description |
|---|---|---|
origin | string | string[] | The allowed origin(s); '*' for any. Defaults to '*' |
methods | string | string[] | Allowed methods; defaults to all of them |
headers | string | string[] | Allowed request headers; defaults to '*' |
credentials | boolean | Let the browser send cookies; the exact origin is reflected instead of '*', as the spec requires |
// An SPA on another domain, using cookie sessions
export default server({
cors: {
origin: 'https://app.example.com',
credentials: true,
},
auth: 'cookie:github',
}).get('/api/me', (ctx) => ctx.user || 401);Examples
// Several front-ends
server({ cors: ['https://app.example.com', 'https://admin.example.com'] });
// A public read-only API: any origin, GET only
server({ cors: { origin: '*', methods: 'GET' } });
// Through the environment instead
// CORS=https://app.example.com
server({ cors: process.env.CORS });See also
security: the locking-down counterpart;corsopens access up.headers(): set any response header yourself on one route.
options.log
The built-in startup, connection and request logs. Off by default:
server({ log: 'info' }); // or the LOG_LEVEL=info environment variableEvery line is prefixed [server:<scope>], and three kinds are logged: the configured modules on startup ([server:auth] github auth enabled), the URL once the server listens, and one line per request with its sizes and status (redirects also show their target).
export default server({ log: 'info' })
.get('/', () => 'Hello world');
// [server:start] http://localhost:3000/
// [server:api] GET / → 200 OK 11bSee also
onResponse: roll your own request logging or metrics.
options.onError
The handler for uncaught errors during a request. By default a 4xx answers with the error's message, since it describes what the client got wrong, while a 5xx answers with a plain Server Error and the real message, its hint and its stack go to the log. See Errors for the full picture and every code.
server({ onError: (error, ctx) => status(500).json({ error: error.message }) });It receives the error and the current ctx, may be async, and must return a Response (or a reply helper). The error carries a code to branch on, a status, and a hint describing the fix, which is for your logs rather than the client:
import server, { status } from '@server/next';
export default server({
onError: (error, ctx) => {
console.error(error);
return status(500).json({ error: error.message });
},
});A failed route schema also lands here, as a ValidationError with source ('body', 'query', 'params' or 'response') and the schema's issues. Its message is intentionally generic (the default handler sends it to the client), so use the fields to log or shape a richer API response:
import server, { ValidationError, status } from '@server/next';
export default server({
onError: (error, ctx) => {
if (error instanceof ValidationError && error.source !== 'response') {
return status(422).json({ error: error.message, issues: error.issues });
}
return status(error.status || 500).send(error.message || 'Server Error');
},
});See also
- Errors: every code, its status and how to handle it.
ValidationError: the schemas whose failures land here.status(),json(): shape the error response.
options.onResponse
A hook over every outgoing HTTP response, just before it's sent:
server({ onResponse: (res, ctx) => { res.headers.set('x-served-by', 'me'); } });It runs for routes, static files, 404s, and onError output alike (never WebSocket traffic), receiving the finalized Response and the ctx, and may be async. Return a Response to replace the outgoing one, it's sent as-is, so the hook owns its status and headers (nothing is re-finalized); return nothing to leave the response unchanged.
// Add a header to every response
export default server({
onResponse: (res, ctx) => {
res.headers.set('x-app-version', '1.2.3');
return res;
},
});A common use is a custom 404 (or any status-based rewrite), since the hook sees the finalized status:
export default server({
onResponse: (res) =>
res.status === 404 ? new Response('Not found here', { status: 404 }) : res,
}).get('/', () => 'home');A returned Response is sent verbatim, so a brand-new one won't carry the framework's security headers or ETag, mutate and return the original when you want those kept.
See also
onError: its output also flows through this hook.headers(): set headers from inside one route instead.
options.openapi
Serves the OpenAPI spec generated from your routes. Off by default:
server({ openapi: true }); // serve it at /openapi.json
server({ openapi: '/api.json' }); // ...at another path
server({ openapi: { title: 'Notes API' } }); // ...overriding the info blockThe routes' validation schemas (body, query, params, response, from any Standard Schema library) become the spec's parameters and payloads, and their schema metadata the tags and summaries. The object form takes:
| Field | Type | Description |
|---|---|---|
path | string | Where the spec is served; /openapi.json by default |
title | string | The API's name; your package.json name by default |
description | string | Free text; your package.json description by default |
version | string | The API version; your package.json version by default |
The server URL comes from your package.json homepage, or the request's origin.
import { z } from 'zod';
const Note = z.object({ title: z.string(), body: z.string() });
export default server({ openapi: { title: 'Notes API' } })
.get('/notes', { response: z.array(Note), schema: { tags: 'notes' } }, () => [])
.post('/notes', { body: Note, schema: { tags: 'notes' } }, () => 201);
// curl localhost:3000/openapi.jsonEvery route appears in the spec, including the built-in auth endpoints (grouped under an auth tag); a route that doesn't belong in it (the homepage, the docs UI shell) opts out with schema: false. The spec is plain JSON, so codegen, Postman or an AI agent consume it straight from the URL. Valibot schemas need the companion @valibot/to-json-schema installed for full output (zod and arktype need nothing extra); a schema nothing can express degrades to a plain string type.
Add a docs UI
There's no built-in viewer: every docs UI is a static shell pointing at the spec, so it's a plain route you own. With Scalar:
server({ openapi: true })
.get('/docs', { schema: false }, () => `<!doctype html>
<html>
<body>
<script id="api-reference" data-url="/openapi.json"></script>
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
</body>
</html>`);Swagger UI, Redoc and the rest drop in the same way, only the shell changes; see the openapi-scalar and openapi-swagger demos. The viewer loads from a CDN, so the docs page needs internet access; the spec itself doesn't.
See also
- Validation: the route schemas the spec is built from.
- Route options: the
schemametadata for tags, titles and descriptions.
options.parser
Controls how the request body is read into ctx.body. The value is a mode: parse (default), raw or stream:
server() // defaults to `parse`
server({ parser: 'raw' }) // read it into a single Buffer
server({ parser: 'stream' }) // receive the raw streamThe three modes:
parse(default): the body is parsed by its content-type. JSON and form fields become an object; uploaded files stream touploadsas they arrive (never buffered whole) andctx.bodyholds a reference to each. A raw non-form body (say a postedimage/png) is streamed touploadsas a single file too.raw: the body is read into a singleBufferand left unparsed, for when you need the exact bytes (for example, verifying a webhook signature).stream: the body is not read at all.ctx.bodyis the request's webReadableStream, so you can pipe it straight to storage without ever buffering it in memory.
Set it globally as above, or on a per route basis:
server()
.post('/webhook', { parser: 'raw' }, (ctx) => {
// ctx.body is the exact Buffer, e.g. to verify a signature
});stream mode is ideal for receiving a single large file without buffering it. Because middleware runs before the body is read, you can authenticate or validate first; see File handling:
export default server()
.post('/videos/:id', { parser: 'stream' }, async (ctx) => {
const id = ctx.url.params.id; // validate this as well
await bucket.file(`${id}.mp4`).write(ctx.body);
return 201;
});The size of buffered bodies is capped by security.maxBodySize, 1mb by default.
uploadsis where files are stored;parseris how the body is read. They are independent:uploadsapplies only inparsemode and is ignored when you read the body asraworstream.
See also
ctx.body: the shape each mode produces.security.maxBodySize: the buffered-body size cap.- Validation: a
bodyschema requiresparsemode.
options.port
The port number on which the server listens for incoming connections. Defaults to the PORT environment variable, or 3000 if neither the option nor the variable are set.
server({ port: 2000 });Order of reading:
- The option passed as a number.
- The environment variable, which can come from
.env, the CLI, etc. - Port
3000otherwise.
options.public
A directory or Bucket served as open static assets. Off by default:
server({ public: './public' }); // or the PUBLIC environment variable
server({ public: bucket.S3(...) }); // serve straight from a bucketRequests are matched against it by path and returned with the right content type, before your routes: when a file exists for the path, the file is served and a same-named route acts as the fallback for when it doesn't. Files are served with Cache-Control: public, max-age=3600, an ETag and Last-Modified (conditional requests get a 304 Not Modified), and Range requests are honored with 206 Partial Content, so media can be seeked and downloads resumed. A global cache replaces that hour, cache: false included.
// A typical app: static assets alongside the API
export default server({ public: './public' })
.get('/api/posts', () => db.posts.list());
// GET /styles.css -> ./public/styles.css
// GET /api/posts -> the routepublic is for files anyone may read. To serve stored files behind your own logic (auth, ownership), return them from a route instead; see Serving stored files.
Favicon: a favicon.ico in the folder is all it takes. Without a public folder, one route serves it, cached:
.get('/favicon.ico', () => cache('7d').file('./assets/favicon.ico'))See also
uploads: where user files are written; serve them back through a route.file(): serve any single file from a route, favicon included.
options.secrets
The keys used to sign and encrypt cookies and tokens. Keep them long, random and private:
server({ secrets: process.env.SECRETS }); // or just the SECRETS env variableThe option wins over the SECRETS environment variable; with neither set, an unsafe- prefixed value is generated per process, which is fine for local development but not for production.
Warning
SECRETSis a comma-separated list, so a secret cannot contain a comma. Everything that generates one is comma-free (openssl rand -base64 32, hex, uuid), but a hand-written passphrase must avoid it. Thesecretsoption takes a real array, so it has no such limit.
Rotating a key
Replacing a secret invalidates everything signed with the old one, which logs everyone out. Pass several instead: the first one signs, and all of them verify.
server({ secrets: [current, previous] });SECRETS=new-key,old-keyTo rotate: move the live key into second place, put a new one first, and deploy. Existing tokens keep working, new ones are signed with the new key. Once a full token lifetime has passed, drop the old key and anything still carrying it stops being accepted.
Warning
The
jwtstrategy signs its tokens with the first key. It must be set and stable there, or every token breaks on restart and across instances (a warning is printed at boot when it isn't).
See also
auth: the strategies that sign things with it.
options.security
Security-related settings, grouped under one option and secure by default:
server() // secure defaults on
server({ security: false }) // turn them all off
server({ security: { frameguard: 'DENY' } }) // override one pieceThe defaults are a set of security response headers on every response (routes, static files, 404s and errors alike), a request-size cap, and a check rejecting route params that climb the path. Cross-origin access is its own option, cors, since it opens access up rather than locking it down.
Pass an object to override or extend individual pieces:
server({
security: {
frameguard: 'DENY',
referrerPolicy: false,
csp: "default-src 'self'",
},
});On by default. Each accepts false to disable it; frameguard, referrerPolicy, and hsts also accept a string to override their value (noSniff and xssProtection are on/off only):
frameguard:X-Frame-Options, defaultSAMEORIGIN. Stops your pages being framed by other sites (clickjacking).noSniff:X-Content-Type-Options: nosniff, default on. Stops the browser MIME-sniffing a response into a different type.referrerPolicy:Referrer-Policy, defaultstrict-origin-when-cross-origin.xssProtection:X-XSS-Protection: 0, default on. Disables the legacy XSS auditor.hsts:Strict-Transport-Security, defaultmax-age=15552000; includeSubDomains. Only sent on production responses, since it applies to HTTPS.
Opt-in, off unless you set them:
csp:Content-Security-Policy. Pass the full policy string.coop:Cross-Origin-Opener-Policy, e.g.same-origin.corp:Cross-Origin-Resource-Policy, e.g.same-origin. Note this can block other origins from embedding yourpublicoruploadsassets.permissionsPolicy:Permissions-Policy, e.g.geolocation=().
A header that a route sets itself is never overridden, so you can still control any of these per route with the headers() reply helper. Setting security: false disables every header above, plus traversalProtection and the maxBodySize limit, but not the trustProxy default.
maxBodySize(number | string | false, default'1mb'): caps how much of a request Server.js holds in memory. Only buffered content counts: JSON, text and url-encoded bodies,rawmode, and multipart text fields. Files never count against it: they stream touploadsand are bounded bymaxFileSizeandmaxTotalSizethere, andstreammode is never capped since its bytes are never buffered. An oversized request is rejected with413 Payload Too Large. Pass a byte count or a size string ('500kb','10mb'), orfalseto disable it.
server({ security: { maxBodySize: '10mb' } });
server({ security: { maxBodySize: false } }); // no limittrustProxy(boolean, defaulttrue): whether to trust thex-forwarded-*headers, which is what makes a proxied deployment see itself correctly. It decidesctx.ip(fromx-forwarded-forandx-real-ip) and the scheme, host and port ofctx.url(fromx-forwarded-proto,x-forwarded-hostandx-forwarded-port). On by default, since most deployments sit behind a reverse proxy or load balancer that terminates TLS: without it the app sees the plainhttp://of the internal hop, so absolute links, redirects and the OAuthredirect_uriall come out wrong. Set it tofalsewhen clients connect directly to your server, otherwise a client could spoof its own IP or origin by sending the headers. When proxies chain, the first value is used, since that is the visitor's own hop. Platform headers that can't be forged (cf-connecting-ip,x-nf-client-connection-ip) are always honored regardless of this setting.
traversalProtection(boolean, defaulttrue): reject requests whose route params point outside where they belong, with a400. That covers params that climb (/files/..%2F..%2F.env) and absolute ones (/files/%2Fetc%2Fhosts), which escape a folder without using any dots. Route params name a resource, so neither shows up unless the value is meant to be re-read as a file path. Nested paths (docs%2Freadme.md) and dots that don't climb (photo..jpg) are unaffected. Turn it off for a route that legitimately receives paths.
server({ security: { trustProxy: false } });
server({ security: { traversalProtection: false } });This checks route params only. Query strings and bodies hold free-form data, where .. can be a valid value, and their contents aren't paths. Code that turns any id into a real file path (a bucket, the filesystem) still needs to keep it inside the intended folder.
Note
CSRF. There's no synchronizer-token middleware here. CSRF is instead mitigated by the
SameSite=Laxsession cookie (a cross-site POST won't carry it) and, for OAuth logins, thestateparameter; thetokenandjwtstrategies are cookie-free, so it doesn't apply to them at all. Add your own check if you need defense-in-depth, like rejecting unsafe-method requests whoseSec-Fetch-Siteheader iscross-site.
See also
cors: opening cross-origin access up, the counterpart to locking down.parser: what themaxBodySizecap applies to.
options.uploads
Where uploaded files are stored. Storing files is explicit: with no uploads, a request that carries one is refused rather than quietly losing it.
server({ uploads: './uploads' }); // a local folder
server({ uploads: bucket.S3(...) }); // any Bucket, e.g. S3 or R2
server({ uploads: { bucket: './uploads', maxFileSize: '5mb' } }); // with checksFiles stream to the destination as they arrive, so they're never buffered whole; see File handling for the full guide. The object form adds validation before storing:
| Field | Type | Description |
|---|---|---|
bucket | path | Bucket | Where to store the files (required) |
maxFileSize | number | string | Largest single file, in bytes or as a string. Default '10mb' |
maxTotalSize | number | string | Largest total across one request's files. Default '100mb' |
maxFiles | number | Most files in one request. Default 100 |
minSize | number | string | Minimum size, same format |
fileType | string[] | Allowed extensions (.jpg) and/or MIME types (image/jpeg), OR logic |
Both size limits apply whichever form you use, so uploads: './uploads' is already bounded. They are enforced as the file streams, so an oversized upload is refused with a 413 and nothing is left behind. minSize is only knowable at the end, so a too-small file is written and then removed, with a 400. A file failing fileType is a 415.
Stored files are named by their content. The key is random, plus the extension of the format its bytes actually are (8Kq2….png), or no extension when they match nothing we recognise. The name the client sent is kept separately as ctx.body.<field>.name, so nothing a client says decides where or under what name its file lands. fileType is checked against the detected format too: a file claiming image/png whose bytes are not a PNG is rejected. Formats without a signature (CSV, SVG, JSON, plain text) can't be detected, so for those the declared type is taken at face value. The same goes for a container: a .docx is a ZIP, so a claim that names what is inside one (Office, OpenDocument, epub) is kept over the container the bytes report.
It also works per route, replacing the global wholesale (limits included), and false skips file fields for one route:
server({ uploads: './uploads' })
.post('/avatar', { uploads: { bucket: './avatars', maxFileSize: '5mb' } }, h)
.post('/survey', { uploads: false }, h);See also
- File handling: the full guide, from parsing to serving files back.
parser:uploadsonly applies in the defaultparsemode.