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 the exit on warnings, not just hard failures

Verifies project health: the Node version floor, erasableSyntaxOnly, .env drift, vendor-pin freshness, importmap coherence, @webjsdev/* version coherence, framework resolvability, the git hook, and a page/layout elision advisory. 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). By default the exit is non-zero only on a hard toolchain failure; --strict also fails on warnings, 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"],
    "strict": true,
    "noEmit": true,
    "checkJs": true,
    "allowJs": true,
    "allowImportingTsExtensions": true,
    "skipLibCheck": true
  },
  "include": ["app/**/*", "components/**/*", "modules/**/*", "lib/**/*"],
  "exclude": ["node_modules", ".webjs"]
}

Key settings:

  • noEmit: type-check only, no compiled output (preserves no-build)
  • allowImportingTsExtensions: needed for explicit .ts in imports
  • checkJs: type-check .js files too (for mixed codebases)

webjs check: correctness, not config

webjs check runs a fixed set of correctness checks (a crash, a security leak, 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 } }

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 page-action form 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 } }

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.