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
# and the component gallery at /ui 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 listener shell (
node:http, orBun.serveon Bun; HTTP/2 if TLS is configured). - The listener shell answers what does not fit the request/response model, before the app handler is called at all: a WebSocket upgrade bound for a
route.tsthat exportsWS, and in dev the live-reload SSE stream at/__webjs/events. Both are intercepted on the node and Bun shells alike. The node shell also emits 103 Early Hints here in production, with modulepreload URLs for the matched page; Bun has no informational-response API, so that step does not exist on the Bun shell. - Declarative redirects from your
webjsconfig are answered:webjs.redirectsand thewebjs.trailingSlashcanonical-form policy, both of which reply with a redirect status and aLocationheader rather than routing the request onward. These are configured by your app but resolved by the framework ahead of everything below, so a 308 from a redirect rule is answered without your root middleware running. That is the case most likely to surprise you, so it is worth remembering specifically. - Framework-internal assets and probes are answered next, because they depend on neither the app analysis nor the vendor importmap and a cold instance must not gate them: the health, readiness and build-info probes (
/__webjs/health,/__webjs/ready,/__webjs/version), the core runtime (/__webjs/core/*), the dev reload client and its SharedWorker (/__webjs/reload.js,/__webjs/reload-worker.js), and downloaded vendor bundles (/__webjs/vendor/*). Not everything under/__webjs/*is here: the server-action RPC endpoint is routed with the app, below. In development only,/public/*plus the/sw.jsand/offline.htmlroot remaps and/favicon.icoare served here too, so a stylesheet is never queued behind the startup analysis. - Root middleware (
middleware.ts) runs next if present, for every request not already answered above. The rule behind the exceptions is worth holding onto rather than the list: anything the listener shell or the framework's pre-analysis stage answers never reaches your middleware, and everything routed with the app does. - Route matching: the router tries (in order) the server-action RPC endpoint (
/__webjs/action/<hash>/<fn>), static files, user source modules, API routes (route.ts), then page routes. The action endpoint is routed here rather than answered early, so root middleware DOES run for a server action, which is what lets you gate actions with auth or rate limiting. In production this is also where/public/*is served, so a middleware that guards an asset still guards it. - Segment middleware chain runs (outermost → innermost) for the matched route.
- For pages: the 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.
- Response is sent (with compression in prod and cache headers).
Progressive Enhancement
The SSR pipeline above produces real HTML. It 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: a server action binds straight into the form. A
<form action=${createPost}>posts to the page's own URL and 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.