Deployment

One Web-Fetch handler powers Node, static export, Vercel, and Cloudflare. Each target is its own entry file that imports the matching adapter; the ISR engine and actions come along for free.

The three request-serving adapters (node, vercel, cloudflare) are thin shells over one framework-agnostic core, createRequestHandler(options), which returns (request) => Response. Match a route, intercept /__what_action and (when you pass a revalidateWebhook) /__what_revalidate, consult the ISR engine, render, and emit cache headers. The fourth adapter, static, is the exception to all of that: exportStatic is a build-time renderer rather than a request handler, and never touches that core. Switching hosts means swapping the adapter import in one entry file, not rewriting the app. The adapter key that what.config.js scaffolds is informational only today: what start prints it and nothing else reads it.

Two practical notes before the snippets. First, createServer, toNodeListener, whatMiddleware, exportStatic, createVercelHandler and buildVercelOutput resolve through what-server's node export condition, so they belong in build scripts and long-running servers; createRequestHandler and createCloudflareHandler live in the default entry and resolve in edge bundles too. Second, every adapter here imports and server-renders your route components, so those components have to be importable outside a browser: write them with h() or the automatic JSX runtime. Components put through what-compiler build their templates with document.createElement at module load time, which throws the moment a server imports them.

Adapter matrix

AdapterBest forISR mechanismPoll scheduler
nodeLong-running server, full controlorigin store (memory/fs/redis)✓ in-process
staticPure SSG to any static hostbuild-time pre-rendernone (re-run the export)
vercelServerless + edge ISRs-maxage / stale-while-revalidate headersnone (Vercel Cron → webhook)
cloudflareWorkers at the edgeorigin store + Cache-Control / Cache-Tag headersnone (Cron Trigger → webhook)

Serverless & polling

Serverless platforms have no always-on process, so the in-process poll scheduler doesn't run there. Use the platform's cron to hit POST /__what_revalidate on a schedule instead. Note it is not quite the same effect: createRevalidateWebhook defaults to regenerate: false, so the endpoint deletes the cached entry rather than re-rendering it, and the next visitor pays a cold blocking render. The poll scheduler leaves a warm entry behind. That endpoint only exists if you passed a revalidateWebhook, and it expects an x-what-revalidate-secret header plus a JSON body of { "paths": [...], "tags": [...] } capped at 100 entries. A wrong or missing secret is a 401.

Node

import { createServer, renderDocument } from 'what-framework/server';
import {
  createCacheEngine,
  createMemoryStore,
  createRevalidateWebhook,
  createScheduler,
} from 'what-isr';
import { routes } from './src/routes.js';

const documentOptions = { clientEntry: '/src/entry-client.js' };

// The scheduler drives the engine directly, with no adapter in the loop, so the
// engine needs its own `render`. Leave it out and every scheduled tick throws.
async function renderRoute({ route, path, params, query, request }) {
  const pageModule = { default: route.component, loader: route.loader };
  return {
    html: await renderDocument(pageModule, { params, query, request }, documentOptions),
    status: 200,
    path,
  };
}

const cache = createCacheEngine({ store: createMemoryStore(), render: renderRoute });

// Keep the home route warm every 5 minutes regardless of traffic.
const scheduler = createScheduler(cache);
scheduler.register(
  { path: '/', query: {}, params: {}, config: routes[0].page, route: routes[0] },
  { intervalMs: 5 * 60 * 1000 },
);

const server = createServer({
  routes,
  cache,
  scheduler,
  revalidateWebhook: createRevalidateWebhook(cache, { secret: process.env.WHAT_REVALIDATE_SECRET }),
  document: documentOptions,
});
server.listen(Number(process.env.PORT) || 3000);

Run it with what start, which spawns ./server.js from the project root and forwards SIGINT/SIGTERM to it, or with node server.js directly. The scheduler option is what installs the SIGTERM/SIGINT handlers, so leave it out and there is nothing to stop cleanly. The document.clientEntry option is what emits the <script type="module"> tag; leave it out and the page server-renders but never hydrates.

createServer answers matched routes plus /__what_action and /__what_revalidate, and 404s everything else: it has no static-file branch, so it will not serve that client entry itself. Put a CDN or reverse proxy in front of it, or build the server the way the scaffold does, wrapping toNodeListener(createRequestHandler(...)) in your own http.createServer that serves an allowlist of client files first.

Static export

Renders every route that declares mode: 'static' or mode: 'hybrid' (expanding getStaticPaths for dynamic ones) to an index.html. A route with no mode is skipped in silence, so declare it:

import { exportStatic } from 'what-framework/server';
import { Home, Post, loadPost } from './src/pages.js';

const routes = [
  { path: '/', component: Home, page: { mode: 'static' } },
  {
    path: '/posts/:slug',
    component: Post,
    loader: loadPost,
    page: { mode: 'static' },
    getStaticPaths: async () => ({ paths: [{ params: { slug: 'hello-world' } }] }),
  },
];

await exportStatic({ routes, outDir: 'dist' });

Routes that export a loader also get a __what_data.json beside their index.html. That file is a build artifact for your own client-side navigation code to fetch; the framework itself hydrates from the inline <script id="__what_data"> in the page, not from the sidecar.

what generate is the CLI's separate SSG path, and it takes a different input: it walks page modules under src/pages/ rather than a routes array, and skips dynamic routes because it has no params to pre-render. There is no --static flag on what build.

Vercel

import { readFileSync } from 'node:fs';
import { buildVercelOutput } from 'what-framework/server';

// build/render.mjs is your bundled function entry. It exports the runtime handler:
//   import { createVercelHandler } from 'what-framework/server';
//   export default createVercelHandler({ routes, cache });
await buildVercelOutput({
  files: { 'index.mjs': readFileSync('build/render.mjs', 'utf8') },
  staticDir: 'dist',   // copied to .vercel/output/static, served by the CDN first
});

buildVercelOutput writes .vercel/output (Build Output API v3): a config.json that tries the filesystem first and falls through to functions/render.func/, which it builds from files. It takes no routes option, so your routes have to be inside the bundle you hand it. Called without files it writes config.json alone, which points every request at a function that was never emitted.

There is no prerender manifest and no expiration field. ISR on Vercel rides the Cache-Control: public, s-maxage=…, stale-while-revalidate=… headers the cache engine already emits, which is why it needs no extra config.

Cloudflare

import { createCloudflareHandler } from 'what-framework/server';
import { routes } from './src/routes.js';
import { cache } from './src/cache.js';

export default createCloudflareHandler({ routes, cache });

createCloudflareHandler returns the worker object itself, { fetch(request, env, ctx) }, so export it directly. Wrapping it in another { fetch: … } hands Workers an object where it expects a function, and the worker will not boot.

ISR here is the same origin engine every other adapter uses. The handler attaches env to request.__env and ctx to request.__ctx, then delegates to the core handler. Give the engine createRedisStore({ client }) from what-isr if you want cache entries shared across isolates, and pass createCloudflareCDN({ zoneId, apiToken }) as the engine's cdn to purge the edge on revalidatePath / revalidateTag. The Cache-Control and Cache-Tag headers the engine emits are what let Cloudflare's own cache serve the edge tier.

Environment

  • WHAT_REVALIDATE_SECRET: the name the scaffold and the examples use for the secret you hand to createRevalidateWebhook. No framework package reads it for you.
  • PORT: same story. createServer returns a bare http.Server, so the port is whatever your .listen() call passes.

See the blog and shop examples for fuller wiring. Both build their engine with a render and register their scheduled route with route and params, so their poll schedulers actually keep a page warm.