A minimal, fast, and type-safe web framework for building APIs with Deno.
- Minimal API - Intuitive Express.js-inspired syntax
- Type Safety - Full TypeScript support with type inference
- Built-in Validation - Schema-based request validation
- Middleware Support - Global and per-route middleware
- Built-in CORS - Per-router rules, merged on mount, OPTIONS preflight
- Modular Routing - Organize routes with nested routers
- Web Standards - Built on native Deno Web APIs
- Zero Dependencies - Lightweight and fast
deno add jsr:@webtools/expressapiimport { HttpServer } from "jsr:@webtools/expressapi";
const server = new HttpServer();
server.get("/", (req, res) => {
return res.json({ message: "Hello, World!" });
});
server.post("/users", (req, res) => {
const user = req.body;
return res.status(201).json({ created: true, user });
});
server.listen(5050);- Getting Started
- Routing
- Request & Response
- Middleware
- CORS
- Schema Validation
- Advanced Usage
- API Reference
- Examples
import { HttpServer } from "jsr:@webtools/expressapi";
// Create server instance
const server = new HttpServer();
// Start the server on port 5050
server.listen(5050);
console.log("Server running on http://localhost:5050");server.get("/hello", (req, res) => {
return res.json({ message: "Hello, World!" });
});ExpressAPI supports all standard HTTP methods:
server.get("/users", (req, res) => {
return res.json({ users: [] });
});
server.post("/users", (req, res) => {
return res.status(201).json({ created: true });
});
server.put("/users/:id", (req, res) => {
return res.json({ updated: true, id: req.params.id });
});
server.patch("/users/:id", (req, res) => {
return res.json({ patched: true, id: req.params.id });
});
server.delete("/users/:id", (req, res) => {
return res.status(204).send(null);
});Access dynamic route segments via req.params:
server.get("/users/:id", (req, res) => {
const userId = req.params.id;
return res.json({ userId });
});
server.get("/users/:userId/posts/:postId", (req, res) => {
return res.json({
userId: req.params.userId,
postId: req.params.postId,
});
});Query strings are automatically parsed:
server.get("/search", (req, res) => {
const { q, page = "1", limit = "10" } = req.query;
return res.json({
query: q,
page: parseInt(page),
limit: parseInt(limit),
});
});ExpressAPI automatically normalizes all route URLs and incoming request paths. This ensures consistent route matching regardless of how URLs are written.
Normalization rules:
- Multiple consecutive slashes are collapsed to a single slash
- Trailing slashes are removed (except for the root path
/) - Leading slashes are normalized
Examples:
// These route definitions are equivalent:
server.get("/users", handler);
server.get("/users/", handler); // Trailing slash removed
server.get("//users", handler); // Multiple slashes normalized
// These requests all match the same route:
// GET /users → matches /users
// GET /users/ → matches /users (trailing slash removed)
// GET //users → matches /users (multiple slashes normalized)
// GET /users/// → matches /users (normalized)Note: The root path / is preserved and not normalized. All other paths are normalized to remove trailing slashes
and collapse multiple slashes.
The HttpRequest object provides access to request data:
server.post("/data", (req, res) => {
// Request properties
console.log(req.url); // Pathname
console.log(req.method); // HTTP method
console.log(req.headers); // Headers object
console.log(req.body); // Parsed body
console.log(req.query); // Query parameters
console.log(req.params); // Route parameters
console.log(req.cookies); // Parsed cookies
console.log(req.ip); // Client IP (remote address, or x-forwarded-for with trustProxy)
console.log(req.raw); // Original Request object
// Custom data context
req.data = { userId: 123 };
return res.json({ success: true });
});// JSON response (default status 200)
res.json({ message: "Success" });
// Custom status code
res.status(201).json({ created: true });
// Text response
res.status(200).send("Plain text");
// Redirect
res.redirect("/new-location", 301);
// Send file (async, throws if the file does not exist)
await res.sendFile("/path/to/file.pdf");
// Custom headers
res.setHeader("X-Custom-Header", "value")
.setHeader("X-Another", "value2")
.json({ data: "..." });
// Set content type
res.type("xml").send("<root></root>");The body is automatically parsed based on Content-Type:
// JSON (application/json)
server.post("/json", (req, res) => {
const { name, email } = req.body;
return res.json({ name, email });
});
// Form data (multipart/form-data)
server.post("/upload", (req, res) => {
const formData = req.body; // Object with form fields
return res.json({ received: formData });
});
// URL encoded (application/x-www-form-urlencoded)
server.post("/form", (req, res) => {
const data = req.body; // Parsed as object
return res.json({ data });
});Global middleware runs before all routes:
// Logging middleware
server.use((req, res) => {
console.log(`${new Date().toISOString()} ${req.method} ${req.url}`);
});
// CORS is applied automatically by HttpServer (see [CORS](#cors)).
// Add extra headers here only if you need something beyond the built-in behaviour.Apply middleware to specific routes:
const authenticate = (req, res) => {
const token = req.headers.get("authorization");
if (!token || !token.startsWith("Bearer ")) {
return res.status(401).json({ error: "Unauthorized" });
}
// Attach user data to request
req.data = { userId: 123 };
};
const requireAdmin = (req, res) => {
if (req.data?.role !== "admin") {
return res.status(403).json({ error: "Forbidden" });
}
};
server.get(
"/admin/users",
(req, res) => {
return res.json({ users: [] });
},
[authenticate, requireAdmin],
);Middleware executes in order. Return a response to stop the chain:
const middleware1 = (req, res) => {
console.log("Middleware 1");
// Continue to next middleware
};
const middleware2 = (req, res) => {
console.log("Middleware 2");
// Stop chain by returning response
return res.status(403).json({ error: "Blocked" });
};
const middleware3 = (req, res) => {
// This won't execute if middleware2 returns
console.log("Middleware 3");
};HttpServer applies CORS headers on every matched route and on OPTIONS preflight responses. You normally do not
need a manual CORS middleware unless you add non-standard headers.
HttpServer ships with permissive defaults on corsRules (wildcard origin, common methods,
Access-Control-Allow-Headers: *, Access-Control-Max-Age, etc.). Override them with cors() on the server or on any
Router you mount.
Call cors(rules) on a Router to attach default CORS rules for that router. When the router is mounted with
use(prefix, router) or use(router), each route gets a merged cors field:
- Parent router’s
corsRules - Mounted router’s
corsRules - The route’s own
cors(if any)
Later entries in that chain override earlier ones (same semantics as Object.assign inside mergeCorsRules).
import { HttpServer, Router } from "jsr:@webtools/expressapi";
const api = new Router("/v1");
api.cors({
allowOrigin: "https://app.example.com",
maxAge: "7200",
});
api.get("/health", (_req, res) => res.json({ ok: true }));
const server = new HttpServer();
server.cors({ allowOrigin: "*" }); // optional: tighten or relax server-wide defaults
server.use(api);
server.listen(5050);For OPTIONS, the server responds with 204 and CORS headers. To pick which route’s cors applies when the same
path exists for several methods, the server reads Access-Control-Request-Method: it resolves the route for that
HTTP method and path. If the header is missing or does not match a registered method, it falls back to the first
route that matches the path (stable iteration over GET, POST, PUT, PATCH, DELETE).
Exported from the package (see mod.ts). allowOrigin, allowMethods, and allowHeaders may be a string or an
async function (req) => string | undefined for dynamic values (e.g. reflect Origin when using credentials).
import type { CorsRules } from "jsr:@webtools/expressapi";
const rules: CorsRules = {
allowOrigin: (req) => req.headers.get("origin") ?? undefined,
allowCredentials: true,
};ExpressAPI includes a powerful schema validation system for type-safe request validation.
import { z } from "jsr:@webtools/expressapi";
server.post(
"/users",
(req, res) => {
// req.body is now typed and validated
const { name, email, age } = req.body;
return res.status(201).json({ user: { name, email, age } });
},
[],
{
body: z.object({
name: z.string().min(3).max(50),
email: z.string().email(),
age: z.optional(z.number().int().positive()),
}),
},
);server.get(
"/users",
(req, res) => {
// req.query is validated and typed
const { page, limit } = req.query;
return res.json({ page, limit });
},
[],
{
query: z.object({
page: z.optional(z.number().int().positive()),
limit: z.optional(z.number().int().positive().max(100)),
}),
},
);server.get(
"/users/:id",
(req, res) => {
// req.params.id is validated as UUID
return res.json({ userId: req.params.id });
},
[],
{
params: z.object({
id: z.string().uuid(),
}),
},
);Invalid data automatically returns a 400 response:
{
"success": false,
"error": "400 Bad Request.",
"details": [
{
"path": ["email"],
"message": "Invalid email format",
"code": "invalid_string"
}
]
}z.string(); // Basic string
z.string().min(3); // Minimum length
z.string().max(100); // Maximum length
z.string().length(10); // Exact length
z.string().email(); // Email validation
z.string().uuid(); // UUID validation
z.string().url(); // URL validation
z.string().regex(/^[A-Z]+$/); // Regex pattern
z.string().startsWith("prefix"); // Must start with
z.string().endsWith("suffix"); // Must end withz.number(); // Basic number
z.number().int(); // Integer only
z.number().positive(); // Must be positive
z.number().negative(); // Must be negative
z.number().min(0); // Minimum value
z.number().max(100); // Maximum value// Objects
z.object({
name: z.string(),
age: z.number(),
email: z.string().email(),
});
// Arrays
z.array(z.string()); // Array of strings
z.array(z.string()).min(1); // At least 1 item
z.array(z.string()).max(10); // At most 10 items
z.array(z.string()).length(5); // Exactly 5 items
// Optional and nullable
z.optional(z.string()); // string | undefined
z.nullable(z.string()); // string | null
// Unions
z.union([z.string(), z.number()]); // string | number
// Enums
z.enum(["red", "green", "blue"]); // "red" | "green" | "blue"
// Any
z.any(); // Any valueOrganize routes into separate modules:
// routes/users.ts
import { Router, z } from "jsr:@webtools/expressapi";
// Create router with prefix
export const usersRouter = new Router("/api/users");
usersRouter.get("/", (req, res) => {
return res.json({ users: [] });
});
usersRouter.post(
"/",
(req, res) => {
return res.status(201).json({ user: req.body });
},
[],
{
body: z.object({
name: z.string().min(3),
email: z.string().email(),
}),
},
);
usersRouter.get("/:id", (req, res) => {
return res.json({ userId: req.params.id });
});// server.ts
import { HttpServer } from "jsr:@webtools/expressapi";
import { usersRouter } from "./routes/users.ts";
const server = new HttpServer();
// Mount router (prefix already applied)
server.use(usersRouter);
// Routes: /api/users, /api/users/:id
server.listen(5050);// routes/users.ts
import { Router, z } from "jsr:@webtools/expressapi";
export const usersRouter = new Router();
usersRouter.get("/", (req, res) => {
return res.json({ users: [] });
});
usersRouter.get("/:id", (req, res) => {
return res.json({ userId: req.params.id });
});// server.ts
import { HttpServer } from "jsr:@webtools/expressapi";
import { usersRouter } from "./routes/users.ts";
const server = new HttpServer();
// Mount router with prefix
server.use("/api/users", usersRouter);
// Routes: /api/users, /api/users/:id
server.listen(5050);Prefixes can be combined when mounting:
// routes/users.ts
const usersRouter = new Router("/users");
usersRouter.get("/", handler);
// Internal routes: /users
// server.ts
server.use("/api", usersRouter);
// Final routes: /api/usersserver.fetch exposes the server as a standard fetch handler: (Request) => Promise<Response>. Useful for passing
custom options to Deno.serve (hostname, TLS, abort signal...) instead of using listen():
const server = new HttpServer();
server.get("/", (_req, res) => res.json({ ok: true }));
Deno.serve({ port: 5050, hostname: "127.0.0.1" }, server.fetch);It also enables fast, portless unit testing — no listen() required:
Deno.test("GET /users/:id", async () => {
const response = await server.fetch(new Request("http://localhost/users/42"));
const body = await response.json();
assertEquals(body.userId, "42");
});Note: the optional second argument (Deno.ServeHandlerInfo) carries the TCP remote address. When it is not provided
(e.g. in tests), req.ip falls back to x-forwarded-for (if trustProxy is enabled) or null.
server.notFound((req, res) => {
return res.status(404).json({
error: "Not Found",
path: req.url,
method: req.method,
});
});Use generics for type-safe request data:
interface AppData {
userId: number;
role: string;
}
const server = new HttpServer<AppData>();
server.use((req, res) => {
// Type-safe data assignment
req.data = { userId: 123, role: "admin" };
});
server.get("/profile", (req, res) => {
// req.data is typed as AppData
const { userId, role } = req.data;
return res.json({ userId, role });
});
server.listen(5050);Any error thrown by a middleware, a route handler, or the validation layer is caught by the server and forwarded to the
global error handler. By default, the server responds with a 500 JSON error. Use onError() to customize this
behaviour:
server.onError((error, req, res) => {
console.error(`Error on ${req.method} ${req.url}:`, error);
return res.status(500).json({
success: false,
error: "Internal Server Error",
message: error instanceof Error ? error.message : String(error),
});
});If the handler returns nothing, the default 500 JSON response is sent.
By default, req.ip is the remote address of the TCP connection — it cannot be spoofed by request headers. If your
server runs behind a trusted reverse proxy (nginx, Caddy, a load balancer...), enable trustProxy to resolve the client
IP from the first entry of the x-forwarded-for header instead:
const server = new HttpServer({ trustProxy: true });
server.get("/ip", (req, res) => {
return res.json({ ip: req.ip });
});Only enable trustProxy when a trusted proxy sets x-forwarded-for, otherwise clients can spoof their IP.
class HttpServer<TData = DataDefault> extends Router<TData>Constructor:
new HttpServer(options?: HttpServerOptions)- Create a server instance. Inherits fromRouterwith default prefix "/". The server does not start automatically - calllisten(port)to start it.options.trustProxy(defaultfalse) - Whentrue,req.ipis resolved from the first entry of thex-forwarded-forheader. Only enable this behind a trusted reverse proxy.
Methods:
listen(port: number)- Start the server and begin listening for requests on the specified portfetch(request: Request, info?): Promise<Response>- Standard fetch handler; usable with a customDeno.servesetup or directly in testsget<TSchemas>(url, handler, middlewares?, schemas?)- Register GET routepost<TSchemas>(url, handler, middlewares?, schemas?)- Register POST routeput<TSchemas>(url, handler, middlewares?, schemas?)- Register PUT routepatch<TSchemas>(url, handler, middlewares?, schemas?)- Register PATCH routedelete<TSchemas>(url, handler, middlewares?, schemas?)- Register DELETE routeuse(middleware)- Add global middlewareuse(prefix, router)- Mount router with prefix (combines with router's own prefix)use(router)- Mount router (uses router's own prefix)cors(rules)- Set defaultCorsRulesfor this server (merged with each route’s rules when responding)notFound(handler)- Custom 404 handleronError(handler)- Custom global error handler, called whenever a middleware, route handler, or validation throws
Built-in behaviour: OPTIONS preflight (204) and CORS headers on successful route handling use mergeCorsRules over
the server’s corsRules and the matched route’s cors. Preflight route selection prefers
Access-Control-Request-Method.
class Router<TData = DataDefault>Constructor:
new Router(prefix?: string)- Create a router with an optional prefix (default: "/")
Methods:
get<TSchemas>(url, handler, middlewares?, schemas?)- Register GET routepost<TSchemas>(url, handler, middlewares?, schemas?)- Register POST routeput<TSchemas>(url, handler, middlewares?, schemas?)- Register PUT routepatch<TSchemas>(url, handler, middlewares?, schemas?)- Register PATCH routedelete<TSchemas>(url, handler, middlewares?, schemas?)- Register DELETE routeuse(middleware)- Add global middlewareuse(prefix, router)- Mount router with prefix (combines with router's own prefix)use(router)- Mount router (uses router's own prefix)cors(rules)- Default CORS for routes registered on this router; merged into each route when the router is mounted on a parent (use), together with the parent’scorsRulesand any per-routecors(later wins)
Same methods as HttpServer except listen and notFound. The prefix is automatically applied to all routes when the
router is used directly or mounted.
class HttpRequest<TData, TRouteTypes>Properties:
url: string- Request pathnamemethod: HttpMethods- HTTP methodheaders: Headers- Request headersbody: TRouteTypes["body"]- Parsed request bodyquery: TRouteTypes["query"]- Query parametersparams: TRouteTypes["params"]- Route parameterscookies: Record<string, string>- Parsed cookies (values are URI-decoded)ip: string | null- Client IP address (TCP remote address by default; firstx-forwarded-forentry whentrustProxyis enabled)data: TData- Custom data contextraw: Request- Original Request object
class HttpResponseMethods:
status(code: number): HttpResponse- Set status codesetHeader(name: string, value: string): HttpResponse- Set headergetHeader(name: string): string | null- Read a previously set headertype(type: string): HttpResponse- Set content typesize(size: number): HttpResponse- Set content lengthjson(body: unknown): Response- Send JSON responsesend(body: BodyInit | null): Response- Send responseredirect(url: string, code?: number): Response- RedirectsendFile(path: string): Promise<Response>- Stream a file from disk. Throws if the path does not exist (handled by the global error handler, seeonError)
CryptoHelper.sha256(payload: string): Promise<string>
CryptoHelper.sha512(payload: string): Promise<string>
CryptoHelper.secureRandom(): numberStringHelper.generateRandomString(pattern?: string, chars?: string): string
StringHelper.encodeBase64Url(data: string): string
StringHelper.decodeBase64Url(data: string): string
StringHelper.normalizePath(...parts: string[]): string
StringHelper.slugify(str: string): string
StringHelper.escapeHtml(str: string): string
StringHelper.unescapeHtml(str: string): string
StringHelper.clean(str: string): stringStringHelper.normalizePath: Normalizes URL paths by joining parts, collapsing multiple slashes, and removing trailing slashes (except for root). Used internally for route and request path normalization.
JsonToken provides a simple JWT-like token system for signing and verifying JSON payloads. It uses SHA-256 for
signature generation and Base64URL encoding.
Format: {base64url(payload)}.{signature}
class JsonToken {
constructor(secret: string);
sign(payload: unknown): Promise<string>;
verify<T>(token: string): Promise<T | null>;
}Basic Usage:
import { JsonToken } from "jsr:@webtools/expressapi";
// Initialize with a secret key
const token = new JsonToken("your-secret-key");
// Sign a payload
const payload = { userId: 123, email: "user@example.com", role: "admin" };
const signedToken = await token.sign(payload);
// Returns: "eyJ1c2VySWQiOjEyMywiZW1haWwiOiJ1c2VyQGV4YW1wbGUuY29tIiwicm9sZSI6ImFkbWluIn0.signature"
// Verify and decode
const decoded = await token.verify<typeof payload>(signedToken);
// Returns: { userId: 123, email: "user@example.com", role: "admin" }
// Invalid token returns null
const invalid = await token.verify("invalid.token");
// Returns: nullHow it works:
- Signing: The payload is JSON stringified, Base64URL encoded, then concatenated with the secret and hashed with SHA-256 to create the signature.
- Verification: The token is split into payload and signature. The payload is re-hashed with the secret and compared to the provided signature using constant-time comparison to prevent timing attacks.
- Security: Uses constant-time string comparison to prevent timing attacks. Invalid tokens return
nullinstead of throwing errors.
Example: Token-based Authentication
import { HttpServer, JsonToken, z } from "jsr:@webtools/expressapi";
const server = new HttpServer();
const token = new JsonToken(Deno.env.get("JWT_SECRET") || "default-secret");
// Issue token
server.post(
"/auth/login",
async (req, res) => {
const { email, password } = req.body;
// Validate credentials (example)
const user = await validateUser(email, password);
if (!user) {
return res.status(401).json({ error: "Invalid credentials" });
}
// Create token with user data
const jwt = await token.sign({
userId: user.id,
email: user.email,
role: user.role,
iat: Date.now(),
});
return res.json({ token: jwt });
},
[],
{
body: z.object({
email: z.string().email(),
password: z.string().min(6),
}),
},
);
// Verify token middleware
const verifyToken = async (req, res) => {
const authHeader = req.headers.get("authorization");
if (!authHeader?.startsWith("Bearer ")) {
return res.status(401).json({ error: "Missing or invalid authorization header" });
}
const jwt = authHeader.slice(7);
const payload = await token.verify<{
userId: number;
email: string;
role: string;
iat: number;
}>(jwt);
if (!payload) {
return res.status(401).json({ error: "Invalid or expired token" });
}
// Attach user data to request
req.data = {
userId: payload.userId,
email: payload.email,
role: payload.role,
};
};
// Protected route
server.get(
"/profile",
(req, res) => {
return res.json({
userId: req.data.userId,
email: req.data.email,
role: req.data.role,
});
},
[verifyToken],
);
server.listen(5050);Note: This is a simplified token system. For production use cases requiring expiration, refresh tokens, or advanced features, consider using a full JWT library.
import { HttpServer, z } from "jsr:@webtools/expressapi";
const server = new HttpServer();
// GET /users
server.get("/users", (req, res) => {
return res.json({ users: [] });
});
// GET /users/:id
server.get("/users/:id", (req, res) => {
return res.json({ user: { id: req.params.id } });
});
// POST /users
server.post(
"/users",
(req, res) => {
return res.status(201).json({ user: req.body });
},
[],
{
body: z.object({
name: z.string().min(3),
email: z.string().email(),
}),
},
);
// PUT /users/:id
server.put("/users/:id", (req, res) => {
return res.json({ updated: true, id: req.params.id });
});
// DELETE /users/:id
server.delete("/users/:id", (req, res) => {
return res.status(204).send(null);
});
server.listen(5050);import { HttpServer, JsonToken, z } from "jsr:@webtools/expressapi";
const server = new HttpServer();
const token = new JsonToken("your-secret-key");
// Login
server.post(
"/login",
async (req, res) => {
const { email, password } = req.body;
// Validate credentials
if (email === "user@example.com" && password === "password") {
const jwt = await token.sign({ userId: 123, email });
return res.json({ token: jwt });
}
return res.status(401).json({ error: "Invalid credentials" });
},
[],
{
body: z.object({
email: z.string().email(),
password: z.string().min(6),
}),
},
);
// Protected route
const authMiddleware = async (req, res) => {
const authHeader = req.headers.get("authorization");
if (!authHeader?.startsWith("Bearer ")) {
return res.status(401).json({ error: "Unauthorized" });
}
const jwt = authHeader.slice(7);
const payload = await token.verify<{ userId: number }>(jwt);
if (!payload) {
return res.status(401).json({ error: "Invalid token" });
}
req.data = { userId: payload.userId };
};
server.get(
"/profile",
(req, res) => {
return res.json({ userId: req.data.userId });
},
[authMiddleware],
);
server.listen(5050);server.post("/upload", (req, res) => {
const formData = req.body;
const file = formData.file; // File object from FormData
if (!file) {
return res.status(400).json({ error: "No file provided" });
}
// Process file...
return res.json({ uploaded: true, filename: file.name });
});Distributed under the MIT License. See LICENSE for more information.