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:
polystorepowers thestoreandsessionoptions: one key-value interface over an in-memoryMap, Redis, DynamoDB, the filesystem, and more. See Stores.bucketpowers theuploads,public, andfaviconoptions: one interface over local disk, S3, Cloudflare R2, and others. See File handling.
npm install polystore bucket
bun add polystore bucketBoth 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 cookie3. 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:
| Option | Type | Description |
|---|---|---|
bucket | path | Bucket | Where to store the files (required) |
maxSize | number | string | Maximum size in bytes or as a string: '5mb', '500kb' |
minSize | number | string | Minimum size, same format |
fileType | string[] | 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 inparsemode;ctx.bodyis 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'sReadableStreamasctx.bodyand 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 unparsedBuffer(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
statecheck still cover the common vector, seesecurity.publicis off: 1.x served./publicautomatically; 2.x serves nothing until you setpublic.- Uploads are dropped without an
uploadsdestination, 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.resare the webRequest/Response, not Node's, code usingreq.pipe/res.writeHeadneeds updating.- Optional companions replace the built-in adapters:
polystoreforstore/session,bucketforuploads/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.x | 2.x |
|---|---|
get, post, put, head, options | same, 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 reply | 2.x |
|---|---|
send, json, redirect, file, download, status, type | same, 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.x | 2.x |
|---|---|
ctx.data | ctx.body |
ctx.url (a string) | ctx.url (a URL, string operations like ctx.url.includes('?') break) |
ctx.params | ctx.url.params |
ctx.query | ctx.url.query |
ctx.path | ctx.url.pathname |
ctx.files | ctx.body (files sit alongside text fields) |
ctx.secure, ctx.xhr | removed (derive from ctx.url.protocol / ctx.headers) |
ctx.method, ctx.headers, ctx.cookies, ctx.ip, ctx.session, ctx.options | same |
New in 2.x: ctx.user, ctx.platform, ctx.socket / ctx.sockets, ctx.app.
Options
| 1.x option | 2.x |
|---|---|
port, favicon | same |
secret | same, now settable as an option (1.x was env-only) |
public | off by default (1.x served ./public) |
security | headers only, no CSRF middleware |
log | on/off only, no levels, ctx.log, or report |
session | a KV store, not a config object |
parse | body, parse/raw/stream + max (1mb default) |
views + engine | removed, import a template engine (Templates) |
socket (socket.io) | removed, native .socket() |
env | removed, use ctx.platform.production |
| , | store, cookies, uploads, cors, auth, openapi, onError, onResponse, cache (new) |
Environment variables: LOG → LOG_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.socket | ctx.socket (this one), ctx.sockets (all) |
payload arg / ctx.data | ctx.body |
ctx.io.emit(...) | ctx.socket.send(...), or loop ctx.sockets |
| socket.io client | browser 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.