Routing
WebJs uses file-based routing. Every file under your project's
app/ directory maps to a URL based on its folder path. There is no
central route configuration file. The file system is the router.
The conventions (page.ts,layout.ts,route.ts,[param]folders,(group)folders,not-found.ts,error.ts, andloading.ts) are adapted for a no-build, web-components-first architecture and will feel familiar if you have used the NextJs App Router.
File Conventions at a Glance
app/
├── layout.ts # root layout, wraps every page
├── page.ts # home page → /
├── not-found.ts # 404 fallback
├── error.ts # root error boundary
├── loading.ts # loading UI (reserved for Suspense)
├── middleware.ts # root middleware
├── about/
│ └── page.ts # /about
├── blog/
│ ├── layout.ts # nested layout for /blog/*
│ ├── page.ts # /blog
│ └── [slug]/
│ └── page.ts # /blog/:slug (dynamic)
├── files/
│ └── [...rest]/
│ └── page.ts # /files/* (catch-all)
├── (marketing)/
│ └── pricing/
│ └── page.ts # /pricing (group folder not in URL)
├── _internal/
│ └── helpers.ts # excluded from routing (private folder)
└── api/
├── hello/
│ └── route.ts # GET /api/hello
└── chat/
└── route.ts # GET + WS /api/chat
Files can use .ts, .js, .mts, or
.mjs extensions. TypeScript files run via Node 24+'s
built-in type-stripping, no build step required.
Pages (page.ts / page.js)
A page.ts file makes a route publicly accessible. Its
default export is an async function that receives a context object
with params, searchParams, and url. The
function runs only on the server during SSR. It never ships to the
browser.
Signature
// app/blog/[slug]/page.ts
import { html } from '@webjsdev/core';
type Ctx = {
params: { slug: string };
searchParams: Record<string, string>;
url: string;
};
export default async function BlogPost({ params, searchParams, url }: Ctx) {
const post = await db.posts.findBySlug(params.slug);
const page = Number(searchParams.page) || 1;
return html`
<h1>${post.title}</h1>
<p>${post.body}</p>
<p>Page ${page} · Loaded from ${url}</p>
`;
}
params: the dynamic route segments (e.g.{ slug: "hello-world" }). Readable synchronously (params.slug) AND awaitable (const { slug } = await params, the Next 15/16 pattern). Both work.searchParams: the query-string key/value pairs (e.g.{ page: "2" }). Also sync-readable and awaitable.url: the full request URL as a string.
The function can be async. You can await database
queries, fetch calls, or any server-side work directly inside the page function.
Return an html`...` tagged template literal (a TemplateResult)
and WebJs will render it to HTML on the server.
Layouts (layout.ts)
A layout.ts file wraps every page (and nested layout) in its directory
and all subdirectories. It receives a children prop containing the
rendered page or inner layout.
Root Layout
// app/layout.ts
import { html } from '@webjsdev/core';
export default function RootLayout({ children }: { children: unknown }) {
return html`
<nav><a href="/">Home</a> | <a href="/about">About</a></nav>
<main>${children}</main>
<footer>© 2025 My App</footer>
`;
}
Nested Layout
// app/dashboard/layout.ts
import { html } from '@webjsdev/core';
export default function DashboardLayout({ children }: { children: unknown }) {
return html`
<div class="dashboard">
<aside>
<a href="/dashboard">Overview</a>
<a href="/dashboard/settings">Settings</a>
</aside>
<section>${children}</section>
</div>
`;
}
Layouts nest automatically by folder depth. For the route
/dashboard/settings, WebJs renders:
RootLayout
└── DashboardLayout
└── SettingsPage
The root app/layout.ts is the outermost wrapper. Every layout in the
chain receives the same params, searchParams, and
url context as the page, plus the children prop.
Layouts and client navigation
Nested layouts double as partial-swap boundaries for the client
router. The SSR pipeline auto-emits keyed boundary comment pairs
(<!--wj:children:<segment>:<route-key>--> ...
<!--/wj:children:<segment>-->) around each layout's
${children} interpolation and around the page itself. When a user
clicks a link, the router compares route-keys: a changed key remounts fresh at
the parent boundary (whose range contains the changed layout's own markup,
Next.js param-change parity), an unchanged one swaps only the deepest shared
boundary's children. The outer layouts' DOM (and any state inside
them: sidenav scroll, input values, mounted custom elements) stays mounted.
Authors write nothing extra; the boundary emission is invisible.
See the client router docs for the full
mechanism, including form submissions, snapshot cache, and the
<webjs-frame> escape hatch.
Dynamic Routes
Wrap a folder name in square brackets to create a dynamic segment. The folder name becomes the parameter key.
Single Parameter
app/
└── users/
└── [id]/
└── page.ts # matches /users/42, /users/abc, etc.
// app/users/[id]/page.ts
import { html } from '@webjsdev/core';
export default async function UserPage({ params }: { params: { id: string } }) {
const user = await db.users.find(params.id);
return html`<h1>${user.name}</h1>`;
}
Multiple Parameters
app/
└── blog/
└── [year]/
└── [month]/
└── page.ts # /blog/2025/04 → { year: "2025", month: "04" }
Catch-All Routes ([...rest])
Prefix the parameter name with ... to capture all remaining path
segments as a single string.
app/
└── files/
└── [...path]/
└── page.ts # /files/a/b/c → { path: "a/b/c" }
// app/files/[...path]/page.ts
import { html } from '@webjsdev/core';
export default function FilesPage({ params }: { params: { path: string } }) {
const segments = params.path.split('/');
return html`
<h1>File browser</h1>
<p>Current path: /files/${params.path}</p>
<ul>
${segments.map((s) => html`<li>${s}</li>`)}
</ul>
`;
}
Route precedence is positional and deterministic, the same model as Next.js.
Segment by segment, a static literal outranks a dynamic [param], which
outranks a catch-all, so the catch-all kind is the lowest priority
at its position, not a blanket "always last" rule. So
/[user]/settings (a static tail) correctly wins over
/[org]/[repo] for /acme/settings, and
/docs/[[...slug]] (a literal first segment) correctly wins over
/[org]/[repo] for /docs/x even though it ends in a
catch-all. An explicit /docs still beats the optional-catch-all base
/docs/[[...slug]] for /docs itself. A genuine tie between
two equally specific routes resolves by a stable alphabetical key, never by file
order, so the match is the same across machines and deploys.
Route Groups ((group))
Folders wrapped in parentheses are route groups. They do not appear in the URL, but they do scope layouts and middleware to a subset of routes.
app/
├── (marketing)/
│ ├── layout.ts # marketing-specific layout
│ ├── about/
│ │ └── page.ts # URL is /about (not /marketing/about)
│ └── pricing/
│ └── page.ts # URL is /pricing
└── (app)/
├── layout.ts # app-specific layout (authenticated shell)
└── dashboard/
└── page.ts # URL is /dashboard
This is useful when you want different layouts or middleware for different sections of your site without affecting the URL structure.
Private Folders (_private)
Any folder whose name starts with an underscore (_) is
excluded from routing entirely. Files inside it will never match a
URL. Use private folders for shared utilities, internal helpers, or any code you
want to colocate with your routes without exposing it.
app/ ├── _lib/ │ ├── db.ts # not a route, safe to import from pages │ └── auth.ts ├── _components/ │ └── header.ts # colocated components, not routed └── page.ts # /, can import from _lib/ and _components/
API Routes / Route Handlers (route.ts)
A route.ts file defines an API endpoint. Instead of a default export,
you export named functions for each HTTP method you want to handle:
GET, POST, PUT, PATCH, and
DELETE.
Route handlers can live anywhere under app/, not
just inside app/api/. The api/ prefix is a convention,
not a requirement.
Basic Example
// app/api/hello/route.ts
export async function GET(req: Request) {
const url = new URL(req.url);
const name = url.searchParams.get('name') || 'world';
return { hello: name, at: new Date().toISOString() };
}
export async function POST(req: Request) {
const body = await req.json();
// ... create resource ...
return Response.json({ created: true }, { status: 201 });
}
Handler Signature
Each handler receives a standard Request object and an optional
context object with params from dynamic route segments:
export async function GET(
req: Request,
{ params }: { params: Record<string, string> }
): Promise<Response | object>
- Return a
Responseobject for full control over status, headers, and body. - Return a plain object or array and WebJs will automatically serialize it as
JSONwith a200status. - If a request arrives for a method you have not exported, WebJs responds with
405 Method Not Allowedand anAllowheader listing the supported methods.
Dynamic API Route
// app/api/posts/[slug]/route.ts
type Ctx = { params: { slug: string } };
export async function GET(_req: Request, { params }: Ctx) {
const post = await db.posts.findBySlug(params.slug);
if (!post) return Response.json({ error: 'Not found' }, { status: 404 });
return Response.json(post);
}
export async function DELETE(_req: Request, { params }: Ctx) {
await db.posts.delete(params.slug);
return Response.json({ deleted: true });
}
WebSocket Routes
To handle WebSocket connections, export a WS function from the same
route.ts file. When a client sends a WebSocket upgrade request to that
URL, WebJs upgrades the connection and calls your WS handler.
// app/api/chat/route.ts
import type { WebSocket } from 'ws';
const clients = new Set<WebSocket>();
// Regular HTTP handler, still works alongside WS
export function GET() {
return new Response(
`Connected clients: ${clients.size}`,
{ headers: { 'content-type': 'text/plain' } },
);
}
// WebSocket handler, called on upgrade
export function WS(ws: WebSocket, req: Request, { params }: { params: Record<string, string> }) {
clients.add(ws);
ws.on('message', (data) => {
const text = data.toString();
for (const client of clients) {
if (client.readyState === 1) client.send(text);
}
});
ws.on('close', () => {
clients.delete(ws);
});
}
WS Handler Signature
export function WS(
ws: WebSocket, // the ws library WebSocket instance
req: Request, // the original HTTP upgrade request (headers, cookies, etc.)
ctx: { params: Record<string, string> } // dynamic route params
): void
The req argument gives you access to cookies, authorization headers,
and query parameters so you can authenticate the connection before accepting
messages. WebSocket handlers work with dynamic routes just like HTTP handlers.
Not Found (not-found.ts)
Place a not-found.ts file at the root of your
app/ directory to customize the 404 page. It is rendered whenever no
route matches a request, or when a page calls notFound().
// app/not-found.ts
import { html } from '@webjsdev/core';
export default function NotFound() {
return html`
<h1>404</h1>
<p>Page not found.</p>
<p><a href="/">← Home</a></p>
`;
}
If no not-found.ts exists, WebJs renders a default
<h1>404 Not Found</h1> page.
Error Boundaries (error.ts)
An error.ts file acts as an error boundary. When an
uncaught error occurs during page rendering, WebJs walks up the folder tree looking
for the nearest error.ts and renders it instead. This
means error boundaries are nested. A deeply-placed error.ts catches
errors for its subtree without affecting the rest of the site.
// app/error.ts: root error boundary
import { html } from '@webjsdev/core';
export default function ErrorBoundary({ error }: { error: unknown }) {
const message = error instanceof Error ? error.message : String(error);
return html`
<h1>Something went wrong</h1>
<p>${message}</p>
<p><a href="/">Go home</a></p>
`;
}
Nested Error Boundaries
app/ ├── error.ts # catches errors site-wide ├── dashboard/ │ ├── error.ts # catches errors only in /dashboard/* │ ├── page.ts │ └── settings/ │ └── page.ts # error here → caught by dashboard/error.ts
The error boundary receives the context object (params,
searchParams, url) along with the error
property. Errors from notFound() and redirect() are
not caught by error boundaries. Those are handled specially by the
framework.
Metadata
WebJs supports two ways to define page metadata (title, description, Open Graph tags, etc.):
Static Metadata
Export a metadata object from any page or layout:
// app/about/page.ts
import { html } from '@webjsdev/core';
export const metadata = {
title: 'About | My App',
description: 'Learn more about our company.',
openGraph: {
title: 'About Us',
description: 'We build cool things.',
},
};
export default function About() {
return html`<h1>About</h1>`;
}
Dynamic Metadata (generateMetadata)
Export an async generateMetadata function for metadata that depends on
route params or data fetching:
// app/blog/[slug]/page.ts
import { html } from '@webjsdev/core';
export async function generateMetadata({ params }: { params: { slug: string } }) {
const post = await db.posts.findBySlug(params.slug);
return post
? { title: `${post.title} | My Blog` }
: { title: 'Not found | My Blog' };
}
export default async function PostPage({ params }: { params: { slug: string } }) {
// ...
}
Metadata Merging
Metadata is collected from the outermost layout to the page. Later values override earlier ones, so a page's metadata takes precedence over its layout's metadata. Supported fields include:
title: sets<title>description: sets<meta name="description">viewport: sets<meta name="viewport">(defaults towidth=device-width,initial-scale=1)themeColor: sets<meta name="theme-color">openGraph: an object of Open Graph properties (keys becomeog:key)preload: an array of<link rel="preload">entries (e.g. fonts, images)
Navigation Helpers
WebJs exports four navigation primitives from '@webjsdev/core' -
two for the server (sentinel-throw helpers) and two for the client (programmatic
nav + cache invalidation).
notFound()
Aborts rendering and returns a 404 response. If a
not-found.ts exists at the app root, it is rendered.
import { html, notFound } from '@webjsdev/core';
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await db.posts.findBySlug(params.slug);
if (!post) notFound(); // stops execution, returns 404
return html`<h1>${post.title}</h1>`;
}
redirect(url, status?)
Aborts rendering and returns a redirect response. When you do not pass a
status, the catching site picks the conventional one: a redirect thrown
during a GET page or layout render (a gate, an auth
bounce) becomes 302 Found, the usual GET-to-GET code; a
redirect thrown from a server action (a POST) becomes
307 Temporary Redirect, which is method-preserving so the
action's intent survives. Pass an explicit status to override either
default: positionally as redirect('/x', 308), or with the
options form redirect('/x', { status: 301 }).
import { redirect } from '@webjsdev/core';
export default async function ProtectedPage() {
const user = await getSession();
if (!user) redirect('/login'); // GET gate -> 302 Found
// if (!user) redirect('/login', 308); // override -> 308 permanent
// if (!user) redirect('/login', { status: 301 }); // options form
return html`<h1>Welcome, ${user.name}</h1>`;
}
Inside a server action, the same redirect('/somewhere')
defaults to 307 instead, so a POST that bounces keeps its
method. The Post/Redirect/Get success path (returning
{ success: true, redirect }) is separate and always uses
303 See Other.
Both server helpers can be called from pages, layouts, and
generateMetadata functions, anywhere in the server-side rendering
chain.
navigate(url, opts?)
Programmatic client-side navigation. Use instead of
location.href = ... to keep the partial-swap behavior. Pushes a
history entry by default; pass { replace: true } to replace.
import { navigate } from '@webjsdev/core';
await navigate('/about');
await navigate('/login', { replace: true });
revalidate(url?)
Evict a cached snapshot from the client router's back/forward cache. Call after
a JS-initiated server-action mutation so the next visit to that URL refetches
instead of restoring stale data. Omit the argument to clear the whole cache.
Form submissions through the router clear the cache automatically on mutating
methods. You only need revalidate() for mutations that bypass the
form pipeline.
import { revalidate } from '@webjsdev/core';
revalidate('/products/123'); // evict one URL
revalidate(); // clear the whole cache
Loading UI (loading.ts)
The loading.ts file convention provides automatic
Suspense boundaries. When placed in a route folder, it defines a loading
UI that will be shown while the page's async content is being resolved.
// app/dashboard/loading.ts (reserved for future Suspense integration)
import { html } from '@webjsdev/core';
export default function Loading() {
return html`
<div class="skeleton">
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
</div>
`;
}
WebJs automatically wraps the sibling page in a Suspense boundary with the
loading content as the fallback. The page content streams in when ready,
replacing the loading UI. No manual Suspense() call required.
On client-side navigation, the same loading.ts is also cloned into
the swap slot for instant feedback. The SSR pipeline emits each segment's
loading template as a hidden
<template id="wj-loading:<segment-path>">; the router
clones the deepest matching template into the active swap region the moment a
link is clicked. See the
Loading States page for the full mechanism.
Route Resolution Order
When a request arrives, WebJs resolves it in this order:
- Static file: if a file exists in the project's public/static directory, it is served directly.
- API route:
route.tshandlers are matched against the URL. WebSocket upgrades also match here. - Page route:
page.tsfiles are matched by positional specificity (segment by segment, a static segment beats a dynamic one beats a catch-all, so the catch-all kind is lowest at its position rather than blanket-last), with ties broken by a stable alphabetical key rather than file order. - Not found: if nothing matches,
not-found.tsis rendered with a404status.
Quick Reference
File Purpose Example URL ───────────────────────────────────────────────────────────────────── page.ts Page component (SSR) /about layout.ts Wrapping layout (nested) wraps children route.ts API / WebSocket handler /api/users error.ts Error boundary (nearest wins) catches render errors not-found.ts Custom 404 page (app root only) 404 fallback loading.ts Loading UI (reserved for Suspense) shown while loading middleware.ts Request middleware (nested) runs before handlers [param]/ Dynamic segment /users/:id [...rest]/ Catch-all segment /files/* (group)/ Route group (not in URL) scopes layouts _private/ Private folder (excluded) not routable