Skip to content

Configuration

WebJs is designed to work with zero configuration. File conventions handle routing, TypeScript works out of the box, and the server is pre-configured with sensible defaults. This page documents what you can configure when you need to.

CLI Options

webjs dev

webjs dev [--port 8080]
  • --port: dev server port. Precedence is --port > PORT (a real env var or a PORT in the app's .env) > 8080. A real exported PORT still wins over the .env value, matching the auto-load's shell-wins-over-file rule.
  • File watching via Node's built-in fs.watch (automatic)
  • Live reload via SSE (/__webjs/events)
  • TypeScript files transformed on the fly
  • No cache-busting needed, since module loads are busted per request

webjs start

webjs start [--port 8080]
  • --port: production server port. Same precedence as dev: --port > PORT (real env var or .env) > 8080.
  • Speaks plain HTTP/1.1. TLS termination + HTTP/2 to the browser is the proxy's job (PaaS edges or nginx/Caddy/Traefik)
  • gzip/brotli compression enabled by default
  • Static file ETag + Cache-Control headers
  • Graceful shutdown on SIGTERM/SIGINT
  • JSON logger (structured, one line per event)

webjs db

webjs db generate     # drizzle-kit generate
webjs db migrate      # drizzle-kit migrate
webjs db push         # drizzle-kit push
webjs db studio       # drizzle-kit studio
webjs db seed         # run db/seed.server.ts

webjs routes

webjs routes                    # a grouped tree of pages + route handlers
webjs routes --table            # aligned KIND / PATH / METHODS / FILE columns
webjs routes --table --no-headers   # same, without the header row (pipe-friendly)
webjs routes --json             # structured JSON (matches the MCP list_routes tool)

Prints the route table to stdout: every page (path, owner file, dynamic params) and every route.{js,ts} handler (path, owner file, HTTP methods). It reuses the same route walker that backs the typed-routes generator and the dev server, so it always reflects exactly what the framework will serve. The --json shape is byte-identical to the read-only MCP list_routes tool, so an agent gets the same data whether it shells out or calls the MCP.

webjs doctor

webjs doctor            # human-readable project-health checklist
webjs doctor --json     # structured results (each with a stable code) + a summary
webjs doctor --strict   # also fail on EVERY remaining warning, not just hard failures and gated errors

Per-check severity is configuration, not a flag. Declare it in package.json under webjs.doctor.gate, keyed by the stable code every result carries, on the same three-level scale ESLint uses. That is what lets CI gate on a chosen subset without --strict making every warning fatal, which is unusable on a runner (the git-hook, env-drift, vendor-pin, framework-resolve, and framework-links checks are all environment-shaped and would fail a perfectly healthy build).

{
  "webjs": {
    "doctor": {
      "gate": {
        "UNMARKED_ASSET_LINKS": "error",
        "ELISION_CARRIERS": "off"
      }
    }
  }
}

error fails the exit, warn reports without failing, and off silences the check: its finding is not printed and it cannot fail the exit, including under --strict. A silenced check still appears on the checklist as [off] and in the summary's silenced count, so it is never invisible, and --json still carries its whole result. A code with no entry keeps its default (error for a hard toolchain failure, warn otherwise), so an app that declares nothing behaves exactly as it did before. Two guarantees make it safe to put in a required CI job: a result that could not check, such as a network or toolchain outage, is capped at warn and can never be escalated, and a malformed gate exits 1 naming the offender rather than being ignored, so a typo cannot silently un-gate the build. That last one covers an unknown code, a bad severity, a wrong shape (a non-object doctor or gate), and a misspelled sibling of gate such as gates; under --json they come back as a configErrors array alongside an empty results.

Verifies project health: the Node version floor, erasableSyntaxOnly, .env drift, vendor-pin freshness, importmap coherence, @webjsdev/* version coherence, framework resolvability, framework link integrity, the git hook, a page/layout elision advisory, and a warning when a route module writes a <link rel="stylesheet"> without asset() (so its url is un-versioned and a deploy cannot bust a cached copy). Each result carries a stable machine code (for example NODE_VERSION, TSCONFIG_ERASABLE, IMPORTMAP_COHERENCE) so an agent branches on the failure kind, not the message text. The --json payload is an object { results, summary } (the results array holds the per-check objects, each with its code and its effective severity; the summary counts pass / warn / fail / off). A rejected webjs.doctor config is the one path that adds a third key, configErrors, with results empty because no check ran. The exit is non-zero on a hard toolchain failure, and on any check the app gated error (see the severity gate above); --strict additionally fails on every remaining warning, so it can gate a fully-clean fix loop the way webjs check --json does.

webjs version

webjs version           # print the installed @webjsdev/cli version
webjs --version  /  -v  # the same, flag form

Prints the installed @webjsdev/cli version, so an agent can detect the toolchain version before relying on a command.

webjs help

webjs help              # the full command banner
webjs help routes       # usage + summary + an Options table + Examples for one command
webjs --help  /  -h     # the banner (flag form)
webjs routes --help     # one command's help (flag form)

webjs help <command> prints that command's exact usage line, a one-line summary, an Options table documenting every flag, and worked examples, so you (or an agent) read the real invocation instead of guessing flags. The --help / -h flag forms are equivalent: bare at the top level for the banner, or after a command for that command's help. Commands that wrap an external tool (typecheck to tsc, db to drizzle-kit, ui to @webjsdev/ui) forward --help to that tool. An unknown help topic exits non-zero.

tsconfig.json

Optional but recommended for editor + CI type-checking:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "types": ["node"],
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "skipLibCheck": true,
    "erasableSyntaxOnly": true,
    "plugins": [{ "name": "@webjsdev/intellisense" }]
  },
  "include": [
    "app/**/*",
    "components/**/*",
    "modules/**/*",
    "lib/**/*",
    "test/**/*",
    "middleware.js",
    "middleware.ts",
    ".webjs/routes.d.ts"
  ],
  "exclude": ["node_modules", ".webjs/vendor", "db/migrations"]
}

