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:githubor token-based auth.
- Defines authentication settings, specifying the method and provider such as
-
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
PORTenvironment variable or3000. - 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
SECRETenvironment variable or generates anunsafe-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
CORSenvironment variable ornull. - 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
Bucketobject for cloud storage, or anUploadPipeline(fromupload()) 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
storeprefixed 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
AUTHenvironment variable ornull. - 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 generic500response.
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
/docsroute (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';).
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 tolocalhost: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 docstitle:string— short route summarydescription: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/:id→ctx.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
URLproperties 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/jsonrequests, it is the parsed JSON value. - For
application/x-www-form-urlencodedormultipart/form-datatext fields, it is a plain object of field values. - For file uploads, the field value is an
UploadedFileobject{ name, id, path, type, size }— see File handling. undefinedfor 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
authoption 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 identifieremail: user emailprovider: 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 }))
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:
stringstarting with<→text/html- other
string→text/plain Buffer→ sent as-isReadableStream→ 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, orReadableStream.options: standardRequestInitoptions (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) andcontent-encodingheaders 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)