🔝

Documentation

⚠️ WIP This is an experimental library right now!

A web server for Bun, Node.js and Functions/Workers with the basics built-in:

import server from "@server/next";

export default server(options)
  .get("/books", () => Book.list())
  .post("/books", BookSchema, (ctx) => {
    return Book.create(ctx.body).save();
  });

It includes all the things you would expect from a modern Server framework, like routing, static file serving, body+file parsing, gzip+brotli, streaming, testing, error handling, websockets, etc.

We also have integrations and adaptors for these:

  • KV Stores: in-memory, Redis, Consul, DynamoDB, Level.
  • Buckets: AWS S3, Cloudflare R2, Backblaze B2.
  • Validation libraries: Zod, Joi, Yup, Validate, etc.
  • Auth: JWT, Session, Cookies, Social login.
// index.test.js
// How to test your server with the built-in methods
import app from "./";  // Import your normal app
const api = app.test();  // Very convenient helper, AXIOS-like interface

it("can retrieve the book list", async () => {
  const { data: books } = await api.get("/books/");
  expect(books[0]).toEqual({ id: 0, name: ... });
});

Getting started

First install it:

npm install @server/next
bun install @server/next

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.

There's an optional but highly recommended dependency, polystore, for managing sessions, auth, etc:

npm install polystore

There are some major configuration options that you might want to set up though, enumerated in the Basic Usage and explained through the docs.

Guides

Basic usage

Now that you know how to create a barebones server, there are some important bits that you might want to update.

SECRET: create an .env file (ignored in git with .gitignore) with the SECRET= and then a long, unique random secret. It will be used to sign and/or encrypt things as needed.

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 (easy for debugging!) store, but for production systems you would normally use something like Redis. Can be as easy as const store = new Map(); for dev.

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

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.

Validation

⚠️ WIP

Server.js accepts a schema as the second argument to any route method. When provided, the request body is validated against it before the middleware runs. If validation fails, a 400 is returned automatically.

import { z } from 'zod';

const UserSchema = z.object({
  name: z.string(),
  email: z.string().email(),
});

export default server()
  .post('/users', UserSchema, (ctx) => {
    // ctx.body is validated and typed
    return createUser(ctx.body);
  });

Compatible with Zod, Joi and Yup.

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');

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.

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',                  generated storage key
    //   path: '/abs/path/to/uploads/xKj3...Uv.jpg',     absolute path on disk
    //   type: 'image/jpeg',                              MIME type from client
    //   size: 45231,                                     bytes
    // }
  });

Cloud storage

Pass any object that implements the Bucket interface (read, write, delete). The 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 }
  });

Validation with upload()

Import the upload() builder to add size or type constraints before files are stored:

import { upload } from '@server/next';

const avatarUpload = upload('./uploads').limit({
  maxSize: '5mb',
  fileType: ['image/jpeg', 'image/png', '.jpg', '.png'],
});

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

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

limit() options:

Option Type Description
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

You can also separate the destination from the limits using store():

const avatarUpload = upload()
  .limit({ maxSize: '5mb', fileType: ['.jpg', '.png'] })
  .store(myBucket);

JSX

JSX is an amazing template language and so Server.js supports it when using Bun. To use it in your project, create a bunfig.toml with this content:

jsx = "react-jsx"
jsxImportSource = "@server/next"

Then call your files .jsx 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 to be aware of compared to normal JSX is that you need to include everything, that is the <html> tag, <body> tag, etc. since we will send whatever is passed as the html. This has more advantages than disadvantages so we thought it was worth it, but there are definitely 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 injext 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 ).
  • 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="/handle-form.js"></script>
      </body>
    </html>
  ));

HTMX

While we don't have an explicit HTMX integration, we believe there's two ways of using HTMX with Server.js that are really good! First let's see 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.

⚠️ However, 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.

Server Options