Key settings:

  • noEmit: type-check only, no compiled output (preserves no-build)
  • allowImportingTsExtensions: needed for explicit .ts in imports
  • erasableSyntaxOnly: rejects the TypeScript syntax Node's built-in stripper cannot erase
  • include covers test/, so npm run typecheck reads the tests you write and a type error there fails the same one command

webjs check: correctness, not config

webjs check runs a fixed set of correctness checks (a crash, a security leak, a reactive prop that silently stops re-rendering, a build or type-strip failure). They always run; there is no project-level config to disable them, and the checks read no package.json config block of their own. Project conventions (layout, naming, testing) are guidance in the shipped skill (.agents/skills/webjs/SKILL.md) and AGENTS.md, not a tool. See Conventions & AI Workflow for the split and run webjs check --rules to list the checks.

Security response headers

WebJs sets standard security headers on every response by default (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, plus Strict-Transport-Security in production over HTTPS). Override or extend them per path with a webjs.headers block in package.json, an array of { source, headers: [{ key, value }] } rules where source is a URLPattern path pattern and a null value removes a default. App middleware wins over the path config, which wins over the defaults. A webjs.csp key (off by default) additionally mints a per-request CSP nonce and emits a matching Content-Security-Policy header. See Deployment → Secure response headers for the full reference.

Redirects

For a moved URL, declare a redirect under webjs.redirects in package.json, an array of { source, destination, permanent?, statusCode? } rules. source is a URLPattern path pattern (so :param / :rest* works) and destination is the target: a path, a path referencing named groups from the source (/posts/:slug filled from /blog/:slug), or an absolute URL for an external redirect. permanent defaults to true (a 308 Permanent Redirect, what SEO wants so link equity transfers); permanent: false is a 307 Temporary Redirect. 308 / 307 preserve the request method (a redirected POST stays a POST); for a legacy 301 / 302 set statusCode explicitly. The incoming query string is preserved by default. Redirects apply at the very start of request handling, before routing, and a malformed entry is dropped with a warning rather than crashing the app.

{
  "webjs": {
    "redirects": [
      { "source": "/old", "destination": "/new" },
      { "source": "/blog/:slug", "destination": "/posts/:slug" },
      { "source": "/legacy", "destination": "/", "permanent": false },
      { "source": "/docs", "destination": "https://docs.example.com" }
    ]
  }
}

Trailing slash

webjs's file router matches /about and /about/ against the same route, so both render identical HTML. That is duplicate content for SEO (the two URLs split link equity) and two keys in the client-router cache. Pick one canonical form with a webjs.trailingSlash key in package.json: "never" strips a trailing slash (/about//about, the recommended form for most apps), "always" adds one (/about/about/), and "ignore" (the default, also the behavior when the key is absent) does nothing, so an existing app is unchanged unless it opts in. The non-canonical URL gets a 308 Permanent Redirect (link equity transfers, and a redirected POST stays a POST). The root / is always left alone; under "always" a path whose last segment looks like a file (has a dot, e.g. /logo.png) is not given a trailing slash. The query string is preserved. Canonicalization runs right after the webjs.redirects rules (so an explicit redirect wins first), at the very start of request handling. There is no server-side loop guard: a redirect whose destination contradicts the slash policy (e.g. "never" with a destination of /x/) creates an infinite redirect loop, so keeping redirect destinations consistent with the policy is your responsibility.

