Documentation

@server/next tests

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/next

That'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:

OptionTypeDescription
bucketpath | BucketWhere to store the files (required)
maxFileSizenumber | stringLargest single file. Default '10mb'
maxTotalSizenumber | stringLargest total across one request. Default '100mb'
maxFilesnumberMost files in one request. Default 100
minSizenumber | stringMinimum size, same format
fileTypestring[]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 in parse mode; ctx.body is 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's ReadableStream as ctx.body and 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 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({ 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.js

Node.js 24+ is required.

Bun

Run with:

bun index.js

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

VariableOptionDefault
PORTport3000
SECRETSsecretsrandom
PUBLICpublicnull
CORScorsnull
AUTHauthnull

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 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 must be configured: 1.x wrote to a temp folder by default; 2.x refuses a request carrying a file until you set uploads, or uploads: false to 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/res objects: the request comes fully parsed on ctx (headers, cookies, body, ip) and the response is the handler's return value, so code reaching into ctx.req/ctx.res (req.pipe, res.writeHead) becomes a plain return.
  • bucket replaces the built-in file adapters for uploads/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.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.req, ctx.resremoved, everything is parsed onto ctx and the response is the return value
ctx.method, ctx.headers, ctx.cookies, ctx.ip, ctx.optionssame

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

Options

1.x option2.x
portsame
faviconremoved, drop a favicon.ico into public, or serve it as a route
secretnow secrets, a list: the first signs, all verify. 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
sessionremoved: there is no per-device data bag. Logins are whatever onLogin stores; keep your own state in a cookie or your database
parseparser, parse/raw/stream; the size cap is security.maxBodySize (1mb default)
views + engineremoved, import a template engine (Templates)
socket (socket.io)removed, native .socket()
envremoved, use ctx.platform.production
,uploads, cors, auth, openapi, onError, onResponse, cache (new)

Environment variables: LOGLOG_LEVEL; VIEWS / ENGINE are gone (templating removed). SECRETSECRETS (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.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, if you need it please build it on top of what we offer. Edge runtimes (Cloudflare, Netlify) don't support sockets. See WebSockets.