The server(options) function initializes a server instance with a variety of configuration options. These options allow you to customize server behavior and integrate essential features. Below is a detailed description of each option available:

  • port: number

    • Specifies the port number on which the server will listen for incoming connections.
  • secret: string

    • A secret key used for securing various operations, such as session data encryption or token signing.
  • views: string | Bucket

    • Path or bucket location where view templates are stored.
  • public: string | Bucket

    • Directory or bucket containing static assets like CSS, JavaScript, and images.
  • uploads: string | Bucket | UploadPipeline

    • Directory, bucket, or upload pipeline used for storing user-uploaded files. See File handling.
  • store: Store

    • A key-value store for general-purpose storage, used for caching or temporary data.
  • session: Store

    • A specialized store for managing user sessions, often involving authentication data.
  • cors: boolean | Origin | Cors

    • Configures Cross-Origin Resource Sharing (CORS) settings. Can be a simple boolean, a specific domain, or a comprehensive CORS configuration object.
  • auth: AuthString | Auth

    • Defines authentication settings, specifying the method and provider such as cookie:github or token-based auth.
  • cookies: Store

    • A key-value store for signed cookie persistence.
  • onError: (error, ctx) => Response

    • Custom error handler for unhandled errors thrown in middleware.
  • openapi: object (WIP)

    • Enables an auto-generated OpenAPI spec at a configured path.
Usage Example
import server from './path/to/server';

export default server({
  port: 3000,
  secret: 'your-secret-key',
  views: './views',
  public: './public',
  uploads: './uploads',
  store: kv(new Map()),
  session: kv(new Map()),
  cors: {
    origin: '*',
    methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
    headers: 'Content-Type,Authorization',
    credentials: true,
  },
  auth: 'cookie:github',
});
port
  • Type: number
  • Default: Value of the PORT environment variable or 3000.
  • Description: Specifies the port number on which the server listens for incoming connections. It serves as the gateway through which users access your server's services. Setting the correct port is crucial, especially in a production environment where multiple services may run concurrently.
secret
  • Type: string
  • Default: Value of the SECRET environment variable or generates an unsafe- prefixed ID.
  • Description: Used for securing operations such as cookie signing and session management. The secret must be safeguarded to prevent unauthorized access or tampering with sensitive data.
cors
  • Type: boolean | string | Origin | Cors
  • Default: Value of the CORS environment variable or null.
  • Description: Defines Cross-Origin Resource Sharing (CORS) policy, crucial for enabling your server to respond to requests from different domains. The policy can be a simple boolean to allow any origin, a specific origin string, or a more detailed CORS configuration object. By default, it uses broad methods and headers if not explicitly set.
views
  • Type: string | Bucket
  • Default: null
  • Description: Specifies the location of your view templates, either as a filesystem path or a Bucket instance. Views are responsible for rendering dynamic content based on data and templates.
public
  • Type: string | Bucket
  • Default: null
  • Description: Indicates the directory or bucket for serving static assets such as images, CSS, and JavaScript files. This setup allows efficient and organized delivery of static resources to the client.
uploads
  • Type: string | Bucket | UploadPipeline
  • Default: null
  • Description: Configures where uploaded files are stored. Pass a local directory path (string) for local storage, a Bucket object for cloud storage, or an UploadPipeline (from upload()) to add size and type validation before storing. When not set, file fields in multipart submissions are silently ignored. See File handling for the full guide.
store
  • Type: Store
  • Default: null
  • Description: A key-value store for caching or temporary data storage. This option is beneficial for enhancing performance by reducing redundant data fetching operations.
session
  • Type: Store
  • Default: Uses store prefixed with "session:" if not explicitly set.
  • Description: A store dedicated to user session management, helping maintain stateful interactions across requests.
auth

Experimental

  • Strategy: AuthString | Auth
  • Default: Value of the AUTH environment variable or null.
  • Description: Configures authentication options to manage user identities and permissions effectively. Supports cookie and token-based authentication, choosing from providers like GitHub and email.