{ "webjs": { "trailingSlash": "never" } }

Base path (sub-path deployment)

Deploying an app under a sub-path (example.com/app/) behind a proxy that does not strip the prefix breaks module resolution unless every framework-emitted URL carries the prefix. Set webjs.basePath in package.json and WebJs handles it: "app", "/app", and "/app/" all normalize to /app, a nested /foo/bar is allowed, and an empty value (or the absence of the key) is a root mount that changes nothing. The prefix is stripped from the incoming request path at the very start of request handling (so route matching, redirects, the trailing-slash policy, and the header config all see a root-relative path) and prepended to every framework-emitted absolute URL: the importmap targets, the modulepreload hints, the boot script's module specifiers, and the dev reload script. A request whose path is not under the base path is not for this app and returns a 404. Author-written <a href> links and client-side navigation are not auto-prefixed yet (a documented follow-up), so target the prefix in your own links when deploying under a base path.

{ "webjs": { "basePath": "/app" } }

Client router

The client router is automatic: it auto-enables in the browser whenever @webjsdev/core loads (any page that ships a component), so SPA-style navigation needs no import or setup. To opt the whole app out and use plain full-page (multi-page) navigation instead, set webjs.clientRouter to false. Components still hydrate and stay interactive; only the link and form interception is disabled, so every navigation is a full browser load. The default (and any value other than false) keeps the router on. See Client Router for the runtime disableClientRouter() / enableClientRouter() escape hatches.

{ "webjs": { "clientRouter": false } }

SSR action seeding

Each 'use server' action result invoked during a buffered SSR render is serialized into the page, and the generated RPC stub reads that seed on its first client call, so a shipping async component does not re-issue the request on hydration. It is on by default and needs no code. Turn it off with webjs.seed in package.json or WEBJS_SEED=0, in which case the client re-fetches on hydration exactly as it did before the feature.

{ "webjs": { "seed": false } }

Seeing whether it works. A seed miss is invisible from the outside: the page still renders correctly, it just pays a network round-trip per async component on every first load. So in development (and only there) every page response carries an X-Webjs-Seed header, also folded into the access-log line as a seed field. Its value is off when seeding is disabled, html-cache when the cached-HTML path answered, collected=<m>, emitted=<n> on a normal render, and collected=<m>, emitted=0, streamed on a page carrying a Suspense or <webjs-suspense> boundary (a streamed render emits no seeds, because its deferred regions resolve after the first flush). A collected above emitted means the serializer could not encode a returned value and the whole block was dropped.

The browser logs one warning per page view when a hydration call missed its seed AND the cause is provable, naming which applies; it stays silent otherwise, including on a page that emitted no seeds, where the header is the reliable signal. Development also warns once per action function when a single render returns two different results for the same arguments, the one case where a seed can disagree with the paint the user is looking at. None of this reaches production: no header, no marker, and prod HTML is byte-identical.

Display-only elision

WebJs never downloads a component module that does no client work: the import is stripped from the served source and the module, its modulepreload hint, and any vendor reachable only through it are pruned. This is on by default and biased toward shipping, so anything ambiguous keeps its JavaScript. Set webjs.elide to false to turn it off app-wide, which makes every module ship.

{ "webjs": { "elide": false } }

The WEBJS_ELIDE environment variable overrides the config key per run (0 / false / off / no force it off, 1 / true / on / yes force it on). That override is also the seam webjs elision --verify uses to render one app both ways in a single process.

WEBJS_ELIDE=0 npm run start

Reach for the switch to isolate a bug, not as a permanent setting: everything it turns off is JavaScript your users would otherwise never download. To see WHAT is being dropped and why, run webjs elision. See Display-Only Elision.

Request limits & server timeouts

The server caps inbound request bodies and bounds connection lifetimes by default, so an uncapped body is not a memory-exhaustion vector and a slow connection is not a slowloris vector. Both apply with secure defaults when unset and are configurable in package.json (env overrides win, and a value of 0 disables that limit / timeout).

