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 into ctx.body, and their size limit.
  • cache (duration | number | false): default Cache-Control for 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 and trustProxy.

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:

VariableOptionNotes
PORTportPort to listen on. Defaults to 3000.
SECRETsecretKey used to sign cookies and tokens. Set a long, random value in production.
PUBLICpublicDirectory of static assets to serve.
FAVICONfaviconPath to the icon served at /favicon.ico.
CORScorsAllowed origin(s), same values as the option.
AUTHauthAuth string, e.g. cookie:github.
AUTH_KEYauth.keyShared secret for the key strategy.
LOG_LEVELlogSet 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:

  1. The option passed as a number.
  2. The environment variable, which can come from .env, the CLI, etc.
  3. Port 3000 otherwise.

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:

  1. The option passed as a string.
  2. The SECRET environment variable.
  3. 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 origin

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

  1. The option (boolean, origin string, or object).
  2. The CORS environment variable.
  3. 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 1mb

The three modes:

  • parse (default): the body is parsed by its content-type. JSON and form fields become an object; uploaded files stream to uploads as they arrive (never buffered whole) and ctx.body holds a reference to each. A raw non-form body (say a posted image/png) is streamed to uploads as a single file too.
  • raw: the body is read into a single Buffer and left unparsed, for when you need the exact bytes (for example, verifying a webhook signature).
  • stream: the body is not read at all. ctx.body is the request's web ReadableStream, 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

uploads is where files are stored; body is how the body is read. They are independent: uploads applies only in parse mode and is ignored when you read the body as raw or stream.

options.cache

Response caching, split into two independent parts:

  • A default Cache-Control header, 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 GET 200 response is hashed into a strong ETag; a later request with a matching If-None-Match gets a 304 Not Modified with 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 in

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

ProviderEnv varsLogin route
email(none, uses your store)POST /auth/register/email, POST /auth/login/email
githubGITHUB_ID, GITHUB_SECRETGET /auth/login/github
googleGOOGLE_ID, GOOGLE_SECRETGET /auth/login/google
microsoftMICROSOFT_ID, MICROSOFT_SECRETGET /auth/login/microsoft
discordDISCORD_ID, DISCORD_SECRETGET /auth/login/discord
facebookFACEBOOK_ID, FACEBOOK_SECRETGET /auth/login/facebook
appleAPPLE_ID, APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_PRIVATE_KEYGET /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 .p8 key. Provide the key contents as APPLE_PRIVATE_KEY, your Team ID as APPLE_TEAM_ID, the key's ID as APPLE_KEY_ID, and your Services ID as APPLE_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 enabled or [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 11b

options.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. security covers response headers only, there's no synchronizer-token middleware. CSRF is instead mitigated by SameSite=Lax session/auth cookies (a cross-site POST won't carry them) and, for OAuth logins, the state parameter. Add a token layer yourself if you need that defense-in-depth.

server()                     // secure headers on by default
server({ security: false })  // turn them all off

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, default SAMEORIGIN. 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, default strict-origin-when-cross-origin.
  • xssProtection: X-XSS-Protection: 0, default on. Disables the legacy XSS auditor.
  • hsts: Strict-Transport-Security, default max-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 your public or uploads assets.
  • 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, default true): whether to trust the x-forwarded-for and x-real-ip headers when computing ctx.ip. On by default, since most deployments sit behind a reverse proxy or load balancer. Set it to false when 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 } });