Options
The server(options) function initializes a server instance with a variety of configuration options. These options allow you to customize server behavior and integrate essential features. Below is a detailed description of each option available:
port(number): port the server listens on.secret(string): key used to sign cookies and tokens.public(path | Bucket): directory or bucket of static assets.uploads(path | Bucket): where uploaded files are stored.body(options): how request bodies are read intoctx.body, and their size limit.cache(duration | number | false): defaultCache-Controlfor GET responses, plus auto-ETag.store(Store): general-purpose key-value store.session(Store): store for user sessions.cors(options): Cross-Origin Resource Sharing settings.auth(options): authentication method and provider, e.g.cookie:github.cookies(Store): store for signed cookie persistence.onError(function): handler for uncaught middleware errors.onResponse(function): hook over every outgoing response.log(options): built-in startup, connection and request logs.favicon(path | BucketFile): icon served at/favicon.ico.security(options): secure-by-default response headers andtrustProxy.
Usage Example
import server from '@server/next';
export default server({
port: 3000,
secret: 'your-secret-key',
public: './public',
uploads: './uploads',
store: kv(new Map()),
cors: true,
auth: 'cookie:github',
});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:
| Variable | Option | Notes |
|---|---|---|
PORT | port | Port to listen on. Defaults to 3000. |
SECRET | secret | Key used to sign cookies and tokens. Set a long, random value in production. |
PUBLIC | public | Directory of static assets to serve. |
FAVICON | favicon | Path to the icon served at /favicon.ico. |
CORS | cors | Allowed origin(s), same values as the option. |
AUTH | auth | Auth string, e.g. cookie:github. |
AUTH_KEY | auth.key | Shared secret for the key strategy. |
LOG_LEVEL | log | Set to info to turn on logging. |
NODE_ENV | (none) | production enables production behavior, such as Secure cookies. |
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.
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.secret
The key used to sign and encrypt cookies and tokens. Keep it long, random and private. Defaults to the SECRET environment variable; if neither is set, an unsafe- prefixed value is generated, which is fine for local development but not for production.
It must be set and stable for the jwt strategy, whose tokens are signed with it, otherwise a per-process secret invalidates every token on restart.
server({ secret: process.env.SECRET });Order of reading:
- The option passed as a string.
- The
SECRETenvironment variable. - A generated
unsafe-value otherwise.
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 originFor finer control, pass an object:
server({
cors: {
origin: 'https://app.example.com',
credentials: true,
},
});With credentials: true, the request's origin is reflected back instead of * (the wildcard isn't allowed alongside credentials). Preflight OPTIONS requests are answered automatically.
Order of reading:
- The option (
boolean, origin string, or object). - The
CORSenvironment variable. - Off otherwise.
options.public
A directory or Bucket served as static assets, such as images, CSS and JavaScript files. Requests are matched against it by path and returned with the right content type. Served with Cache-Control, 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. Off by default.
server({ public: './public' });options.uploads
Where uploaded files are stored: a local directory path, a Bucket for cloud storage like S3 or R2, or an object { bucket, maxSize, minSize, fileType } that adds size and type checks before storing. When it's not set, file fields in multipart submissions are silently skipped. See File handling.
server({ uploads: './uploads' });options.body
Controls how the request body is read into ctx.body. The value is a mode (parse | raw | stream, default parse), or an object { mode, max }:
server() // defaults to `parse`
server({ body: 'raw' }) // read it into a single Buffer
server({ body: 'stream' }) // receive the raw stream
server({ body: { max: '1mb' } }) // capped at 1mbThe 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', { body: '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', { body: 'stream' }, async (ctx) => {
const id = ctx.url.params.id; // validate this as well
await bucket.file(`${id}.mp4`).write(ctx.body);
return 201;
});Limiting the body size
max caps how much of a request Server.js holds in memory, defaulting to '1mb'. Only buffered content counts: JSON, text and url-encoded bodies, raw mode, and multipart text fields; files stream to uploads and are limited by the uploads object form instead. An oversized request is rejected with 413 Payload Too Large. Pass a byte count or a size string ('500kb', '10mb'), or max: false to disable it. stream mode is never capped, since its bytes are never buffered.
server({ body: { max: '1mb' } }) // global default
.post('/proxy', { body: { max: false } }, h); // no limit for this route
uploadsis where files are stored;bodyis how the body is read. They are independent:uploadsapplies only inparsemode and is ignored when you read the body asraworstream.
options.cache
Response caching, split into two independent parts:
- A default
Cache-Controlheader, set from this option. It tells the browser (and any CDN) how long the response is fresh, so it isn't re-requested at all during that window. Off by default. - An automatic
ETag, always on. Any buffered GET200response is hashed into a strongETag; a later request with a matchingIf-None-Matchgets a304 Not Modifiedwith no body, so the bytes travel only when they've actually changed. Streaming responses are skipped, since they can't be hashed without buffering.
The value is a duration ('1h', '7d'), a number of seconds, or false/0 for no-store. It sets Cache-Control: public, max-age=<seconds>, and only applies to GET responses with a 200 status, so mutations and errors are never cached. For anything more specific (private, s-maxage, immutable, ...), set the header yourself with headers().
// 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 `body`)
.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)))
// full control: just set the header
.get('/avatar', () => headers('cache-control', 'private, max-age=300').file('./me.png'));Precedence is cache() (a reply helper, 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.
options.store
A general-purpose key-value store used for sessions, auth, cookies and your own data. Pass any polystore instance, backed by an in-memory Map, Redis, DynamoDB, the filesystem, and more. Off by default, though several features (like auth) need it. See Stores.
import kv from 'polystore';
server({ store: kv(new Map()) });options.session
The store used for user sessions. Defaults to your store prefixed with session:, so you rarely set it directly; pass it only to keep sessions in a separate store.
server({ store, session: sessionStore });options.auth
Configures authentication and loads the signed-in user onto ctx.user. The string form is <strategy>:<provider> (e.g. cookie:google); for several providers use { strategy, providers: [...] }. Defaults to the AUTH environment variable, or off. See the full Authentication guide.
export default server({ store, auth: 'cookie:google' })
.get('/me', (ctx) => ctx.user || 401);
// visit /auth/login/google to sign inThis option only loads the user; it does not gate any route. Protect the routes you want by checking ctx.user yourself (if (!ctx.user) return 401), as shown in Protecting routes.
Strategies are cookie (a session cookie), token (an opaque bearer token), and jwt (a self-contained, signed bearer token). For machine-to-machine access, the provider-less key strategy checks a shared secret from AUTH_KEY (or auth.key) against an Authorization: Bearer <key> header.
Each provider registers its own routes and reads its credentials from environment variables:
| Provider | Env vars | Login route |
|---|---|---|
email | (none, uses your store) | POST /auth/register/email, POST /auth/login/email |
github | GITHUB_ID, GITHUB_SECRET | GET /auth/login/github |
google | GOOGLE_ID, GOOGLE_SECRET | GET /auth/login/google |
microsoft | MICROSOFT_ID, MICROSOFT_SECRET | GET /auth/login/microsoft |
discord | DISCORD_ID, DISCORD_SECRET | GET /auth/login/discord |
facebook | FACEBOOK_ID, FACEBOOK_SECRET | GET /auth/login/facebook |
apple | APPLE_ID, APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_PRIVATE_KEY | GET /auth/login/apple |
Every OAuth provider (all except email) exposes GET /auth/login/<provider> to start the flow and /auth/callback/<provider> for the redirect back (a GET, except Apple which uses POST). Point each provider's OAuth redirect URI at https://<your-host>/auth/callback/<provider>.
Apple needs a little more setup: its client secret is a short-lived ES256 JWT, signed from your
.p8key. Provide the key contents asAPPLE_PRIVATE_KEY, your Team ID asAPPLE_TEAM_ID, the key's ID asAPPLE_KEY_ID, and your Services ID asAPPLE_ID.
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 or missing credentials respond with 401 (a failed OAuth state check is 403).
options.cookies
A store used to persist signed cookies across requests. When it's not set, cookies are stored client-side only and are not signed.
server({ cookies: store });options.onError
A handler invoked whenever a middleware throws an unhandled error. It receives the error and the current ctx, and must return a Response. Without it, the default responds with the error's message and its status (or 500 when none is set).
import server, { status } from '@server/next';
export default server({
onError: (error, ctx) => {
console.error(error);
return status(500).json({ error: error.message });
},
});options.onResponse
A hook over every outgoing HTTP response: routes, static files, 404s, and onError output alike (never WebSocket traffic). It receives 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.
options.log
Enables Server.js' built-in logging. Off by default; set it to 'info', or use the LOG_LEVEL=info environment variable, to log three things, all prefixed with [server:<scope>]:
- Options: one line per configured module on startup, e.g.
[server:auth] github auth enabledor[server:uploads] ./uploads. - Connection: the server URL once it starts listening, e.g.
[server:start] http://localhost:3000/. - Requests: one line per request, e.g.
[server:api] POST /hello 1kb → 200 OK 10kb(redirects also show their target).
export default server({ log: 'info' })
.get('/', () => 'Hello world');
// [server:start] http://localhost:3000/
// [server:api] GET / → 200 OK 11boptions.favicon
The icon served at /favicon.ico, as a file path (read from disk) or a bucket file (e.g. from S3). When set, a GET /favicon.ico route is registered: the bytes are read once on the first request and cached until restart, then served with Cache-Control and an ETag (so conditional requests get a 304). A favicon.ico in your public folder still wins. When it's not set, no route is registered, so your own /favicon.ico route (if any) handles it, and otherwise it's a normal 404.
// from disk
server({ favicon: './assets/favicon.ico' });
// from a bucket
server({ favicon: bucket.file('favicon.ico') });options.security
Security-related settings, grouped under one option. Server.js sends a set of secure-by-default response headers on every response (routes, static files, 404s and errors alike). Pass security: false to turn them all off, or an object to override or extend individual pieces.
CSRF.
securitycovers response headers only, there's no synchronizer-token middleware. CSRF is instead mitigated bySameSite=Laxsession/auth cookies (a cross-site POST won't carry them) and, for OAuth logins, thestateparameter. Add a token layer yourself if you need that defense-in-depth.
server() // secure headers on by default
server({ security: false }) // turn them all offPass 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, but not the trustProxy default below.
trustProxy(boolean, defaulttrue): whether to trust thex-forwarded-forandx-real-ipheaders when computingctx.ip. On by default, since most deployments sit behind a reverse proxy or load balancer. Set it tofalsewhen clients connect directly to your server, otherwise a client could spoof its own IP by sending the header. Platform headers that can't be forged (cf-connecting-ip,x-nf-client-connection-ip) are always honored regardless of this setting.
server({ security: { trustProxy: false } });