Skip to content

Backend-Only Mode

WebJs works as a pure API framework with no pages, no SSR, and no web components. If you only need file-based routing, middleware, TypeScript, and a fast HTTP server, you can use WebJs without writing a single page or component. Everything in the framework is designed around standard Request/Response objects, so the server-side features work independently of the rendering layer.

When to Choose Backend-Only

Use backend-only mode when:

  • You are building a REST or JSON API for a mobile app, SPA, or external consumers.
  • You want file-based routing and middleware without the weight of a UI framework.
  • Your frontend is a separate app (React, Vue, Svelte, plain HTML) and you need a typed API backend.
  • You are building a microservice that only serves data.
  • You want typed RPC endpoints (server actions, optionally exposed over REST via route.ts) callable from another WebJs app or any HTTP client.

Use full-stack WebJs when you want server-rendered pages, web components, streaming SSR, and server actions all in one codebase.

Minimal API-Only App Structure

my-api/
  app/
    api/
      health/
        route.ts           # GET /api/health
      users/
        route.ts           # GET /api/users, POST /api/users
        [id]/
          route.ts         # GET /api/users/:id, PUT, DELETE
      auth/
        login/
          route.ts         # POST /api/auth/login
        signup/
          route.ts         # POST /api/auth/signup
        middleware.ts       # rate limiting for /api/auth/*
    middleware.ts           # segment middleware for all /api/* (CORS, etc.)
  actions/
    users.server.ts        # server actions (exposed over REST via route.ts)
  db/
    connection.server.ts
    schema.server.ts
  lib/
    session.ts
  middleware.ts             # root middleware (logging, timing)
  package.json
  tsconfig.json

There is no page.ts, no layout.ts, no components/ directory. WebJs detects what files exist and only activates the features you use.

File-Based API Routing

A route.ts file anywhere under app/ becomes an API endpoint. Export functions named after HTTP methods:

// app/api/users/route.ts
import { db } from '#db/connection.server.ts';
import { users } from '#db/schema.server.ts';

export async function GET(req: Request, { params }: { params: Record<string, string> }) {
  const rows = await db.query.users.findMany({
    columns: { id: true, name: true, email: true, createdAt: true },
  });
  return Response.json(rows);
}

export async function POST(req: Request) {
  const body = await req.json();
  const [user] = await db.insert(users).values({ name: body.name, email: body.email }).returning();
  return Response.json(user, { status: 201 });
}
// app/api/users/[id]/route.ts
import { eq } from 'drizzle-orm';
import { db } from '#db/connection.server.ts';
import { users } from '#db/schema.server.ts';

export async function GET(req: Request, { params }: { params: { id: string } }) {
  const user = await db.query.users.findFirst({ where: { id: Number(params.id) } });
  if (!user) return Response.json({ error: 'Not found' }, { status: 404 });
  return Response.json(user);
}

export async function PUT(req: Request, { params }: { params: { id: string } }) {
  const body = await req.json();
  const [user] = await db.update(users).set(body).where(eq(users.id, Number(params.id))).returning();
  return Response.json(user);
}

export async function DELETE(req: Request, { params }: { params: { id: string } }) {
  await db.delete(users).where(eq(users.id, Number(params.id)));
  return new Response(null, { status: 204 });
}

Dynamic segments ([id]), catch-all segments ([...rest]), and route groups ((groupName)) all work the same way as with pages.

Middleware for Auth, CORS, Rate Limiting

Middleware works identically in backend-only mode. Place middleware.ts files at the root or in any segment directory:

// middleware.ts (root): logging for every request
export default async function logger(
  req: Request,
  next: () => Promise<Response>,
): Promise<Response> {
  const start = Date.now();
  const resp = await next();
  console.log(`${req.method} ${new URL(req.url).pathname} ${resp.status} ${Date.now() - start}ms`);
  return resp;
}
// app/middleware.ts: CORS for all routes under app/
export default async function cors(
  req: Request,
  next: () => Promise<Response>,
): Promise<Response> {
  if (req.method === 'OPTIONS') {
    return new Response(null, {
      status: 204,
      headers: {
        'access-control-allow-origin': '*',
        'access-control-allow-methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
        'access-control-allow-headers': 'content-type, authorization',
        'access-control-max-age': '86400',
      },
    });
  }
  const resp = await next();
  resp.headers.set('access-control-allow-origin', '*');
  return resp;
}
// app/api/auth/middleware.ts: rate limit auth endpoints
import { rateLimit } from '@webjsdev/server';