cookies
  • Type: Store
  • Default: null
  • Description: A key-value store used to persist signed cookies across requests. If not set, cookies are stored client-side only and are not signed.
onError
  • Type: (error: Error, ctx: Context) => Response | Promise<Response>
  • Default: null
  • Description: A custom error handler invoked whenever an unhandled error is thrown inside a middleware. Receives the error and the current context, and must return a Response. If not set, Server.js returns a generic 500 response.
import server, { status, json } from '@server/next';

export default server({
  onError: (error, ctx) => {
    console.error(error);
    return status(500).json({ error: error.message });
  },
});
openapi

⚠️ WIP

  • Type: object
  • Default: null
  • Description: Enables an auto-generated OpenAPI spec endpoint. When set, a /docs route (or the path you configure) is registered that serves the OpenAPI JSON derived from your routes.
export default server({
  openapi: { path: '/docs' },
})
  .get('/users', { tags: 'users', title: 'List users' }, () => User.list());
Additional Considerations
  • Bucket: Provides an interface for data operations, supporting storage flexibility across different systems.
  • CORS Policy: Includes pre-defined methods and headers to handle cross-origin requests smoothly.
  • Environment Variables: Many options can be configured via environment variables, allowing dynamic configuration in different deployment environments.

This detailed overview of each option provides you with the flexibility and control needed to tailor your server's configuration to fit diverse application requirements.

Router

The routes can be created in two ways; through the main server(), or through the router() (from import { router } from '@server/next';).

For simple applications just using the `server()` instance is usually enough and recommended. But once you start to have too many routes (a good rule of thumb is 10 routes), it's convenient to split into different files.

Let's see first a simple example of how to define different routes:

import server from '@server/next';

export default server()
  .get('/', () => 'Hello from the homepage')
  .get('/info', () => '<p>Greetings from the info page</p>')
  .post('/contact', ctx => {
    console.log(ctx.body);
    return 'Message sent successfully';
  })
  .put('/users/:id', ctx => {
    console.log(ctx.url.params.id, ctx.body);
    return 200;
  });

A route is composed of 3 parts: the method, the path, and the middleware:

.METHOD(PATH, ...MIDDLEWARE)

The method can be any of the well-known HTTP methods:

  • get(): Used to request data from a specified resource. Does not accept a body.
  • post(): Used to send data to a server to create/update a resource. Accepts a body.
  • put(): Used to update a current resource with new data. Accepts a body.
  • patch(): Used to apply partial modifications to a resource. Accepts a body.
  • delete(): Used to delete a specified resource. Does not usually accept a body.
  • options(): Used to describe the communication options for the target resource. Does not accept a body.
  • head(): Similar to GET, but it requests a response without the body content. Does not accept a body.

