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
yarn add @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 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.
Note:
bucket
is still not available
bucket
: if you want to accept user files you will need a place to persist them. By default they are put in the filesystem, but since most cloud providers are ephemeral, provide a bucket
and it will be used to handle the files directly.
An example of how that works in practice:
// index.js
import server from "@server/next";
import Bucket from "bucket/b2";
import { createClient } from "redis";
const bucket = Bucket("mybucketname", { id, key });
const store = createClient("...").connect();
export default server({ bucket, store })
.get("/", () => "Hello world")
.post("/", (ctx) => {
console.log(ctx.body);
return 201;
});
Middleware
Validation
Stores
File handling
To manage files, you need to install and use the library bucket
, which is a very thin wrapper for file management systems. It is also created by the makers of Server.js.
The easiest and default is to set a folder in your filesystem:
import FileSystem from "bucket/fs";
const uploads = FileSystem("./uploads");
// All paths are relative to the CWD
export default server({ uploads })
.get("/", () => "Hello")
.put("/users/:id", async (ctx) => {
// This is the plain string as the file name, already in our FS
const fileName = ctx.body.profile;
// 'yOuZEdSsNLq8PgZyLhSz0Llh.jpg'
// Convert it into a File instance
const file = ctx.uploads.file(fileName);
// Now we can use other methods if we want
// .info(), .read(), .write(), .pipe(), .pipeTo(), etc
const info = await file.info();
// {
// id: "yOuZEdSsNLq8PgZyLhSz0Llh.jpg",
// type: "jpg",
// size: 435435,
// timestamp: "2024-08-07T14:26:37Z",
// // Note: this can be customized providing the option "domain"
// url: "file:///Users/me/my-project/uploads/yOuZEdSsNLq8PgZyLhSz0Llh.jpg",
// }
return 200;
});
To upload the files to a 3rd party system, you just need to use the corresponding bucket
implementation (or write a thin compatibility layer). Let's see an example with Backblaze's B2:
import server from "@server/next";
import Backblaze from "bucket/b2";
const uploads = Backblaze("bucket-name", {
id: process.env.BACKBLAZE_ID,
key: process.env.BACKBLAZE_KEY,
});
export default server({ uploads })
.put("/users/:id", async (ctx) => {
const fileName = ctx.body.profile;
// 'yOuZEdSsNLq8PgZyLhSz0Llh.jpg'
// Convert it into a File instance
const file = ctx.uploads.file(fileName);
// Now we can use other methods if we want
const info = await file.info();
// {
// id: "yOuZEdSsNLq8PgZyLhSz0Llh.jpg",
// type: "jpg",
// size: 435435,
// timestamp: "2024-08-07T14:26:37Z",
// url: "https://f???.backblazeb2.com/???/yOuZEdSsNLq8PgZyLhSz0Llh.jpg",
// }
return 200;
});
.;
Example: resizing the user profile picture
Let's see a complete example of uploading and resizing a user profile picture:
import server, { status } from "@server/next";
import sharp from "sharp";
import FileSystem from "bucket/fs";
const uploads = FileSystem("./uploads");
// All paths are relative to the CWD
export default server({ uploads })
.get("/", () => "Hello")
.put("/users/:id", async (ctx) => {
// Create the instance of the file to read and write
const src = ctx.uploads.file(ctx.body.profile);
const dst = ctx.uploads.file("/profile/" + ctx.url.params.id + ".jpg");
const ext = src.id.split(".").pop();
if (!["jpg", "jpeg", "png", "webp", "avif"].includes(ext)) {
await src.remove(); // Don't store it
return status(400).json({ error: "Invalid file format" });
}
// Create the Readable, Transform and Writable Node streams
await pipeline(
src.readable("node"),
sharp().resize(200, 200).jpg(),
dst.writable("node")
);
// We no longer need the original file
await src.remove();
return status(200).json({ updated: true });
});
Note that the option uploads
gets converted into a Bucket
instance and passed as ctx.uploads
. Th
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.
JSX
With Server and Bun, you can use JSX as a template engine to write your views:
// index.jsx
import server from '@server/next';
export default server()
.get('/', () => (
<html>
<body>
<h1>Hello world</h1>
</body>
</html>
));
Note that this is only using JSX as a template language, not React! So you cannot do events like
onClick
, use hooks likeuseEffect()
, etc. Basically, anything that requires you toimport X from 'react';
, you cannot use it here!
To do so, first you need to set your project. Create a bunfig.toml
in the root of your project with this:
jsx = "react-jsx"
jsxImportSource = "@server/next"
Then write your index.jsx
as shown above. Finally, start your project and go to home:
bun index.tsx
# Open localhost:3000
You should see "Hello world" displayed on the screen.
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
- Directory or bucket used for storing user-uploaded files.
-
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.
- Defines authentication settings, specifying the method and provider such as
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 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
SECRET
environment 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
CORS
environment 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
- Default:
null
- Description: Designates the directory or bucket for handling file uploads from users. Effective management of upload destinations ensures data integrity and security.
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
- Type:
AuthString | Auth
- Default: Value of the
AUTH
environment 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.
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.data);
return 'Message sent successfully';
})
.put('/users/:id', ctx => {
console.log(ctx.url.params.id, ctx.data);
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.delete()
: Used to delete a specified resource. Does not accept a body.patch()
: Used to apply partial modifications to a resource. Accepts 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'));
Context
Context docs here
Reply
Reply docs here
Testing
Testing is an integral part of Server.js, and so we provide some helpers for it! The main one you should usually be aware of is the .test()
method applied to the app. For the usual test setup, you have your normal server file, and then your test file like this:
// src/index.js - where your server runs
export default server()
.get('/', () => 'Hello world')
.get('/obj', () => ({ hello: 'world' }));
Then to test that, you can create a test file such as:
// src/index.test.js
import app from './index.js';
// Create a testing endpoint from your app:
const api = app.test();
describe('home', () => {
it('returns a simple "Hello world"', async () => {
const { body } = await api.get('/');
expect(body).toBe('Hello world');
});
it('returns a JSON object', async () => {
const { body, headers } = await api.get('/obj');
expect(headers['content-type']).toInclude('application/json');
expect(body).toBe({ hello: 'world' });
});
});
In this example, since it's a normal test file, we showed how to create a testing instance and then calling methods on it. You could also not even create the api
variable and instead just call it like this:
const { body } = await app.test().get('/');
The server is emulating the request/response, but it uses the same logic as Server.js, so tests are blazing fast and follow the proper router/middleware/etc. However, this has the disadvantage that it's not a real HTTP request, so there are some limitations. For example, the compression such as brotli or the content-size header are usually set by the edge proxy, so you won't be able to test those here.
Platforms
There are many platforms/runtimes where Server works! We put a lot of work to make sure it works the same way with minimal changes in them, this includes:
- Node.js of course, including everywhere that Node is supported (VPS, Heroku, Render, etc).
- Bun, and it will be even faster!
- Cloudflare Worker
- Netlify Function + Edge function
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)