export default rateLimit({ window: '10s', max: 5 });

Server Actions over REST via route.ts

Define your API logic as plain server-action functions, then expose them over HTTP through a route.ts handler. The route() adapter from @webjsdev/server writes the common handler (merge query + params + JSON body, run an optional validator, JSON-respond) in one line:

// actions/users.server.ts
'use server';
import { db } from '#db/connection.server.ts';
import { users } from '#db/schema.server.ts';

export async function listUsers() {
  return db.query.users.findMany({
    columns: { id: true, name: true, email: true },
  });
}

export async function getUser({ id }: { id: string }) {
  const user = await db.query.users.findFirst({ where: { id: Number(id) } });
  if (!user) throw new Error('User not found');
  return user;
}

export async function createUser({ name, email }: { name: string; email: string }) {
  const [user] = await db.insert(users).values({ name, email }).returning();
  return user;
}
// app/api/v2/users/route.ts
import { route } from '@webjsdev/server';
import { listUsers, createUser } from '#actions/users.server.ts';

const validateUser = (input: any) => {
  if (!input.name || typeof input.name !== 'string') throw new Error('name is required');
  if (!input.email || typeof input.email !== 'string') throw new Error('email is required');
  return input;
};

export const GET = route(listUsers);
export const POST = route(createUser, { validate: validateUser });

// app/api/v2/users/[id]/route.ts: ctx.params.id merges into the input
import { route } from '@webjsdev/server';
import { getUser } from '#actions/users.server.ts';
export const GET = route(getUser);

