Skip to content

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, and loading.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>&copy; 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 Response object for full control over status, headers, and body.
  • Return a plain object or array and WebJs will automatically serialize it as JSON with a 200 status.
  • If a request arrives for a method you have not exported, WebJs responds with 405 Method Not Allowed and an Allow header 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="/">&larr; 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 to width=device-width,initial-scale=1)
  • themeColor: sets <meta name="theme-color">
  • openGraph: an object of Open Graph properties (keys become og: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:

  1. Static file: if a file exists in the project's public/static directory, it is served directly.
  2. API route: route.ts handlers are matched against the URL. WebSocket upgrades also match here.
  3. Page route: page.ts files 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.
  4. Not found: if nothing matches, not-found.ts is rendered with a 404 status.

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