Server-Side Rendering
Every WebJs page is server-rendered by default. There is no client-only mode and no opt-in flag. When a request arrives, the server executes your page function, renders the result to an HTML string, injects Declarative Shadow DOM for every web component that appeared, and streams the response. The browser paints meaningful content before a single byte of JavaScript has been parsed.
How SSR Works
Pages are plain async functions that return html`` tagged templates (a TemplateResult). The server imports the page module, calls its default export, and passes the result through renderToString(). That function walks the template tree, resolves every Promise it encounters in template holes, and produces a complete HTML string.
// app/page.ts
import { html } from '@webjsdev/core';
export const metadata = { title: 'Home' };
export default async function Home() {
const posts = await db.post.findMany({ take: 10 });
return html`
<h1>Latest Posts</h1>
<ul>
${posts.map(p => html`<li>${p.title}</li>`)}
</ul>
`;
}
The runtime (Node 24+ or Bun) strips TypeScript types natively when it imports a .ts file, so the file above runs directly on the server with no manual compilation step.
The SSR Pipeline
When the server receives a GET request for a page URL, the pipeline runs in this order:
- Route match: the router scans the
app/directory tree (at startup) and matches the URL pathname against the file-based route table. Dynamic segments ([slug]), catch-all segments ([...rest]), and route groups ((marketing)) all use the file-based conventions documented under Routing. - 103 Early Hints: before SSR begins, the server calls
res.writeEarlyHints()with<link rel="modulepreload">headers for every module URL that the page will need (the page file itself, its layout chain, and any web component modules discovered on a previous render). This lets the browser start fetching scripts while the server is still computing HTML. - Segment middleware: if any
middleware.tsfiles exist on the path from root to the matched route, they execute outermost-first as a chain:(req, next) => Response. - Load page module: the server dynamically imports the matched
page.tsfile. In dev mode, a cache-busting query string is appended to the import URL so edits take effect immediately without restarting. - Load layout chain: layout files are loaded outermost-first (
app/layout.ts, thenapp/blog/layout.ts, etc.). Each layout wraps the previous result via itschildrenprop. - Collect metadata: each layout and the page can export a
metadataobject or agenerateMetadata(ctx)async function. Metadata is merged page-wins so the innermost file's title takes precedence. - renderToString: the fully-nested template tree is rendered to an HTML string. Promises in template holes are awaited. Arrays and
repeat()iterables are expanded. - injectDSD: the rendered HTML is scanned for registered custom element tags. For each one found, the server instantiates the component class, applies attributes, runs the pre-render lifecycle (
willUpdate, controllers'hostUpdate, then reflectsreflect: trueproperties so they appear on the tag), calls itsrender()method (which may be async), and wraps the output in a<template shadowrootmode="open">element immediately after the opening tag. Scopedcss``styles are included inside the template. - Stream response: the fully rendered document (or its initial chunk plus Suspense boundaries) is sent as a streaming
text/htmlresponse.
Declarative Shadow DOM (DSD)
Declarative Shadow DOM is the key technology that makes web component SSR work without a hydration runtime. When the server renders a custom element like <my-counter count="5">, the output looks like this:
<my-counter count="5">
<template shadowrootmode="open">
<style>:host { display: inline-flex; gap: 8px; } ...</style>
<button>-</button>
<span>5</span>
<button>+</button>
</template>
</my-counter>
The browser's HTML parser recognises <template shadowrootmode="open"> and immediately attaches a shadow root with that content. This happens during parsing, before any JavaScript runs. The result is:
- First paint before JS loads: the component's styled markup is visible as soon as the HTML arrives. There is no flash of unstyled content and no layout shift.
- No hydration mismatch: the shadow root already exists when the custom element's constructor runs. webjs's
connectedCallbackchecks for an existingshadowRootand re-renders into it rather than creating a new one. - Scoped styles for free: the
<style>inside the shadow root is scoped by the browser's native shadow DOM encapsulation. No CSS-in-JS runtime is needed.
Components Without Shadow DOM
If a component sets static shadow = false, DSD injection is skipped. The component renders into the light DOM and its styles are not scoped. This is useful for components that need to participate in the parent document's layout or inherit global styles.
The Server Element Shim
The injectDSD pass instantiates each component server-side, but there is no real DOM, so a naive this.getAttribute(...) or this.addEventListener(...) in the constructor or render() would throw. WebJs backs the SSR-time instance with a server element shim, so the attribute and event surface a component reads during the pre-render lifecycle is safe and does not crash.
- Attribute methods work:
getAttribute,hasAttribute,setAttribute, andtoggleAttributeread and write the SSR instance's attribute map, so reading an attribute inrender()or reflecting a property during the SSR update cycle behaves as it does in the browser. - Event methods are no-ops:
addEventListener,removeEventListener, anddispatchEventare inert at SSR (there is no event loop on the server), so wiring a delegated listener in the constructor is safe. The real listeners bind on the client after the script loads. - attachInternals() is inert: it returns an inert object server-side, so a form-associated component does not crash during its first paint.
Reading attributes that drive render through a reactive property (declared via the WebComponent({ ... }) factory) is still the idiomatic path, but a direct this.hasAttribute(...) no longer crashes at SSR. Genuinely browser-only members (this.classList, this.querySelector(...), this.attachShadow(...), this.getBoundingClientRect(...), layout reads) have no server shim and still throw, so keep them in connectedCallback or a later hook. See Lifecycle for which hooks run where.
closest() at SSR for compound components
A compound component (a tabs trigger, a toggle-group item) derives its active or pressed state by walking up to its parent and reading the parent's value. WebJs supports this.closest(...) at SSR for tag-name selectors only, backed by the SSR walker's ancestor chain, so the active or pressed state is marked in the first server paint rather than only after hydration.
get _tabs() { return this.closest('ui-tabs'); }
render() {
const active = this._tabs?.value === this.value;
this.dataset.state = active ? 'active' : 'inactive';
return html`<button data-state=${active ? 'active' : 'inactive'}><slot></slot></button>`;
}
The walker threads the chain of enclosing custom-element instances into each instance, and the shim's closest() resolves a parent over that chain, so this.closest('ui-tabs').value reads the live parent property the walker already applied. The first client render produces the identical state (the browser's real closest() against the real DOM), so there is no hydration flash. Two limits apply.
- Only tag-name selectors resolve at SSR (
closest('ui-tabs')). A class, attribute, or descendant selector returnsnullserver-side and resolves on the client. That covers the compound-component pattern, anything finer is client-only. - The compound parent must be light DOM (the default). A shadow-DOM parent projects its children through a native
<slot>, and those slotted children are not threaded the SSR ancestor chain, so theirclosest(parent)resolves tonullin the first server paint (it still resolves on the client after hydration). Keep compound parents light DOM for a correct first paint.
See Components for the full compound-component pattern.
Async Rendering
Pages, layouts, and components can all be async. The server awaits every level of the render tree:
// app/posts/[slug]/page.ts
import { html } from '@webjsdev/core';
import '#components/post-card.ts';
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await db.post.findUnique({ where: { slug: params.slug } });
if (!post) throw notFound();
return html`
<post-card
title="${post.title}"
body="${post.body}"
date="${post.createdAt.toISOString()}">
</post-card>
`;
}
The component's render() method can also be async. During SSR, if render() returns a Promise, injectDSD awaits it before serialising the contents, so a component can fetch its own server data into the first paint (const u = await getUser(this.uid)). SSR blocks by default, so the resolved DATA is in the first paint with no fallback (a JS-off client reads it). On the client, a re-fetch keeps the prior content (stale-while-revalidate) until the new render resolves, and a thrown await is isolated to that component. See Components and Lifecycle.
Streaming SSR with Suspense
Not every data fetch should block the initial HTML flush. The Suspense function creates a boundary that streams deferred content after the initial paint:
import { html, Suspense } from '@webjsdev/core';
export default function CataloguePage() {
return html`
<h1>Catalogue</h1>
${Suspense({
fallback: html`<p>Loading items...</p>`,
children: loadExpensiveItems(),
})}
`;
}
async function loadExpensiveItems() {
const items = await db.item.findMany();
return html`<ul>${items.map(i => html`<li>${i.name}</li>`)}</ul>`;
}
Here is how streaming works under the hood:
- The server renders the page. When it encounters a Suspense boundary, it emits the fallback HTML wrapped in a
<webjs-boundary id="s1">element and records the children Promise. - The initial HTML chunk (everything up to and including all fallbacks) is flushed to the browser immediately. The response stream stays open.
- A tiny inline script is included in the
<head>that defineswindow.__webjsResolve(id), a function that swaps a boundary's fallback for real content. - As each Suspense promise resolves, the server renders the resolved content and streams it as a
<template data-webjs-resolve="s1">...</template>followed by an inline script calling__webjsResolve("s1"). - The browser receives each chunk, the script runs, and the fallback is replaced with the real content. No framework JS is needed: it is a plain DOM
replaceWith().
Nested Suspense is supported: resolved content can itself contain Suspense boundaries, which are emitted and resolved in subsequent streaming chunks.
Client Upgrade
After the browser has painted the SSR'd HTML, it loads the page's ES modules (as <script type="module"> imports). When a custom element's module loads:
- The class is registered via
Class.register('tag'). - The browser upgrades every instance of that tag in the document, calling
connectedCallback(). - In
connectedCallback, the framework first applies anydata-webjs-prop-*attributes emitted by SSR for.prop=${value}bindings (decoding via the wire serializer, assigning as JS properties, stripping the attributes from the live DOM). - If
this.shadowRootalready exists (from DSD), WebJs skipsattachShadow()and re-renders into the existing shadow root. The DSD content serves as the initial paint, and the client render just adds event listeners and reactive bindings. - The fine-grained client renderer preserves focus, cursor position, scroll offset, and form state across subsequent state updates.
Template-hole SSR coverage
Each html`` hole has well-defined SSR semantics:
| Hole | Server (SSR) | Client |
|---|---|---|
<div>${x}</div> (text) | Rendered with HTML escaping | Same |
class=${x} (attribute) | Serialized as class="x" | Same |
?disabled=${b} (boolean) | Emits disabled="" iff truthy | Same |
.prop=${v} on a custom element | Round-trips via data-webjs-prop-* attribute carrying the wire-encoded value; consumed by the SSR walker before render() | Applied + stripped on connectedCallback |
.prop=${v} on a native element | Dropped (no SSR walker for native tags). Use the attribute form for SSR-visible values. | Applied directly as el[prop] = v when the template runs in the browser |
@click=${fn} (event listener) | Dropped (no event loop on the server) | Bound via addEventListener |
The custom-element .prop path supports rich types out of the box: Array, Object, Date, Map, Set, BigInt, and reference cycles. Functions, class instances with private state, and DOM nodes are unserializable; they drop with a dev warning. See Components for the full property-binding semantics.
Metadata in <head>
The SSR pipeline collects metadata from the layout chain and the page, then injects it into the document <head>. You declare metadata via a named export:
// Static metadata
export const metadata = {
title: 'Blog Post Title | My App',
description: 'A summary for search engines and social cards.',
viewport: 'width=device-width, initial-scale=1',
themeColor: '#1a1a1a',
openGraph: {
title: 'Blog Post Title',
description: 'A summary for social cards.',
image: 'https://example.com/og.png',
url: 'https://example.com/posts/hello',
},
preload: [
{ href: '/fonts/inter.woff2', as: 'font', type: 'font/woff2', crossorigin: '' },
],
};
Or generate it dynamically based on route params:
export async function generateMetadata({ params }: { params: { slug: string } }) {
const post = await db.post.findUnique({ where: { slug: params.slug } });
return {
title: post ? post.title + ' | My App' : 'Not Found',
description: post?.summary,
openGraph: post ? { title: post.title, image: post.coverImage } : undefined,
};
}
Metadata is merged outermost-layout-first, page-last. The page's values win when keys conflict. The resulting <head> includes:
<title>: frommetadata.title<meta name="description">: frommetadata.description<meta name="viewport">: defaults towidth=device-width,initial-scale=1if not set<meta name="theme-color">: frommetadata.themeColor<meta property="og:*">: one tag per key inmetadata.openGraph<link rel="preload">: frommetadata.preloadarray (fonts, images, etc.)<script type="application/ld+json">: frommetadata.jsonLd(schema.org structured data)
JSON-LD structured data
Set metadata.jsonLd to a schema.org object (or an array of objects, one script per element) to emit <script type="application/ld+json"> for Google rich results (Article, Product, BreadcrumbList, FAQ, etc.). WebJs serializes and HTML-safe-escapes it for you, so a value containing </script> can never break out of the tag. You own the schema; the framework adds no schema library. It works in generateMetadata too, for per-request data.
export const metadata = {
jsonLd: {
'@context': 'https://schema.org',
'@type': 'Article',
headline: 'Blog Post Title',
author: { '@type': 'Person', name: 'Ada' },
datePublished: '2026-06-01',
},
};
Module Preload Hints
The SSR pipeline automatically emits <link rel="modulepreload"> tags for:
- The page's own module file
- Every layout in the chain
- Every custom element tag that actually appeared in the rendered HTML
This breaks the ES module waterfall. Without modulepreload, the browser would discover each component's import only after parsing its parent module. With the hints in the <head>, the browser begins fetching all modules in parallel immediately.
Component preload discovery works because Class.register('tag') records the module URL alongside the tag name. During injectDSD, every custom element tag that matched is added to a usedComponents set. After rendering, the set is converted to <link> tags.
The per-file modulepreload model is what makes webjs's no-build, ESM-native architecture competitive with bundling: the SSR pass knows every module the page needs, the browser fetches them in parallel over an HTTP/2 connection, granular cache invalidation means edits only invalidate the changed file. Same model as Rails 7+ with importmap-rails.
103 Early Hints
Before SSR even starts, the server sends HTTP 103 Early Hints to the browser with the same modulepreload URLs that will appear in the <head>. This uses the Node.js res.writeEarlyHints() API. The browser can begin DNS resolution, TLS negotiation, and module fetching while the server is still executing page code and rendering templates.
Early Hints are sent only in production mode (dev-mode file churn would send stale URLs after rebuilds) and only for GET/HEAD requests to page routes.
// Conceptual flow: // 1. Browser sends GET /blog/hello // 2. Server matches route, resolves module URLs // 3. Server sends 103 Early Hints: // Link: </app/blog/[slug]/page.ts>; rel=modulepreload // Link: </app/layout.ts>; rel=modulepreload // Link: </components/post-card.ts>; rel=modulepreload // 4. Server runs SSR (may take 50-200ms for DB queries) // 5. Server sends 200 OK with full HTML // 6. Browser already has modules cached from step 3
Error Handling
If a page or layout throws during rendering, the SSR pipeline catches the error and walks up the layout chain looking for the nearest error.ts file. Error boundaries are nested: app/blog/error.ts catches errors in blog pages, while app/error.ts is the outermost fallback.
Special throw helpers are also caught:
throw notFound(): renders thenot-found.tspage with a 404 status.throw redirect('/login'): sends a redirect. Thrown during a GET render it defaults to302Found; thrown from a server action (a POST) it defaults to the method-preserving307. Pass a status (redirect(url, 308)orredirect(url, { status })) to override.
In development, unhandled errors show the full stack trace in the browser. In production, only a generic "Something went wrong" message is shown, with no stack traces leaked to the client.
No CSRF cookie: SSR responses are cacheable
SSR responses set no per-request CSRF cookie. Server-action CSRF is enforced by an Origin / Sec-Fetch-Site check on the request itself (see Security), so nothing has to ride the page. Because the HTML carries no Set-Cookie, a page that opts into a public Cache-Control (via metadata.cacheControl, e.g. on a root layout for a whole visitor-identical app) can be cached at a CDN edge. A per-user page simply leaves the default no-store in place.
Full SSR Example
// app/layout.ts
import { html } from '@webjsdev/core';
export const metadata = { title: 'My App' };
export default function RootLayout({ children }: { children: unknown }) {
return html`
<nav><a href="/">Home</a> <a href="/about">About</a></nav>
<main>${children}</main>
`;
}
// app/page.ts
import { html, Suspense } from '@webjsdev/core';
import '#components/hero-banner.ts';
export const metadata = {
title: 'Home | My App',
description: 'Welcome to my webjs application.',
openGraph: { title: 'My App', image: '/public/og.png' },
};
export default function Home() {
return html`
<hero-banner headline="Welcome"></hero-banner>
<section>
${Suspense({
fallback: html`<p>Loading recent posts...</p>`,
children: recentPosts(),
})}
</section>
`;
}
async function recentPosts() {
const posts = await db.post.findMany({ take: 5, orderBy: { createdAt: 'desc' } });
return html`
<ul>
${posts.map(p => html`<li><a href="/posts/${p.slug}">${p.title}</a></li>`)}
</ul>
`;
}
When a browser requests /, it receives the full HTML with the hero banner painted via DSD, the "Loading recent posts..." fallback visible immediately, and (milliseconds later) the real post list streamed in, all before the page's JavaScript has finished loading.