Architecture
WebJs is a monorepo with three packages that together form the framework. Understanding the split helps when you need to import something specific or embed WebJs into another runtime.
Mental model, especially if you come from React Server Components: WebJs has no server/client component split. Pages, layouts, and components are isomorphic modules, the same source runs on the server to produce SSR'd HTML and then loads in the browser to add interactivity. There is no Flight protocol and no "server component" identity. The one server boundary is the .server.{js,ts} file, which is an RPC + source-protection mechanism (server actions and server-only utilities), not a server-rendered component. route.{js,ts} is a server-only HTTP handler. And elision (skipping the JS download of a module that does no client work) is a build-free optimization on those isomorphic modules, not a boundary, it never changes behaviour because progressive enhancement is the no-JS baseline.
Package Overview
webjs/ ├── packages/ │ ├── core/ # webjs : browser + server runtime │ ├── server/ # @webjsdev/server : dev/prod server, router, SSR, actions │ └── cli/ # @webjsdev/cli : webjs dev/start/build/db commands ├── examples/ │ └── blog/ # reference app exercising every feature ├── website/ # webjs.dev, including this documentation at /docs └── docs/ # docs.webjs.dev, a redirect-only host
WebJs (core)
Isomorphic: safe to import on both server and client. Contains:
html/css: tagged template literals for templates and stylesWebComponent: base class for custom elementsrender: client-side fine-grained DOM rendererrenderToString: server-side async HTML renderer with DSD injectionrepeat()is the keyed list directiveSuspense()is the streaming SSR boundarynotFound()/redirect()are navigation sentinelsconnectWS()is the auto-reconnecting WebSocket clientrichFetch()is the rich-type-aware fetch wrapperstringify()/parse()are webjs's built-in serializer (Date, Map, Set, BigInt, TypedArray, Blob, File, FormData, cycles)
@webjsdev/server
Server-only. Contains:
startServer()creates an HTTP(S) server with all features wiredcreateRequestHandler()returns a(Request) → Responsehandler for embedding- File-based router, SSR pipeline, server actions, WebSocket handler
cookies()/headers()provide request context via AsyncLocalStoragejson()/readBody()are content-negotiated JSON helpersrateLimit()is the in-memory fixed-window rate limiter- CSRF, compression, graceful shutdown, health probes, logger
@webjsdev/cli
The webjs command-line tool:
webjs dev: dev server with file watching + live reload via SSEwebjs start: production server, speaks plain HTTP/1.1 (front a reverse proxy for TLS + HTTP/2). No build step: serves source directly.webjs db generate/migrate/studio: Drizzle Kit wrappers (drizzle-kit)
Modules Architecture (Recommended)
For non-trivial apps, WebJs recommends a feature-scoped modules pattern:
my-app/ ├── app/ # thin route adapters │ ├── layout.ts │ ├── page.ts │ └── api/posts/route.ts # delegates to modules/posts ├── db/ # data layer (Drizzle) │ ├── connection.server.ts │ └── schema.server.ts ├── lib/ # cross-cutting infra │ └── session.ts ├── modules/ # feature-scoped business logic │ ├── auth/ │ │ ├── actions/ # mutations (one file per action) │ │ ├── queries/ # reads (one file per query) │ │ ├── components/ # feature-owned UI │ │ ├── utils/ # pure helpers │ │ └── types.ts # shared type definitions │ └── posts/ │ ├── actions/ │ ├── queries/ │ ├── components/ │ ├── utils/ │ └── types.ts ├── components/ # shared UI primitives └── middleware.ts # root middleware
Rules
- Routes stay thin. If a route.ts has more than ~20 lines of business logic, extract it into a module action.
- One module per feature. auth, posts, comments, etc. each get their own folder.
- Actions return
ActionResult<T>: a{ success, data } | { success: false, error, status }envelope that routes translate to HTTP responses mechanically. - Server-only imports (the DB driver
pg,node:*, anything needing Node APIs) stay in.server.{js,ts}files,route.tshandlers, ormiddleware.ts. Never in pages, layouts, or components, because page modules also load in the browser to register transitively imported components. Pages and components import functions from a.server.{js,ts}file; the framework rewrites that import into an RPC stub for the browser.
Imports: the # root alias
App-internal imports use the # path alias instead of deep ../../../ relatives:
import { db } from '#db/connection.server.ts';
import { Button } from '#components/ui/button.ts';
import { listPosts } from '#modules/posts/queries/list-posts.server.ts';
This is Node's native package.json "imports" field. The scaffold ships a single catch-all key, so every top-level folder is aliased and a new one needs no config change:
"imports": { "#*": "./*" }
It resolves at runtime on Node 24+ AND Bun with no build step and no tsconfig paths. The sigil is # (not @) and there is no slash after it (#lib/..., not #/lib/...): a #/-prefixed key does not resolve on Bun. WebJs expands the same map for its import graph, so a #-aliased .server.ts still trips the server-only boundary, and the browser importmap gets a matching scope per top-level folder automatically. A same-directory import stays a plain relative (./sibling.ts); opt out anywhere by writing a relative path.
Request Lifecycle
- HTTP request arrives at the Node HTTP server (or HTTP/2 if TLS configured).
- Root middleware (
middleware.ts) runs first if present. - 103 Early Hints sent (prod only) with modulepreload URLs for the matched page.
- Route matching: the router tries (in order) internal endpoints, static files, user source modules, API routes (
route.ts), then page routes. - Segment middleware chain runs (outermost → innermost) for the matched route.
- For pages: SSR pipeline runs (load page + layouts, render to HTML, inject DSD, collect metadata, stream response with Suspense).
- For API routes: the matched handler function runs, returns a Response.
- For WebSocket upgrades: the WS handler is invoked with the ws object + Request.
- Response is sent (with compression in prod and cache headers).
Progressive Enhancement
Step 6 above produces real HTML. The SSR pipeline runs every web component's render() on the server, so the component's initial markup is in the response before any script loads. The browser paints content, processes <a> links, and handles <form> submissions before any JavaScript runs. The client router, custom-element upgrades, and Suspense streaming are layered on top of that HTML. They enhance an already-working page, they do not constitute it.
- Read-paths: the SSR'd HTML is the user's first interaction. With JS disabled, content reads,
<a>links navigate, and display-only custom elements render correctly. - Write-paths: server actions are reachable as plain HTML form POSTs. A
<form action=/actions/foo>works without JavaScript, and the framework upgrades it to a partial-swap submission once the client router is active. - Interactivity: JavaScript is only required for behaviors that respond to user input:
@click, a signal mutation, drag handlers, focus management. The component's initial render is HTML either way. A counter shows "0" without JS, and only the +/- click handling needs scripts.
See Progressive Enhancement for the full design rationale and patterns.
Embedding
WebJs can be embedded in any Node-compatible runtime via createRequestHandler:
import { createRequestHandler } from '@webjsdev/server';
const app = await createRequestHandler({
appDir: process.cwd(),
dev: false,
});
// Use with any HTTP server:
const resp = await app.handle(new Request('http://x/api/hello'));
console.log(await resp.json());
This returns standard Request → Response, usable in Express, Fastify, Bun, Deno, Cloudflare Workers (with the file-system caveat documented in the deployment guide).
Coming from Next.js? The execution model above (isomorphic modules, no Server/Client Component split, the .server boundary as the one server boundary) is the biggest difference. The Migrating from Next.js guide maps each Next idiom to its WebJs equivalent.