The path is detailed in the path documentation, but generally is one of these: omitted, an exact match, a parameter-based path, or a wildcard path:

  • omitted: it will match any path, in the router it is the same as *
  • /info: exact match to localhost:3000/info
  • /posts/:id: a match by parameter
  • /posts/:id(number): a match by parameter, including types
  • /posts/*: a match to any sub-path that has not been previously matched

Finally, the middleware is a function that gets called when both the method and paths match the current request. There are two types, one is a simple middleware that does not return anything; no return, return undefined, return null or return false all work like that. These will be called in sequence as detailed below, until finding a middleware that returns something or there is no middleware left.

export default server()
  .use(middleware1)
  .get('/', middleware2, middleware3, middleware4);

In this example, when opening http://localhost:3000/ in the browser, the middleware will be called in order of middleware1, then middleware2, then middleware3, and finally middleware4. This is assuming 1, 2, and 3 do not return, because if they return then it'll not be called.

If in that example middleware 4 returns something, that will be used to respond to the browser. If it does not, a 404 will be triggered, since once a method is matched it will not continue to other methods.

For express users

There are 2 key differences between express.js and server.js, with the goal of making it very obvious what paths are being matched by requests:

  • Paths do not match subpaths by default. This means that if you write .get('/') and the browser calls /info, that path will not get matched. If you want that funcionality, please be more explicit and write .get('/*').
  • Only one HTTP method match for server.js. Once a method is matched, no other method can be matched. This means that if you write .get('/*') and then .get('/info'), that one can never be matched since all the paths go into the first one. For this, please write the .get('/info') paths first.

These are made so that it's immediately clear what is being matched when. There are few small disadvantages to be aware because of this:

It's not so easy to do partial matches, e.g. if you want to do .get('/admin/*', authMiddleware).get('/admin/settings', ...) that wouldn't work because only the first method is matched. We prefer explicit controls in server.js over some automation. You can then do either:

// More explicit controls
export default server()
  .get('/admin/settings', authMiddleware, settingsReply)
  .get('/admin/users', authMiddleware, usersReply);


// Or keep it
export default server()
  .use(ctx => {
    // Only for GET methods
    if (ctx.method !== 'get') return;
    // Ignore non-admin paths
    if (ctx.url.pathname.startsWith('/admin/')) return;

    // Apply the auth logic
    // ...
  })
  .get('/admin/settings', settingsReply)
  .get('/admin/users', usersReply);

But for the second example, we strongly recommend going directly to the router:

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

const authRoutes = router(authMiddleware)
  .get('/admin/settings', settingsReply)
  .get('/admin/users', usersReply);

export default server()
  .use('/admin/*', authRoutes);

Parameters

The route parameters are represented in the path as /:name and then are passed as a string to ctx.url.params.name (see url docs as well):

export default server()
  .get('/:page', ctx => ({
    page: ctx.url.params.page,
    name: ctx.url.query.name
  }));

// test.js
const { body } = await app.test().get('/hello?name=Francisco');
expect(body).toEqual({ page: 'hello', name: 'Francisco' });

Parameters can also have types explicitly set, in which case they will be casted to that type. There's only 3 possible types, string (default), number and date:

// When requesting `/users/25
export default server().get('/users/:id(number)', ctx => {
  console.log(ctx.url.path, ctx.url.params.id, typeof ctx.url.params.id);
  // /users/25  25  number
});

// When requesting `/calendar/2025-01-01`
export default server().get('/calendar/:day(date)', ctx => {
  const day = ctx.url.params.day;
  console.log(day, day instanceof Date);
  // Date('2025-01-01)  true
});

Wildcards

By default path matches are exact (unlike express.js):

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

export default server()
  .get('/', ctx => view('home.html'))
  .get('/info', ctx => view('info.html'));

To match any sub-path, use *:

export default server()
  .get('/files/*', (ctx) => `Requested: ${ctx.url.pathname}`);

Using router()

For larger applications, split your routes into separate files using router():

// routes/users.js
import { router } from '@server/next';

export default router()
  .get('/', () => User.list())
  .get('/:id(number)', (ctx) => User.find(ctx.url.params.id))
  .post('/', (ctx) => User.create(ctx.body))
  .put('/:id(number)', (ctx) => User.update(ctx.url.params.id, ctx.body))
  .delete('/:id(number)', (ctx) => User.delete(ctx.url.params.id));
// index.js
import server from '@server/next';
import usersRouter from './routes/users.js';

export default server()
  .use('/users/*', usersRouter);

The router's paths are relative — / inside the router becomes /users/ in the app.

Route options

Each route method optionally accepts a plain options object as its second argument. These are used to annotate routes for OpenAPI/documentation purposes:

export default server()
  .get('/users', { tags: 'users', title: 'List users' }, () => User.list())
  .post('/users', { tags: 'users', title: 'Create user' }, UserSchema, (ctx) => User.create(ctx.body));

Available options:

  • tags: string | string[] — group routes in the OpenAPI docs
  • title: string — short route summary
  • description: string — longer description

Context

Every middleware receives a ctx object as its only argument. It contains everything about the incoming request, the server configuration, and some useful helpers.

export default server()
  .get('/hello', (ctx) => {
    console.log(ctx.method);  // 'get'
    console.log(ctx.url.pathname);  // '/hello'
    return 'Hello world';
  });

ctx.method

The HTTP method of the request, lowercased:

.get('/example', (ctx) => {
  console.log(ctx.method);  // 'get'
});

Possible values: 'get', 'post', 'put', 'patch', 'delete', 'head', 'options'.

ctx.url

The full URL of the request, extended with params and query:

.get('/users/:id', (ctx) => {
  console.log(ctx.url.pathname);     // '/users/42'
  console.log(ctx.url.params.id);    // '42'
  console.log(ctx.url.query.page);   // '2'  (from ?page=2)
  console.log(ctx.url.hostname);     // 'localhost'
});
  • ctx.url.params: URL path parameters, e.g. /users/:idctx.url.params.id. Types can be inferred; see Parameters.
  • ctx.url.query: Parsed query string as a plain object, e.g. ?page=2&sort=asc{ page: '2', sort: 'asc' }.
  • All standard URL properties are available (pathname, hostname, href, origin, etc.).

ctx.body

The parsed request body. Available for POST, PUT, and PATCH requests.

.post('/users', (ctx) => {
  console.log(ctx.body.name);   // 'Francisco'
  console.log(ctx.body.email);  // '[email protected]'
});
  • For application/json requests, it is the parsed JSON value.
  • For application/x-www-form-urlencoded or multipart/form-data text fields, it is a plain object of field values.
  • For file uploads, the field value is an UploadedFile object { name, id, path, type, size } — see File handling.
  • undefined for methods that do not send a body (GET, DELETE, etc.).

ctx.headers

The request headers as a plain object, with lowercased keys:

.get('/', (ctx) => {
  console.log(ctx.headers['content-type']);  // 'application/json'
  console.log(ctx.headers['authorization']); // 'Bearer ...'
});

To set headers in the response, use the headers() reply helper.

ctx.cookies

The parsed request cookies as a plain object:

.get('/', (ctx) => {
  console.log(ctx.cookies.token);  // 'abc123'
});

To set cookies in the response, use the cookies() reply helper.

ctx.session

The session data for the current user. The session is persisted in the store configured via the session option. An empty object {} when no session exists.

export default server({ session: store })
  .post('/login', (ctx) => {
    ctx.session.userId = 42;
    return 200;
  })
  .get('/me', (ctx) => {
    return { id: ctx.session.userId };
  });

You can type the session by passing a generic to server():

type MySession = { userId: number };

export default server<MySession>()
  .get('/me', (ctx) => {
    // ctx.session.userId is typed as number
    return { id: ctx.session.userId };
  });

ctx.user

⚠️ WIP Requires the auth option to be configured.

The authenticated user, populated when a user is logged in. undefined when not authenticated.

.get('/profile', (ctx) => {
  if (!ctx.user) return 401;
  return { name: ctx.user.name, email: ctx.user.email };
});

Standard fields always present on ctx.user:

  • id: unique user identifier
  • email: user email
  • provider: the auth provider used ('github', 'email', etc.)
  • strategy: the auth strategy ('cookie', 'jwt', 'token')

You can type the user by passing a second generic to server():

type MyUser = { id: number; name: string; email: string };

export default server<{}, MyUser>()
  .get('/profile', (ctx) => {
    return ctx.user?.name;
  });

ctx.options

The resolved server settings (the processed version of the options passed to server()):

.get('/', (ctx) => {
  console.log(ctx.options.port);   // 3000
});

Mainly useful in middleware that needs to inspect server configuration.

ctx.platform

Information about the runtime environment:

.get('/', (ctx) => {
  console.log(ctx.platform.runtime);     // 'node' | 'bun' | 'cloudflare' | ...
  console.log(ctx.platform.production);  // true | false
  console.log(ctx.platform.provider);    // 'netlify' | 'aws' | null | ...
});

ctx.socket

⚠️ WIP

The active WebSocket connection. Only available inside .socket() route handlers.

ctx.sockets

⚠️ WIP

The list of all active WebSocket connections. Useful for broadcasting messages.

ctx.events

⚠️ WIP

An event emitter scoped to the current request lifecycle.

Reply

Middleware can return different types of values to send a response. For simple cases you can return a value directly; for more control, Server.js exports reply helpers that can be chained together.

Inline replies

The simplest way to reply is to just return a value from your middleware:

// Plain string → text/plain (or text/html if it starts with '<')
.get('/text', () => 'Hello world')
.get('/html', () => '<h1>Hello world</h1>')

// Object or array → application/json
.get('/json', () => ({ hello: 'world' }))

// HTTP status code shorthand (empty body)
.post('/create', () => 201)

// A web-standard Response object
.get('/custom', () => new Response('ok', { status: 200 }))

Reply helpers

For more control, import and use the reply helpers. They can be used standalone or chained:

import server, { status, json, redirect, headers, cookies, type, download, file } from '@server/next';

All helpers are chainable and end with either .send() or a terminal method like .json() or .redirect().

status()

Set the HTTP status code:

import { status } from '@server/next';

.post('/users', (ctx) => {
  const user = createUser(ctx.body);
  return status(201).json(user);
})

.delete('/users/:id', () => status(204).send())

json()

Send a JSON response (sets Content-Type: application/json):

import { json } from '@server/next';

.get('/users', () => json({ users: [] }))

// With a status code:
.post('/users', (ctx) => status(201).json({ id: 1, ...ctx.body }))
Returning a plain object is equivalent to calling json() — Server.js will detect it and serialize it automatically. Use json() explicitly when you need to chain other helpers like status() or headers().

redirect()

Redirect the client to another URL (302 by default):

import { redirect } from '@server/next';

.get('/old-path', () => redirect('/new-path'))

// With a different status code:
.get('/moved', () => status(301).redirect('/permanent-new-path'))

headers()

Set response headers:

import { headers } from '@server/next';

.get('/', () => headers('x-custom', 'value').send('Hello'))

// Multiple headers at once:
.get('/', () => headers({ 'x-foo': 'bar', 'x-baz': 'qux' }).send('Hello'))

type()

Set the Content-Type header. Accepts a MIME type or a file extension:

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

.get('/data.csv', () => type('csv').send('a,b,c'))
.get('/data', () => type('application/json').send('{"a":1}'))

cookies()

Set response cookies:

import { cookies } from '@server/next';

// Simple string value
.post('/login', () => cookies('token', 'abc123').send())

// With options (path, expires)
.post('/login', () => cookies('token', { value: 'abc123', path: '/', expires: '7d' }).send())

// Delete a cookie by setting it to null
.post('/logout', () => cookies('token', null).send())

// Multiple cookies at once
.get('/', () => cookies({ theme: 'dark', lang: 'en' }).send())

send()

The base terminal method. Sends the response with an optional body:

import { send, status } from '@server/next';

.get('/ping', () => send('pong'))
.delete('/item', () => status(204).send())

send() auto-detects the content type:

  • string starting with <text/html
  • other stringtext/plain
  • Buffer → sent as-is
  • ReadableStream → streamed
  • anything else → application/json

file()

Send a file from the filesystem:

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

.get('/', () => file('./index.html'))
.get('/report', () => file('./reports/latest.pdf'))

// The Content-Type is set automatically from the file extension.
// Returns 404 if the file does not exist.

download()

Trigger a file download in the browser (Content-Disposition: attachment):

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

.get('/export', () => download('export.csv').file('./data/export.csv'))

// Without a filename (no filename in Content-Disposition):
.get('/export', () => download().file('./data/export.csv'))

Chaining examples

All helpers return a Reply instance, so you can chain as many as needed before the terminal call:

import { status, headers, cookies, json } from '@server/next';

.post('/login', async (ctx) => {
  const user = await authenticate(ctx.body);
  if (!user) return status(401).json({ error: 'Invalid credentials' });

  return status(200)
    .cookies('session', { value: user.sessionId, path: '/' })
    .json({ ok: true, user });
})

Testing

Testing is a first-class feature of Server.js. Call .test() on your app to get a lightweight test client that runs requests through the full middleware stack without starting an HTTP server.

Basic setup

Keep your server in its own file so tests can import it:

// src/index.js
import server from '@server/next';

export default server()
  .get('/', () => 'Hello world')
  .get('/users', () => User.list())
  .post('/users', (ctx) => User.create(ctx.body));
// src/index.test.js
import app from './index.js';

const api = app.test();

it('returns hello world', async () => {
  const res = await api.get('/');
  expect(await res.text()).toBe('Hello world');
});

Available methods

The test client mirrors the HTTP methods. Each call returns a standard Response:

const api = app.test();

api.get('/path', options?)
api.post('/path', body?, options?)
api.put('/path', body?, options?)
api.patch('/path', body?, options?)
api.delete('/path', options?)
api.head('/path', options?)
api.options('/path', options?)
  • body: the request body — accepts a string, plain object (serialized as JSON), FormData, or ReadableStream.
  • options: standard RequestInit options (e.g. headers).

Reading the response

Each method returns a standard web Response:

const res = await api.get('/users');

res.status              // 200
res.headers.get('content-type')  // 'application/json'
await res.text()        // raw body as string
await res.json()        // parsed JSON body

Sending a body

Pass a plain object and Server.js will serialize it as JSON and set Content-Type: application/json automatically:

const res = await api.post('/users', { name: 'Francisco', email: '[email protected]' });
expect(res.status).toBe(201);
expect(await res.json()).toMatchObject({ name: 'Francisco' });

For other body types:

// Plain text
await api.post('/echo', 'Hello world');

// FormData (e.g. file uploads)
const form = new FormData();
form.append('name', 'Francisco');
await api.post('/upload', form);

// Custom headers
await api.get('/secure', { headers: { authorization: 'Bearer token123' } });

Full example

// src/books.test.js
import app from './index.js';

const api = app.test();

describe('books API', () => {
  it('lists books', async () => {
    const res = await api.get('/books');
    expect(res.status).toBe(200);
    expect(await res.json()).toEqual([]);
  });

  it('creates a book', async () => {
    const res = await api.post('/books', { title: 'Dune', author: 'Herbert' });
    expect(res.status).toBe(201);
    const body = await res.json();
    expect(body.title).toBe('Dune');
  });

  it('returns 404 for unknown books', async () => {
    const res = await api.get('/books/9999');
    expect(res.status).toBe(404);
  });

  it('deletes a book', async () => {
    const res = await api.delete('/books/1');
    expect(res.status).toBe(204);
  });
});

Limitations

The test client simulates requests in-process — it does not open a real TCP connection. This means:

  • Compression (brotli, gzip) and content-encoding headers are not applied (those are usually set by edge proxies).
  • Edge-only features like cf-* headers won't be present.

For integration tests that require a real HTTP connection, start the server on a test port and use fetch directly.

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');

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 is faster and also unlocks additional features like JSX templates and native S3 bucket support.

Cloudflare Workers

⚠️ WIP

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

⚠️ WIP

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
SECRET secret random (unsafe)
CORS cors null
AUTH auth null

FAQ

How is it different from Hono?

Server.js attempts to run your code unmodified in all runtimes. With Hono you need to change the code for different runtimes:

// Server.js code for Node.js, Bun and Netlify
import server from "@server/next";
export default server().get("/", () => "Hello server!");
// Hono code for Node.js
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.text('Hello Node.js!'))
serve(app)

// Hono code for Bun
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.text('Hello Bun!'))
export default app

// Hono code for Netlify
import { Hono } from 'jsr:@hono/hono'
import { handle } from 'jsr:@hono/hono/netlify'
const app = new Hono()
app.get('/', (c) => c.text('Hello Hono!'))
export default handle(app)