Skip to content

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 styles
  • WebComponent: base class for custom elements
  • render: client-side fine-grained DOM renderer
  • renderToString: server-side async HTML renderer with DSD injection
  • repeat() is the keyed list directive
  • Suspense() is the streaming SSR boundary
  • notFound() / redirect() are navigation sentinels
  • connectWS() is the auto-reconnecting WebSocket client
  • richFetch() is the rich-type-aware fetch wrapper
  • stringify() / 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 wired
  • createRequestHandler() returns a (Request) → Response handler for embedding
  • File-based router, SSR pipeline, server actions, WebSocket handler
  • cookies() / headers() provide request context via AsyncLocalStorage
  • json() / readBody() are content-negotiated JSON helpers
  • rateLimit() 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 SSE
  • webjs 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.ts handlers, or middleware.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

  1. HTTP request arrives at the listener shell (node:http, or Bun.serve on Bun; HTTP/2 if TLS is configured).
  2. 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.ts that exports WS, 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.
  3. Declarative redirects from your webjs config are answered: webjs.redirects and the webjs.trailingSlash canonical-form policy, both of which reply with a redirect status and a Location header 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.
  4. 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.js and /offline.html root remaps and /favicon.ico are served here too, so a stylesheet is never queued behind the startup analysis.
  5. 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.
  6. 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.
  7. Segment middleware chain runs (outermost → innermost) for the matched route.
  8. For pages: the SSR pipeline runs (load page + layouts, render to HTML, inject DSD, collect metadata, stream response with Suspense).
  9. For API routes: the matched handler function runs, returns a Response.
  10. 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.