Guides

Basic usage

After getting started, there are some important bits that you might want to update:

SECRET: an environment variable with a long, unique random secret. It will be used to sign and/or encrypt things as needed. Put it in .env (+gitignore it) or in your provider's secret manager.

store: almost anything you want to persist will need a KV store to do so. For dev you can use an in-memory or a file-based store, but for production you would normally use something like Redis. Use a polystore instance for this.

uploads: if you want to accept user-uploaded files you need to tell the server where to put them. Pass a path for storing files locally, or a Bucket instance for cloud storage. See File handling for the full guide.

Dependencies

Server.js has no required runtime dependencies. Two optional companion libraries provide the adapters most apps need:

  • polystore powers the store and session options: one key-value interface over an in-memory Map, Redis, DynamoDB, the filesystem, and more. See Stores.
  • bucket powers the uploads, public, and favicon options: one interface over local disk, S3, Cloudflare R2, and others. See File handling.
npm install polystore bucket
bun add polystore bucket

Both are optional: anywhere a store is expected you can pass any object with the Polystore interface, and anywhere a bucket is expected any object with the Bucket interface (file(name) returning a file handle, and folder(prefix)). The companion libraries just save you from writing those adapters.

Middleware

Middleware are plain functions that receive ctx and optionally return a response. You can attach them globally with .use() or per-route inline:

// Global middleware, runs for every request
const logger = (ctx) => {
  console.log(ctx.method, ctx.url.pathname);
};

// Auth guard, returns early with 401 if not logged in
const requireUser = (ctx) => {
  if (!ctx.user) return 401;
};

export default server()
  .use(logger)
  .get('/public', () => 'Anyone can see this')
  .get('/private', requireUser,
    () => 'Logged-in users only');

If a middleware returns a value, the chain stops and that value is sent as the response. If it returns nothing (undefined), the next middleware runs.

See the Router docs for how middleware order and path matching work.

Stores

A store is a key-value interface used to persist sessions, auth tokens, and other data. You can pass any compatible store to the store or session options. We use Polystore to manage the stores, so you'll need to install and pass it:

import kv from 'polystore';

const store = kv(new Map());

export default server({ store })
  .get('/', () => 'Hello');

For production, use a proper store like Redis:

import kv from 'polystore';
import { createClient } from 'redis';

const url = process.env.REDIS_URL;
const store = kv(createClient({ url }).connect());

export default server({ store })
  .get('/', () => 'Hello');

Login with GitHub

A step-by-step walkthrough of adding "Sign in with GitHub" with the auth option. The same steps work for any OAuth provider, swap github for google, discord, and so on. For the full option, strategy and route reference, see Authentication.

1. Register a GitHub OAuth app

In GitHub, go to Settings → Developer settings → OAuth Apps → New OAuth App. Set the Authorization callback URL to https://<your-host>/auth/callback/github (for local development, http://localhost:3000/auth/callback/github). GitHub gives you a Client ID and a Client Secret.

2. Set the environment variables

GITHUB_ID=your-client-id
GITHUB_SECRET=your-client-secret
SECRET=a-long-random-string   # signs the session cookie

3. Configure the server

Auth keeps users and sessions in a store, so pass one along with auth: 'cookie:github':

import server from '@server/next';
import kv from 'polystore';

export default server({
  store: kv(new Map()),      // use Redis or similar in production
  auth: 'cookie:github',
})
  .get('/', (ctx) =>
    ctx.user
      ? `Hi ${ctx.user.name}`
      : '<a href="/auth/login/github">Login with GitHub</a>',
  );

4. Send users to the login route