Body-size limit (413). Every request body the server reads (the action RPC endpoint, route.{js,ts} handlers via readBody, and the no-JS form-action dispatch path) is capped. A JSON / RPC body defaults to 1 MiB (webjs.maxBodyBytes or WEBJS_MAX_BODY_BYTES); a form / multipart body defaults to 10 MiB (webjs.maxMultipartBytes or WEBJS_MAX_MULTIPART_BYTES). An over-limit body responds 413 Payload Too Large and is never buffered whole: a Content-Length over the cap is rejected before the body is read, and a chunked body with no declared length is abandoned the instant it crosses the cap.

Server timeouts. The production server sets three node:http built-ins: requestTimeout (30s, webjs.requestTimeoutMs / WEBJS_REQUEST_TIMEOUT_MS) bounds the time to receive the whole request, headersTimeout (20s, webjs.headersTimeoutMs / WEBJS_HEADERS_TIMEOUT_MS) the time to receive just the headers, and keepAliveTimeout (5s, webjs.keepAliveTimeoutMs / WEBJS_KEEP_ALIVE_TIMEOUT_MS) the idle window before a kept-alive socket is closed. Per node semantics headersTimeout must be under requestTimeout to fire, so an inconsistent config is clamped automatically.

{ "webjs": { "maxBodyBytes": 262144, "maxMultipartBytes": 5242880, "requestTimeoutMs": 30000 } }

Config typos are reported at boot

Every key in the webjs block is optional, which means an unknown one has no way to announce itself: write "redirect" for "redirects" and the key is simply never read, the feature sits at its default, and the app looks configured. So WebJs validates the block against its published JSON Schema once per boot, in development and in production alike, and prints a single warning naming what it ignored.

It is a warning, never a failure. A typo costs one feature its setting, and refusing to boot over that would cost the whole app, usually mid-deploy.

What it catches, and what it does not. It reports any unknown top-level key, whatever it is called. It also checks the value of 9 of the 17 known keys: one against an allowed set (trailingSlash), and eight against a boolean or whole-number type (elide, seed, clientRouter, maxBodyBytes, maxMultipartBytes, and the three timeouts). It does not descend into nested objects, so a key misspelled inside webjs.dev or webjs.start is missed, and it does not check the type of the other 8 (headers, redirects, basePath, allowedOrigins, csp, dev, start, doctor), whose schemas are the free-form ones a blunt check would start rejecting working configs over. Give one of those the wrong type outright, as in "headers": "x", and it passes this check. Whether anything downstream then says so is up to that key's own reader, and the two array-valued ones do: webjs.headers and webjs.redirects each warn when the key is present but not an array, and again for every individual rule or entry they drop, naming what was ignored. An absent key is the default and says nothing.

Editors catch more of this earlier still: a scaffolded app wires the schema into .vscode/settings.json, and the WebjsConfig type from @webjsdev/core types the block while you author it. And webjs.doctor is checked by a different tool entirely: the server never reads it, and webjs doctor exits non-zero on a malformed one, because a silently ignored gate would leave CI un-gated while looking gated.

Environment Variables

Use process.env in server-side code (pages, actions, route handlers, middleware). WebJs auto-loads <appDir>/.env into process.env once at boot using Node 24+'s built-in process.loadEnvFile, so a scaffolded app with a committed .env.example and a developer-copied .env just works without installing dotenv or wiring up the file path. The auto-load fires before any server-only module is imported, which matters for code that reads process.env at module-init time (e.g. createAuth({ secret: process.env.AUTH_SECRET })).

Precedence: shell wins over file. process.loadEnvFile does not override values that are already present in process.env, so values exported by the host shell or a process manager (Docker, systemd, Railway, Fly) take precedence over the same key in .env. This matches the Rails / Next / Astro convention: .env is for developer-local defaults; production secrets come from the platform.

No file, no problem. A missing .env, a malformed file, or running on Node without loadEnvFile all fail silently. The server still boots; only the missing values are undefined (the same way a typo would be).

Override per-invocation by passing values on the command line:

DATABASE_URL=postgres://... npm start

Validating env vars at boot (env.{js,ts})

The auto-load populates process.env but does not check it, so a missing or misconfigured required var (an absent DATABASE_URL, a too-short AUTH_SECRET) fails late and cryptically: a database connection error mid-request, an undefined secret signing a token. Add an optional env.{js,ts} module at the app root (a sibling of middleware.js and readiness.js) that default-exports a schema, and WebJs validates the environment at boot and fails fast with one message listing every problem at once.

// env.ts (app root)
export default {
  DATABASE_URL: 'string',                                   // required by default
  AUTH_SECRET: { type: 'string', required: true, minLength: 16 },
  PORT: { type: 'number', optional: true, default: 8080 },  // coerced + defaulted (webjs default port)
  NODE_ENV: { type: 'enum', values: ['development', 'production', 'test'] },
};