The action is reachable two ways:

  • As an HTTP endpoint: GET /api/v2/users/:id from curl, Postman, or any HTTP client, served by the route.ts handler.
  • As a typed function import: another WebJs app (or the same app's components) can import { getUser } from '#actions/users.server.ts' and call it as a function with full type safety.

The route() adapter merges URL params, query string, and JSON body into a single object argument. The optional validate function runs before the handler and can transform or reject input (works with zod, valibot, or any schema library that throws on error). For CORS, wrap the handler in the cors() middleware, or apply it in middleware.ts for the path.

WebSocket Support

Export a WS function from any route.ts to create a WebSocket endpoint:

// app/api/chat/route.ts
import type { WebSocket } from 'ws';

const clients = new Set<WebSocket>();

export function WS(ws: WebSocket, req: Request, { params }: { params: Record<string, string> }) {
  clients.add(ws);

  ws.on('message', (data: Buffer) => {
    const msg = data.toString();
    for (const c of clients) {
      if (c.readyState === 1) c.send(msg);
    }
  });

  ws.on('close', () => clients.delete(ws));
}

WebSocket endpoints coexist with HTTP handlers in the same route.ts. The second argument is a Request object from the upgrade handshake, so you can read cookies, headers, and query params for auth.

Content-Negotiated JSON

Use the json() helper from @webjsdev/server and the richFetch() client helper from webjs for rich-encoded responses that preserve Date, Map, Set, BigInt, TypedArray, Blob, File, FormData, and reference cycles:

// app/api/events/route.ts
import { json } from '@webjsdev/server';
import { db } from '#db/connection.server.ts';

export async function GET() {
  const events = await db.query.events.findMany();
  return json(events); // dates stay as Dates for richFetch callers
}
// Internal client (another webjs app or same-app component)
import { richFetch } from '@webjsdev/core';

const events = await richFetch('/api/events');
// events[0].createdAt is a real Date object

// External client (curl, Postman) gets plain JSON automatically
// curl http://localhost:8080/api/events

The json() helper reads the Accept header. If the client sent Accept: application/vnd.webjs+json (as richFetch does), the response is encoded with the WebJs serializer. Otherwise, plain application/json. The Vary: Accept header is set automatically.

For reading request bodies with the same content negotiation, use readBody(req) from @webjsdev/server.

Health Probes, Graceful Shutdown, Compression

All production features work in backend-only mode with no extra configuration:

  • Health probes: GET /__webjs/health and GET /__webjs/ready return { "status": "ok" }.
  • Graceful shutdown: SIGINT/SIGTERM drains in-flight requests, then exits cleanly.
  • Compression: Brotli/Gzip for JSON responses in production.
  • ETags: static file ETags and cache headers.
  • Structured logging: JSON-per-line in production, human-readable in dev.

createRequestHandler() for Serverless/Edge

Embed a backend-only WebJs app in any environment that speaks Request/Response:

import { createRequestHandler } from '@webjsdev/server';

// Build the handler once at cold start
const app = await createRequestHandler({
  appDir: process.cwd(),
  dev: false,
});

// Serverless function (AWS Lambda with response streaming, Vercel, etc.)
export default async function handler(req: Request): Promise<Response> {
  return app.handle(req);
}

// Or embed in an existing Fastify server
import Fastify from 'fastify';
const fastify = Fastify();

fastify.all('*', async (request, reply) => {
  const url = new URL(request.url, `http://${request.headers.host}`);
  const webReq = new Request(url, {
    method: request.method,
    headers: request.headers as Record<string, string>,
    body: ['GET', 'HEAD'].includes(request.method) ? undefined : request.body,
  });
  const resp = await app.handle(webReq);
  reply.status(resp.status);
  resp.headers.forEach((v, k) => reply.header(k, v));
  const body = await resp.text();
  reply.send(body);
});

fastify.listen({ port: 8080 });

Comparison with Express/Fastify

Here is what WebJs gives you compared to a traditional Node.js API framework:

  • File-based routing: no manual app.get() / app.post() registration. Drop a route.ts in a folder and it is live.
  • Nested middleware: middleware scoped to route subtrees, not global or per-route.
  • TypeScript first: no build step, no compilation, no config. .ts files run directly.
  • Rich wire format: webjs's built-in serializer round-trips Date/Map/Set/BigInt/TypedArray/Blob/File/FormData and reference cycles.
  • WebSocket support: export a WS function from a route file, no separate setup.
  • Health probes: built-in, zero config.
  • route.ts + route(): turn server functions into REST endpoints with validation and CORS.
  • Graceful shutdown: handles SIGINT/SIGTERM, drains connections, hard-exits on timeout.
  • Compression and ETags: built-in, negotiated automatically.

What WebJs does not give you:

  • Massive middleware ecosystem: Express has thousands of middleware packages (passport, multer, helmet, etc.). WebJs has a handful of built-in utilities. You can still use any standard library that works with Request/Response.
  • Years of battle-testing: Express and Fastify have been production-proven at enormous scale. WebJs is new.
  • Plugin system: Fastify's plugin architecture for encapsulated contexts does not have a WebJs equivalent. The middleware chain and file conventions are the extension points.
  • Advanced schema validation: Fastify has built-in JSON Schema validation with Ajv. In webjs, use the validate config export (or the route() adapter's validate option) with zod, valibot, or any library.

Example: Complete API-Only Setup

package.json

{
  "name": "my-api",
  "type": "module",
  "scripts": {
    "dev": "webjs dev",
    "start": "webjs start",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@webjsdev/cli": "0.1.0",
    "@webjsdev/core": "0.1.0",
    "@webjsdev/server": "0.1.0",
    "drizzle-orm": "^1.0.0-rc.3"
  },
  "devDependencies": {
    "drizzle-kit": "^1.0.0-rc.3",
    "typescript": "^5.7.0"
  }
}

middleware.ts (root)

export default async function logger(
  req: Request,
  next: () => Promise<Response>,
): Promise<Response> {
  const start = Date.now();
  const resp = await next();
  const ms = Date.now() - start;
  console.log(`${req.method} ${new URL(req.url).pathname} ${resp.status} ${ms}ms`);
  return resp;
}

app/api/posts/route.ts

import { json } from '@webjsdev/server';
import { db } from '#db/connection.server.ts';
import { posts } from '#db/schema.server.ts';

export async function GET() {
  const rows = await db.query.posts.findMany({
    orderBy: { createdAt: 'desc' },
    with: { author: { columns: { name: true } } },
  });
  return json(rows);
}

export async function POST(req: Request) {
  const { title, body, authorId } = await req.json();
  const [post] = await db.insert(posts).values({
    title, body, slug: title.toLowerCase().replace(/s+/g, '-'), authorId,
  }).returning();
  return json(post, { status: 201 });
}

Run it

webjs dev
# API is live at http://localhost:8080
# curl http://localhost:8080/api/posts
# curl http://localhost:8080/__webjs/health

No pages, no layouts, no components, no SSR. Just a fast, typed API server with file-based routing.