Link or redirect to GET /auth/login/github. The server runs the OAuth handshake, handles the callback, upserts the user into your store, sets a signed HttpOnly session cookie, and redirects back (to /user by default; change it with the object form's redirect).

5. Read and protect

The signed-in user is on ctx.user on every request (undefined when logged out), so a one-line middleware guards a route:

const requireUser = (ctx) => {
  if (!ctx.user) return 401;
};

export default server({ store, auth: 'cookie:github' })
  .get('/account', requireUser,
    (ctx) => `Hello ${ctx.user.email}`);

Log out with POST /auth/logout, which clears the session.

File handling

By default, file fields in multipart form submissions are silently skipped; only text fields are parsed. To enable uploads, configure the uploads option.

Files are streamed to their destination as they arrive, so even large uploads aren't buffered in memory. Adding validation (below) is the one exception, since a file must be read whole to check its size.

Local storage

Pass a directory path and files are saved there automatically:

export default server({ uploads: './uploads' })
  .post('/profile', (ctx) => {
    console.log(ctx.body.avatar);
    // {
    //   name: 'photo.jpg',         // original filename
    //   id:   'xKj3mN9pQr2s4tUv.jpg', // storage key
    //   path: '/abs/.../xKj3...Uv.jpg', // abs path
    //   type: 'image/jpeg',        // MIME type
    //   size: 45231,               // bytes
    // }
  });

Cloud storage

Pass any object that implements the Bucket interface: file(name) returns a file handle you write(data) / remove() / stream() (where data is a string, Buffer, or ReadableStream), and folder(prefix) returns a sub-scoped bucket. The uploaded-file object shape is identical:

import S3 from 'bucket/s3';

const uploads = S3('my-bucket', {
  id: process.env.S3_ID,
  key: process.env.S3_KEY,
});

export default server({ uploads })
  .post('/profile', (ctx) => {
    console.log(ctx.body.avatar);
    // { name, id, path, type, size }
  });

Multiple values and files

Repeat a field name to collect multiple values into an array on ctx.body (HTML's name="tags[]" works too; the [] is stripped). This applies to files as well, so a repeated file field gives you an array of UploadedFiles.

Validation

To add size or type constraints, pass uploads an object with the destination bucket plus the limits, instead of a bare path:

export default server({
  uploads: {
    bucket: './uploads', // a path or a Bucket
    maxSize: '5mb',
    fileType: ['image/jpeg', 'image/png', '.jpg', '.png'],
  },
})
  .post('/profile', (ctx) => {
    // { name, id, path, type, size }
    console.log(ctx.body.avatar);
  });

If a file fails validation, the request throws before any handler runs.

Object-form options:

OptionTypeDescription
bucketpath | BucketWhere to store the files (required)
maxSizenumber | stringMaximum size in bytes or as a string: '5mb', '500kb'
minSizenumber | stringMinimum size, same format
fileTypestring[]Allowed extensions (.jpg) and/or MIME types (image/jpeg), OR logic

With no maxSize/minSize/fileType set, files stream through unvalidated, the same as passing the bucket directly (uploads: './uploads').

Serving stored files

public serves a folder as open static assets. To serve a file from a bucket behind your own logic (an auth check, an ownership check, a signed link), return it from a normal route instead. Nothing is exposed until your handler says so:

import server, { file, type, redirect } from '@server/next';

export default server({ store, auth: 'cookie:github' })
  .get('/files/:id', async (ctx) => {
    if (!ctx.user) return 401;
    const stored = bucket.file(ctx.url.params.id);
    if (!(await isOwner(ctx.user, stored))) return 403; // your check
    return stored; // streamed back, typed, 404 if missing
  });

Returning the bucket file handle streams it with the file's own Content-Type and a 404 when it doesn't exist. Three shapes work, pick per need:

// 1. The handle: streamed (no buffering), type from the name, 404 if missing
.get('/avatar', () => bucket.file('avatars/me.jpg'))

// 2. Through file(): same, but chainable (add download(), headers(), a status)
.get('/avatar', () => file(bucket.file('avatars/me.jpg')))

// 3. Raw bytes: buffered in memory; set the type yourself (bytes carry no name)
.get('/avatar', async () =>
  type('jpg').send(await bucket.file('avatars/me.jpg').bytes()))

Prefer 1 or 2 (streaming) for large files; use 3 only when you need the bytes in hand.

Signed temporary URLs. Streaming a private file routes its bytes through your server. If your storage provider issues short-lived signed URLs, redirect straight to the object after your auth check so the transfer offloads to the provider:

.get('/files/:id', async (ctx) => {
  if (!ctx.user) return 401;
  const url = await signUrl(bucket, ctx.url.params.id, { expires: '5m' }); // provider-specific
  return redirect(url);
});

Streaming, raw, and large uploads

The default parse mode handles forms and files for you. For the cases it doesn't, set the body option per route:

  • A raw, single-file body. Posting a file as the whole request body (e.g. Content-Type: image/png, no form) is stored as one file in parse mode; ctx.body is its { name, id, path, type, size } reference, just like a form field.
  • Very large files, or a per-request destination. Use body: 'stream' to receive the request's ReadableStream as ctx.body and pipe it to storage yourself, after your own checks, without buffering. bucket().folder(prefix) scopes a sub-path:
import S3 from 'bucket/s3';

const uploads = S3('my-bucket', {
  id: process.env.S3_ID,
  key: process.env.S3_KEY,
});

export default server({ uploads })
  .post('/videos/:id', { body: 'stream' },
    requireUser, async (ctx) => {
      await uploads
        .folder(ctx.url.params.id)
        .file('video.mp4')
        .write(ctx.body);
      return 201;
    });
  • Webhooks or exact bytes. Use body: 'raw' to get the unparsed Buffer (for example, to verify a signature).

WebSockets

Define WebSocket handlers with .socket(event, fn), where event is 'open', 'message', or 'close'. Inside a handler, ctx.socket is the current connection, ctx.sockets is every open connection (for broadcasting), and on a 'message' event ctx.body is the data the client sent.

import server, { file } from '@server/next';

export default server()
  .get('/', () => file('./index.html'))
  .socket('open', (ctx) => {
    ctx.socket.send('welcome');
  })
  .socket('message', (ctx) => {
    // echo back to the sender
    ctx.socket.send(`You said: ${ctx.body}`);
    // ...or broadcast to everyone
    for (const socket of ctx.sockets) {
      socket.send('someone said something');
    }
  })
  .socket('close', () => {
    // a client disconnected
  });

Connect from the browser with the standard WebSocket API:

const ws = new WebSocket(`ws://${location.host}`);
ws.onopen = () => ws.send('hello');
ws.onmessage = (e) => console.log(e.data);

The upgrade is handled for you; you don't open a separate port or socket path. .socket() works the same on Node and Bun. Edge runtimes (Cloudflare and Netlify) don't support long-lived socket connections.

Authenticated sockets

If auth is configured, the connecting user is resolved during the WebSocket handshake and exposed as ctx.user in every socket handler, the same object you get on HTTP routes (undefined when nobody is signed in). A browser sends its session cookie automatically on the upgrade, so the cookie strategy just works; there's nothing extra to pass from the client. As with HTTP, this only loads the user, it does not reject the connection, so guard inside the handler and close() unauthenticated sockets yourself:

export default server({ store, auth: 'cookie:github' })
  .socket('open', (ctx) => {
    if (!ctx.user) return ctx.socket.close();
    ctx.socket.send(`welcome ${ctx.user.name}`);
  });

A browser WebSocket can't set headers, so the cookie strategy authenticates browser sockets; non-browser clients can send an Authorization: Bearer header on the upgrade for the token / jwt / key strategies. A missing or expired credential connects as anonymous (ctx.user undefined); a present-but-invalid one is rejected at the handshake with 401.

Proxying requests

fetch() returns a web Response, and returning one from a route forwards it as-is: status, headers, and a streamed body (never buffered into memory). That's the whole mechanism behind a backend-for-frontend, an API gateway, or hiding an upstream credential from the browser:

export default server()
  .get('/gh/*', (ctx) =>
    fetch(`https://api.github.com${ctx.url.pathname.replace('/gh', '')}`, {
      headers: { authorization: `Bearer ${process.env.GH_TOKEN}` },
    }),
  );

To transform the response instead of forwarding it verbatim, read it and return something new:

.get('/data', async () => {
  const res = await fetch('https://upstream.example/data');
  if (!res.ok) return res.status; // forward the upstream error status
  const data = await res.json();
  return { ...data, cachedAt: new Date().toISOString() };
});

Templates

Server.js has no built-in view engine, and doesn't need one: a route that returns an HTML string is sent as text/html, so any template engine works by returning its rendered output, and none of them is a dependency of the core. Compile your templates once and call them from your routes.

// EJS
import ejs from "ejs";

export default server()
  .get("/users/:id", (ctx) =>
    ejs.renderFile("./views/user.ejs", { id: ctx.url.params.id }),
  );
// Pug: compileFile caches the compiled template
import pug from "pug";

const home = pug.compileFile("./views/home.pug");

export default server().get("/", () => home({ name: "World" }));
// Handlebars: compile once from disk
import Handlebars from "handlebars";
import { readFile } from "node:fs/promises";

const tpl = Handlebars.compile(await readFile("./views/home.hbs", "utf8"));

export default server().get("/", () => tpl({ name: "World" }));

When the rendered output doesn't start with < (a partial or fragment), set the type explicitly:

import { type } from "@server/next";

.get("/fragment", () => type("html").send(tpl(data)));

JSX

JSX is an amazing template language and so Server.js supports it when using Bun. Configure it once in your tsconfig.json, which sets it up for both Bun (the runtime) and your editor (types):

{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "@server/next"
  }
}

Both lines are needed: jsx turns on JSX and the automatic runtime, and jsxImportSource points it at Server.js instead of React. Keep them directly in the file, since Bun doesn't read JSX settings through extends. Then name your files .jsx (or .tsx) and you are ready to go!

import server from '@server/next';

export default server()
  .get('/', () => <Home />)
  .get('/:page', ctx => <Page id={ctx.url.params.page} />);

The main difference from normal JSX is that you include the whole document (the <html>, <body>, etc. tags) since whatever you return is sent as the HTML. The trade-offs:

  • We will send the html fragments unmodified, so () => <div>Hello</div> will render "<div>Hello</div>".
  • Exception: if you define <html>...</html> as the top level, we will automatically inject <!DOCTYPE html>, since it's not possible to inject that with JSX.
  • You also need to define the top level tags and html structure such as <html>, <head>, <body>, etc. We recommend putting those into a template and reusing it, but that's up to your preferences.
  • This is a great match for HTMX!
  • You can use fragments as usual with <></> (but not with <Fragment>).
  • Since you are using JSX, normal interpolation is safe from XSS since any special characters are encoded as their html entities.
  • You can set up raw HTML, e.g. to avoid having inline JS scripts escaped, like this: <script dangerouslySetInnerHTML={{ __html: "alert('hello world');" }}></script>.

Some examples:

export default server()
  .get('/', () => (
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <title>My first app</title>
      </head>
      <body>
        <h1>My first app</h1>
        <form>
          Your name:
          <input name="firstname" />
          <br />
          <button>Send</button>
        </form>
        <script src="https://cdn.jsdelivr.net/gh/franciscop/server-next@master/handle-form.js"></script>
      </body>
    </html>
  ));

Note

You cannot render JSX to a plain string. It is only turned into HTML as the response is sent, so its escaping can never be bypassed by concatenating the result into other markup.

HTMX

There's no explicit HTMX integration, but two approaches work well. First, the vanilla version:

export default server()
  .get('/', () => file('index.html'))
  .post('/action', async (ctx) => {
    // do sth

    return '<div>Success!</div>';
  });

In here, we assume you have a template in index.html and are loading HTMX from there. Then when an action occurs, you can return the raw HTML string.

Warning

If you interpolate strings like that, you might be subject to XSS attacks!

.post('/action', async (ctx) => {
  // DO NOT DO THIS
  return `<div>Success! ${ctx.url.query.name}</div>`;
  // DO NOT DO THIS
});

For that reason we recommend that you set up JSX with Server.js and then instead reply like this:

.post('/action', async (ctx) => {
  // This is safe since html entities will be encoded:
  return <div>Success! {ctx.url.query.name}</div>;
});

Since JSX will treat that interpolation as a text interpolation and not a html interpolation, html entities will be escaped as expected and presented as plain text.

Migration from 1.x

Server.js 2.x is a ground-up rewrite on top of web standards (Request/Response), with no required dependencies and first-class support for Bun, Node, and other platforms. The concepts from 1.x carry over, but the syntax changed.

⚠️ Defaults that changed silently. Check these first. These don't throw an error, so they're easy to miss on a migrated app:

  • No CSRF token middleware (1.x bundled Csurf). SameSite cookies and the OAuth state check still cover the common vector, see security.
  • public is off: 1.x served ./public automatically; 2.x serves nothing until you set public.
  • Uploads are dropped without an uploads destination, file fields are skipped, no error.
  • Option precedence flipped: an explicit server({...}) option now wins over its env var, the reverse of 1.x. Overriding a hardcoded default via env in production no longer takes effect.

Runtime and dependencies

1.x ran on Express (pulling in Express, socket.io, formidable, and more). 2.x is built directly on the web-standard Request/Response, needs no dependencies, and runs unchanged on Bun, Node, and edge runtimes.

  • No Express underneath, server.utils.modern() and raw Express middleware are gone; middleware are plain (ctx) => … functions (see Middleware).
  • ctx.req / ctx.res are the web Request / Response, not Node's, code using req.pipe / res.writeHead needs updating.
  • Optional companions replace the built-in adapters: polystore for store/session, bucket for uploads/public/favicon.

Creating the server

1.x took an options object and an array of routes; 2.x chains routes onto the default export and exports it:

// 1.x
const server = require('server');
const { get, post } = server.router;
server({ port: 3000 }, [
  get('/', (ctx) => 'Hello world'),
  post('/', (ctx) => `Received ${ctx.data}`),
]);

// 2.x
import server from '@server/next';
export default server({ port: 3000 })
  .get('/', (ctx) => 'Hello world')
  .post('/', (ctx) => `Received ${ctx.body}`);

Routing

Router methods are chained onto the server (or a standalone router()) instead of imported from server.router:

1.x2.x
get, post, put, head, optionssame, chained: .get('/x', fn)
del(...).delete(...)
patch.patch(...)
socket(...).socket(event, fn), see WebSockets
error('name', fn)onError option
sub('/prefix', ...)router() + .use()

Error handling is now the single onError option instead of per-name routes; branch on error.code inside it for the namespaced cases. Routing is first-match, one route per request, Express regex paths and /prefix/*-then-specific layering don't apply, so use .use() for shared guards. See Router.

Replies

Return a plain value (string, object, number, Response) instead of a server.reply helper; the helpers still exist but are now imported from the package:

1.x reply2.x
send, json, redirect, file, download, status, typesame, imported from @server/next
header(...)headers(...)
cookie(...)cookies(...)
render(view, locals)removed, import a template engine (Templates)
jsonp(...)removed
,cache(...) (new)

Context (ctx)

Everything about the URL now lives under ctx.url (a URL instance), and the parsed body is always ctx.body:

1.x2.x
ctx.datactx.body
ctx.url (a string)ctx.url (a URL, string operations like ctx.url.includes('?') break)
ctx.paramsctx.url.params
ctx.queryctx.url.query
ctx.pathctx.url.pathname
ctx.filesctx.body (files sit alongside text fields)
ctx.secure, ctx.xhrremoved (derive from ctx.url.protocol / ctx.headers)
ctx.method, ctx.headers, ctx.cookies, ctx.ip, ctx.session, ctx.optionssame

New in 2.x: ctx.user, ctx.platform, ctx.socket / ctx.sockets, ctx.app.

Options

1.x option2.x
port, faviconsame
secretsame, now settable as an option (1.x was env-only)
publicoff by default (1.x served ./public)
securityheaders only, no CSRF middleware
logon/off only, no levels, ctx.log, or report
sessiona KV store, not a config object
parsebody, parse/raw/stream + max (1mb default)
views + engineremoved, import a template engine (Templates)
socket (socket.io)removed, native .socket()
envremoved, use ctx.platform.production
,store, cookies, uploads, cors, auth, openapi, onError, onResponse, cache (new)

Environment variables: LOGLOG_LEVEL; VIEWS / ENGINE are gone (templating removed). PORT, SECRET, PUBLIC, FAVICON, CORS, AUTH still work.

WebSockets

socket.io is replaced by native WebSockets, no dependency, the same on Node and Bun:

1.x (socket.io)2.x (native)
socket('chat', fn) (any event name).socket('open' | 'message' | 'close', fn)
ctx.io, ctx.socketctx.socket (this one), ctx.sockets (all)
payload arg / ctx.datactx.body
ctx.io.emit(...)ctx.socket.send(...), or loop ctx.sockets
socket.io clientbrowser WebSocket

socket.io's rooms, namespaces, named events, reconnection, and transport fallbacks have no equivalent, build what you need yourself. Edge runtimes (Cloudflare, Netlify) don't support sockets. See WebSockets.