Each field is a type name ('string') or an options object. Supported types: string, number, boolean, url, enum. A field is required by default; mark it optional: true (or give it a default) to allow it to be absent. String fields support minLength and a pattern (a RegExp or string); enum fields take a values array. Coerced values (a number, a boolean) and applied defaults are written back to process.env, so the app reads the coerced value.

Fails fast, reports everything. On a validation failure the server does not start. It throws a clear, aggregated Error naming every offending var and what is wrong (missing, wrong type, failed constraint), so the CLI exits non-zero and an embedded host rejects at boot. The whole list is reported at once, never one error at a time.

Escape hatch: a function validator. Instead of a schema object, default-export a function (env) => void. It runs at boot with the env object and any thrown Error becomes the boot failure. This is how an app uses zod (or any validator) without WebJs depending on it:

// env.ts (function form)
import { z } from 'zod';
const schema = z.object({ DATABASE_URL: z.string().url(), AUTH_SECRET: z.string().min(16) });
export default (env) => { schema.parse(env); };

The whole feature is opt-in: with no env.{js,ts} at the app root, nothing changes.

Server-only env vars (the default)

Any environment variable that does not start with WEBJS_PUBLIC_ is server-only. It is never sent to the browser. DATABASE_URL, AUTH_SECRET, OAuth client secrets, third-party API keys: read them in server actions, route handlers, middleware, or page functions, and pass derived values (not the raw secret) to components.

Public env vars (WEBJS_PUBLIC_*)

Any env var whose name starts with WEBJS_PUBLIC_ is exposed to the browser as process.env.WEBJS_PUBLIC_X. WebJs injects an inline script in the SSR'd HTML head that sets window.process.env before any user code or vendor bundle runs. Components can read these directly:

// .env at the app root (auto-loaded at boot)
WEBJS_PUBLIC_API_URL=https://api.example.com
WEBJS_PUBLIC_STRIPE_KEY=pk_live_abc
SENTRY_DSN=https://[email protected]/y      # server-only, no prefix

// components/checkout.ts
class Checkout extends WebComponent {
  render() {
    return html`<a href=${process.env.WEBJS_PUBLIC_API_URL + '/pay'}>Pay</a>`;
  }
}

This is the no-build equivalent of Next.js's NEXT_PUBLIC_ convention. There is no transform step. The value is a real property read on a real window.process.env object in the browser.

NODE_ENV is always defined in the browser. The shim sets process.env.NODE_ENV to 'development' in webjs dev or 'production' in webjs start. Vendor bundles that probe process.env.NODE_ENV (lit, react, others) read the right value with no extra config.

Naming and safety. The prefix is fail-closed. An env var without WEBJS_PUBLIC_ in its name cannot accidentally reach the browser at runtime, even if a component naively writes process.env.DATABASE_URL. The value will read as undefined, the same way a typo would. There is no way to opt out of the prefix, by design.

The SSR-time gap, and the lint rule that closes it. A component's render() runs on the server during SSR. If a component reads process.env.SECRET there and interpolates it into the HTML output, the secret gets shipped to every browser even though the runtime shim does not expose it. To catch this at write time, webjs check ships a no-server-env-in-components rule that flags any process.env.X read in a component file when X is not WEBJS_PUBLIC_* and not NODE_ENV. The fix is always one of: rename to WEBJS_PUBLIC_* if the value is intended for the browser, or read it in a page function / server action / middleware and pass a derived value to the component as an attribute.

Programmatic API

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

// Option 1: Full server
await startServer({
  appDir: process.cwd(),
  port: 8080,
  dev: false,
  compress: true,
  http2: false,
  logger: myCustomLogger, // { info, warn, error }
});

// Option 2: Embeddable handler
const app = await createRequestHandler({
  appDir: process.cwd(),
  dev: false,
  logger: myCustomLogger,
});
const resp = await app.handle(new Request('http://x/api/hello'));

What Can't Be Configured

Some things are intentionally fixed:

  • Routing conventions: page.ts, layout.ts, route.ts, middleware.ts, error.ts, not-found.ts are the file names. No aliases.
  • Light DOM by default: components render into light DOM so global CSS and Tailwind utilities apply directly. Opt into shadow DOM per component with static shadow = true. No global toggle.
  • CSRF on server actions: always on for /__webjs/action/* RPC. Can't disable.
  • Import map: auto-generated. Maps @webjsdev/core sub-paths to framework-served URLs and any bare npm imports your client code uses to vendor bundles.