Documentation
A web server for Bun, Node.js and Functions/Workers with the basics built-in:
import server, { status } from "@server/next";
export default server(options)
.get("/books", () => db.books.list())
.post("/books", { body: BookSchema }, async (ctx) => {
const book = await db.books.create(ctx.body);
return status(201).send(book);
});It includes all the things you would expect from a modern Server framework, like routing, static file serving, body+file parsing, streaming, testing, error handling, websockets, etc.
Getting started
First install it:
npm install @server/next
bun add @server/nextThat's everything: 63 login providers and file storage come with it, so auth: 'cookie:github' works with no database and uploads takes a folder path. See Dependencies for S3 and the rest.
Now you can create your first simple server:
// index.js
import server from "@server/next";
export default server()
.get("/", () => "Hello world")
.post("/", (ctx) => {
console.log(ctx.body);
return 201;
});Then run node . or bun . and open your browser on http://localhost:3000/ to see the message.
Routes take plain (ctx) => value functions like these, receiving the context and returning the response; chain several and they become middleware, each running until one responds.
There are some important bits that you might want to update:
SECRETS: 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. It is a comma-separated list, so that a key can be rotated without signing everyone out.
auth: logging in. The one-liner (auth: 'cookie:github') needs no database at all; add the onLogin/getUser callbacks when you want your own user records. There is a tutorial per setup.
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 ships with two libraries included, so there is nothing extra to install: antarctic powers the 63 auth providers, and bucket powers uploads and public. The second is re-exported:
import server, { bucket } from "@server/next";The simple form needs no import: uploads takes a folder path, and auth takes no storage at all. For key-value storage of your own, polystore pairs well (npm install polystore), turning a Map, Redis or DynamoDB into one interface; see the Persisting data tutorial.
Validation is the same story: any Standard Schema library works as a route schema, so zod, valibot or arktype are your dependencies, not ours (valibot users also want @valibot/to-json-schema for the OpenAPI spec).
File handling
Storing files is explicit: with no uploads option, a request that carries a file is refused rather than losing it quietly. Configure uploads to accept them, or uploads: false to ignore file fields on purpose.
Files are streamed to their destination as they arrive, so even large uploads aren't buffered in memory. Size limits are checked chunk by chunk as the bytes flow, and an oversized file is aborted and removed mid-write.
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
// path: 'xKj3mN9pQr2s4tUv.jpg', // key within the bucket
// type: 'image/jpeg', // MIME type
// size: 45231, // bytes
// }
});Store path to find the file again later. It's the file's key inside the bucket, not a location on disk, so it reads the same whether you store files locally or in the cloud. Pass it back to the bucket to read the file or serve it:
import server, { bucket } from "@server/next";
const uploads = bucket.FS('./uploads');
export default server({ uploads })
.get('/photo/:key', (ctx) => uploads.file(ctx.url.params.key));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 server, { bucket } from "@server/next";
const uploads = bucket.S3('my-bucket', {
id: process.env.S3_ID,
secret: process.env.S3_SECRET,
});
export default server({ uploads })
.post('/profile', (ctx) => {
console.log(ctx.body.avatar);
// { name, path, type, size }
});Per-route destinations
The uploads option also works per route, so avatars, videos and documents can go to different folders or buckets with different limits. A route's value replaces the global wholesale (limits included), and false skips file fields for that route:
export default server({ uploads: './uploads' })
.post('/avatar', { uploads: { bucket: './avatars', maxFileSize: '5mb' } },
(ctx) => ctx.body.avatar)
.post('/videos', { uploads: videos }, // an S3 bucket just for these
(ctx) => ctx.body.file)
.post('/survey', { uploads: false }, // no files accepted here
(ctx) => ctx.body);Multiple 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
maxFileSize: '5mb',
fileType: ['image/jpeg', 'image/png', '.jpg', '.png'],
},
})
.post('/profile', (ctx) => {
// { name, 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) |
maxFileSize | number | string | Largest single file. Default '10mb' |
maxTotalSize | number | string | Largest total across one request. 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 |
The two size limits apply however uploads was configured, so uploads: './uploads' is bounded too, and they are enforced while the file streams: an oversized upload is a 413 with nothing left in the bucket.
Stored files are named after what their bytes are, not what the client called them: a random key plus the detected format's extension (8Kq2….png), or no extension when the format isn't recognised. The client's filename stays available as ctx.body.<field>.name. fileType is checked against the detected format, so a file claiming image/png whose bytes aren't a PNG is refused; formats with no signature (CSV, SVG, JSON, plain text) can't be detected, and their declared type is trusted.
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, { bucket } from "@server/next";
const uploads = bucket.FS('./uploads');
export default server({ uploads, auth: 'cookie:github' })
.get('/files/:id', async (ctx) => {
if (!ctx.user) return 401;
const stored = uploads.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', () => uploads.file('avatars/me.jpg'))
// 2. Through file(): same, but chainable (add download(), headers(), a status)
.get('/avatar', () => file(uploads.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 uploads.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 uploads.file(ctx.url.params.id).signedUrl({ expires: '5m' });
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 parser 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, path, type, size }reference, just like a form field.
- Very large files, or a per-request destination. Use
parser: 'stream'to receive the request'sReadableStreamasctx.bodyand pipe it to storage yourself, after your own checks, without buffering.uploads.folder(prefix)scopes a sub-path:
import server, { bucket } from "@server/next";
const uploads = bucket.S3('my-bucket', {
id: process.env.S3_ID,
secret: process.env.S3_SECRET,
});
export default server({ uploads })
.post('/videos/:id', { parser: 'stream' },
requireUser, async (ctx) => {
await uploads
.folder(ctx.url.params.id)
.file('video.mp4')
.write(ctx.body);
return 201;
});- Webhooks or exact bytes. Use
parser: '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({ 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 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 in your tsconfig.json, which sets it up for both Bun (the runtime) and your editor (types):
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@server/next"
}
}Or, if you have no tsconfig.json, the same two settings in bunfig.toml:
jsx = "react-jsx"
jsxImportSource = "@server/next"One or the other, not both; tsconfig.json is the one to reach for, since it also types JSX in your editor. Both lines are needed either way: 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.
Styles
A <style> block is rendered as written. Add minify to strip its comments and whitespace:
<style minify>{`
/* a reset */
body { margin: 0; }
`}</style>
// <style>body{margin:0}</style>Warning
The contents of
<style>and<script>are sent raw, since encoding them would break the CSS or JS. Never interpolate user input into one, since a</style>or a</script>in the value ends it and the rest runs as HTML.
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.
Platforms
Server.js is designed to run the same code unmodified across all supported runtimes and platforms. No adapter imports, no platform-specific entrypoints.
// This file works on Node.js, Bun, Cloudflare
// Workers, and Netlify Functions
import server from "@server/next";
export default server()
.get('/', () => 'Hello world');The same default export adapts to each runtime:
- Node.js: run the file with
node .; served over the built-in HTTP server. - Bun: run it with
bun .; faster, and unlocks JSX and native S3 buckets. - Cloudflare Workers: the default export is a standard fetch handler.
- Netlify Functions: exports a Netlify-compatible handler automatically.
Node.js
The default export default server() also starts an HTTP server and listens on the configured port (default 3000). Run with:
node index.jsNode.js 24+ is required.
Bun
Run with:
bun index.jsNo extra setup needed. Bun also unlocks JSX templates and native S3 bucket support.
Cloudflare Workers
The default export is a standard fetch handler, which is what Cloudflare Workers expect. Deploy as usual with wrangler.
import server from "@server/next";
export default server()
.get('/', () => 'Hello from the edge');Netlify Functions
Server.js exports a callback method compatible with Netlify's function handler signature. The export default is set up automatically.
import server from "@server/next";
export default server()
.get('/', () => 'Hello from Netlify');Environment variables
Regardless of platform, these environment variables are read automatically if set:
| Variable | Option | Default |
|---|---|---|
PORT | port | 3000 |
SECRETS | secrets | random |
PUBLIC | public | null |
CORS | cors | null |
AUTH | auth | null |
See Options › Environment variables for the full list.
Migration from 1.x
Server.js 2.x is a ground-up rewrite on top of web standards (Request/Response), with 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 must be configured: 1.x wrote to a temp folder by default; 2.x refuses a request carrying a file until you set
uploads, oruploads: falseto ignore file fields.- 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 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). - No
req/resobjects: the request comes fully parsed onctx(headers,cookies,body,ip) and the response is the handler's return value, so code reaching intoctx.req/ctx.res(req.pipe,res.writeHead) becomes a plain return. bucketreplaces the built-in file adapters foruploads/public, shipped and re-exported. For key-value storage, polystore is a good companion (npm install polystore); the framework itself stores nothing.
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.req, ctx.res | removed, everything is parsed onto ctx and the response is the return value |
ctx.method, ctx.headers, ctx.cookies, ctx.ip, ctx.options | same |
New in 2.x: ctx.user, ctx.platform, ctx.signal, ctx.socket / ctx.sockets, ctx.app.
Options
| 1.x option | 2.x |
|---|---|
port | same |
favicon | removed, drop a favicon.ico into public, or serve it as a route |
secret | now secrets, a list: the first signs, all verify. 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 | removed: there is no per-device data bag. Logins are whatever onLogin stores; keep your own state in a cookie or your database |
parse | parser, parse/raw/stream; the size cap is security.maxBodySize (1mb default) |
views + engine | removed, import a template engine (Templates) |
socket (socket.io) | removed, native .socket() |
env | removed, use ctx.platform.production |
| , | uploads, cors, auth, openapi, onError, onResponse, cache (new) |
Environment variables: LOG → LOG_LEVEL; VIEWS / ENGINE are gone (templating removed). SECRET → SECRETS (comma-separated). PORT, PUBLIC, CORS, AUTH still work (FAVICON is gone with the option).
WebSockets
socket.io is replaced by native WebSockets, no socket library on either end, 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, if you need it please build it on top of what we offer. Edge runtimes (Cloudflare, Netlify) don't support sockets. See WebSockets.