Server Rendering
Render pages on the server for instant loads and SEO.
Server modules cannot go through what-compiler
what-compiler lowers JSX to module-scope templates built with document.createElement('template'), so a compiled module touches document the moment it is imported. A server has no document, and the import throws ReferenceError: document is not defined before any render can start. Write server-rendered views with h(), which is what the fullstack scaffold does (it has no compiler and no JSX), or compile them with the automatic JSX runtime (jsxImportSource: "what-framework") rather than with what-compiler. Every server-side example on this page uses h().
renderToString
The simplest way to render on the server. Pass a virtual node tree and get back a complete HTML string synchronously. Components are called as functions, event handlers are stripped, and all attributes are properly escaped:
import { h } from 'what-framework';
import { renderToString } from 'what-framework/server';
function Greeting({ name }) {
return h('div', { class: 'greeting' },
h('h1', {}, `Hello, ${name}!`),
h('p', {}, 'Welcome to the site.'),
);
}
const html = renderToString(
h(Greeting, { name: 'Alice' })
);
console.log(html);
// <div class="greeting"><h1>Hello, Alice!</h1><p>Welcome to the site.</p></div>
Use this in your server handler to return fully rendered HTML. It handles text nodes, arrays, nested components, void elements (like <img> and <br>), and properly escapes all attribute values and text content to prevent XSS.
To make that HTML interactive, the client calls hydrate(vnode, container) from what-framework with a tree that matches what the server rendered.
renderToStream
For large pages, streaming sends HTML to the browser as it is generated instead of waiting for the entire page to finish. renderToStream returns an async generator that yields HTML chunks:
import { h } from 'what-framework';
import { renderToStream } from 'what-framework/server';
// In your server handler (Node.js example)
app.get('/', async (req, res) => {
res.setHeader('Content-Type', 'text/html');
const stream = renderToStream(
h(App, { url: req.url })
);
for await (const chunk of stream) {
res.write(chunk);
}
res.end();
});
Streaming is especially powerful with async components. If a component returns a Promise, renderToStream awaits it and continues streaming once the data is ready. The browser can start painting the page header while the database query for the content is still running.
One difference from renderToString: a streamed render keeps each component frame open across its await points, so useEffect bodies do run on the server here. renderToString suppresses them, and onMount callbacks never run on either path. Keep browser-only work in onMount so switching a page from one entry point to the other cannot change what executes server-side.
definePage
Use definePage to declare how a page should be rendered. The call itself just returns your config with mode defaulting to 'static'; the static exporter and the deploy adapter are what read it. Four modes are recognized:
static(default) -- Pre-render at build time. The static exporter writes the route to disk, andgenerateStaticPagedrops your ownscriptsarray. Best for content that rarely changes.server-- Render on every request. The deploy adapter bypasses the ISR cache for this mode and answers withCache-Control: private, no-store. Use it for personalized or dynamic pages that depend on cookies, headers, or real-time data.client-- Not pre-rendered: the static exporter skips the route. It does not mean the server sends an empty shell, because both the request-time adapter andgenerateStaticPagestill render the component to real HTML.generateStaticPageadditionally appends<script type="module" src="/@what/client.js">, a URL no What runtime serves, so point the page at your own client entry instead.hybrid-- Pre-rendered at build time likestatic, with interactive islands layered on top. The best of both worlds for content-heavy pages with a few interactive widgets.
import { definePage } from 'what-framework/server';
// src/pages/blog/[slug].js: static blog post, built once at deploy time
export default definePage({
mode: 'static',
component: BlogPost,
title: 'My First Post',
meta: { description: 'An introduction to What Framework.' },
});
// src/pages/dashboard.js: server-rendered, fresh data on each request
export default definePage({
mode: 'server',
component: Dashboard,
title: 'Dashboard',
});
// src/pages/products/[id].js: hybrid, static page plus a cart island
export default definePage({
mode: 'hybrid',
component: ProductPage,
title: 'Widget Pro',
islands: ['AddToCart', 'ReviewSection'],
});
Server Components
server() tags a component as server-only. Be precise about what that buys you today: the whole implementation is Component._server = true, and nothing else in What reads that flag. There is no bundler integration, no compiler handling and no client/server split, so the component still ends up in your client bundle and its body still runs if the client renders it:
import { h } from 'what-framework';
import { server } from 'what-framework/server';
// Marks intent for your own tooling. It does not remove the
// component from the client bundle, so keep secrets out of it.
const UserProfile = server(function({ user }) {
return h('div', { class: 'profile' },
h('img', { src: user.avatar, alt: user.name }),
h('h2', {}, user.name),
h('p', {}, user.bio),
);
});
Do not put a database query, an env var or a secret inside a component and rely on server() to keep it off the client. The surfaces that genuinely only ever run on the server are a page module's export const loader, your own API routes, and the body of an action() reached through /__what_action. Fetch there, then pass plain data down as props to components like the one above.
Server Actions
Server actions let you define server-side functions that can be called directly from client code. Define an action on the server, and the client calls it via an automatic RPC mechanism over fetch:
import { action } from 'what-framework/server';
// Define a server action
const saveUser = action(async (data) => {
const user = await db.users.create(data);
return { success: true, id: user.id };
}, {
id: 'saveUser',
onSuccess: (result) => console.log('Saved:', result.id),
revalidate: ['/users'],
});
Give every action an explicit id unless you build with what-compiler, which supplies a deterministic one. Without either, action() generates a random id per process and warns; the client and server bundles then disagree about the name and every dispatch answers 404.
revalidate does two different things depending on which side runs. In the served endpoint it purges those paths from the ISR cache. In the client wrapper it fires the in-memory invalidatePath subscribers instead, and revalidateTags is ignored there.
useAction
The useAction hook wraps a server action with reactive state for pending status, errors, and response data:
import { useAction } from 'what-framework/server';
function SaveButton() {
const { trigger, isPending, error, data } = useAction(saveUser);
return (
<div>
<button
onClick={() => trigger({ name: 'Alice', email: 'alice@example.com' })}
disabled={() => isPending()}
>
{() => (isPending() ? 'Saving...' : 'Save User')}
</button>
{() => (error() ? <p class="error">{error().message}</p> : null)}
{() => (data() ? <p class="success">Saved with ID: {data().id}</p> : null)}
</div>
);
}
The thunks are load-bearing here, so keep them. isPending, error and data arrive by destructuring a call, which the compiler cannot recognize as signals, so every bare read of them is evaluated once at creation and never again. A bare {isPending()} leaves the button on its first label for the life of the page, and a bare {error() && <p>{error().message}</p>} is worse in a quieter way: the guard is tested once while error() is still null, so the paragraph is never built and the error never appears at all, with nothing on the console to say so. Wrapping each dynamic part in () => gives the compiler the reactive region it needs, and works identically on the compiled and the h() paths.
The guarded arm itself is safe either way as of 0.13.4: the compiler now builds it inside the && rather than ahead of it, so error().message is not evaluated while error() is null and the component no longer throws at mount. That is why the failure is now a silent one. Older builds threw a TypeError here instead.
useMutation
For simpler cases where you just need pending/error/data tracking around any async function, use useMutation:
import { useMutation } from 'what-framework/server';
const { mutate, isPending, error, data, reset } = useMutation(
async (id) => {
const res = await fetch(`/api/items/${id}`, { method: 'DELETE' });
return res.json();
},
{
onSuccess: () => console.log('Deleted'),
onError: (err) => console.error(err),
onSettled: () => console.log('Done'),
}
);
// Call the mutation
mutate(42);
useOptimistic
Show the user an instant result while the server processes the real mutation. If the server call fails, the optimistic update is automatically rolled back:
import { useOptimistic } from 'what-framework/server';
function LikeButton({ postId, initialCount }) {
const likes = useOptimistic(
initialCount,
// Reducer: how to apply an optimistic action
(current, action) => action === 'like' ? current + 1 : current - 1
);
async function handleLike() {
// Shows +1 immediately, rolls back on error
await likes.withOptimistic('like', async () => {
const res = await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
const data = await res.json();
return data.count; // Server's authoritative count
});
}
return (
<button onClick={handleLike}>
Like ({() => likes.value()})
</button>
);
}
The withOptimistic helper applies the optimistic action immediately, runs the async function, and either resolves with the server value or rolls back to the previous state on error.
Note the thunk around likes.value(). likes is an object returned by a call, not a signal binding the compiler tracks, so a bare {likes.value()} is inserted once and the count never moves. {() => likes.value()} is what makes the label follow the optimistic state.
CSRF Protection
Server actions include built-in CSRF protection. What provides utilities for token management and a fail-closed default that prevents silent security vulnerabilities:
import {
generateCsrfToken,
csrfMetaTag,
nodeActionMiddleware,
} from 'what-framework/server';
// Server: generate a token per session
app.use((req, res, next) => {
if (!req.session.csrfToken) {
req.session.csrfToken = generateCsrfToken();
}
next();
});
// Server: inject the token into your HTML
const html = `
<head>
${csrfMetaTag(req.session.csrfToken)}
</head>
`;
// Renders: <meta name="what-csrf-token" content="abc-123...">
// Server: mount the /__what_action endpoint
app.use(nodeActionMiddleware({
getCsrfToken: (req) => req.session.csrfToken,
}));
Mount the endpoint with nodeActionMiddleware rather than writing the route by hand. The client wrapper sends the action name in an X-What-Action header and posts only { args } as the body, while a plain no-JS form sends it as a hidden _action field, so a handwritten route that reads req.body.actionId answers 404 to every request. The middleware handles both shapes, parses the body by content type, caps it at 1 MB and then dispatches through handleActionRequest, which is the low-level dispatcher rather than a route handler. On edge runtimes (Deno, Bun, Cloudflare Workers) use fetchActionHandler({ getCsrfToken }) instead, which takes a Request and returns a Response.
handleActionRequest uses fail-closed semantics: if no CSRF token reaches it, it returns a 500 error explaining the misconfiguration rather than silently accepting the request. Token comparison uses constant-time string matching to prevent timing attacks. The client reads the token from the <meta> tag automatically (falling back to the what-csrf cookie) and sends it with every action request.
Server Runtime Required
Server actions need a server runtime to handle the /__what_action endpoint. They work with Node.js, Deno, Bun, and edge runtimes such as Cloudflare Workers. For purely static sites without a server, use client-side fetch calls to your API instead of server actions.
Static Site Generation
Use generateStaticPage to pre-render pages at build time. It renders your page component through renderToString inside a server render scope, then wraps the output in a full HTML document with the configured title, meta tags, styles, and island scripts. Because the component gets a real component frame, a statically generated page can use useState, useSignal, useMemo, useRef and a root Context.Provider; as on every renderToString path, useEffect bodies are suppressed and onMount never runs. The data object is passed to the component as its props, minus a key field if you have one, which the vnode layer consumes:
import { generateStaticPage, definePage } from 'what-framework/server';
const page = definePage({
mode: 'hybrid',
component: BlogPost,
title: 'My Post',
meta: { description: 'A great article.' },
styles: ['/styles/blog.css'],
islands: ['CommentSection'],
});
const html = generateStaticPage(page, { slug: 'my-post' });
// Full HTML document with <!DOCTYPE html>, <head>, island hydration script
The island hydration script, a <script type="module"> that imports and calls hydrateIslands(), is emitted whenever the page declares a non-empty islands array, in any mode. What mode: 'static' changes is your own scripts array, which is dropped. The names inside islands are only used as a has-islands switch; they are never written into the document, so each island still needs its own Island marker in the component and its own island() registration on the client.
generateStaticPage drops <Head> tags
generateStaticPage opens a head sink for the render but never serializes it, so a <Head> declared inside the page contributes nothing to the document. The <head> it emits is built only from the title and meta you hand definePage. For per-page head tags, render with renderToStringWithHead and place the returned head string in your own document, or use renderDocument, which serializes the head into the document it returns and emits the hydration payload as well. Both were checked: each one carries the page's title, meta and link tags through. renderPage and renderToStringAsync return the same head string if you are already using one of them.
One thing to get right whichever you pick: <Head> collects from its title, meta and link props, and those are the only tags that reach the head. Anything written as a child of <Head> is passed straight through and renders where the component sits, which is in the body. So h(Head, { meta: [{ name: 'description', content: '…' }] }) lands in the head, while <Head><meta name="description" …/></Head> puts a stray <meta> in your <body> on every path, including both of the ones above.