Skip to main content
@vertz/cloudflare provides createHandler — a single function that wires up your API, SSR, security headers, and optional ISR caching into a Cloudflare Worker module.

Quick start

// src/worker.ts
import { createHandler } from '@vertz/cloudflare';
import { createDb } from '@vertz/db';
import { createServer, type ServerConfig } from '@vertz/server';
import * as app from '../dist/server/app';
import { todos } from './api/entities/todos/todos.entity';
import { todosModel } from './api/schema';

interface Env {
  DB: D1Database;
}

export default createHandler({
  app: (env) => {
    const typedEnv = env as Env;
    const db = createDb({
      models: { todos: todosModel },
      dialect: 'sqlite',
      d1: typedEnv.DB,
    });
    return createServer({ entities: [todos], db });
  },
  ssr: {
    module: app,
    clientScript: '/assets/entry-client.js',
    title: 'My App',
  },
});
That’s it. The handler automatically:
  • Routes /api/* requests to your server (entities, auth, services)
  • Renders all other routes via SSR
  • Adds security headers with per-request nonce-based CSP
  • Detects requestHandler on ServerInstance for auth-aware routing

Configuration

CloudflareHandlerConfig

createHandler({
  // Required — factory that creates your Vertz server.
  // Receives Worker env bindings. Called once, then cached.
  app: (env) => createServer({ ... }),

  // Required — SSR configuration for non-API routes.
  // Pass an SSR module config (zero-boilerplate) or a custom callback.
  ssr: { module: app },

  // Optional — API path prefix. Default: '/api'
  // Matches createServer's apiPrefix default, so you rarely need this.
  basePath: '/api',

  // Optional — add security headers to all responses. Default: true
  // Includes CSP with per-request nonce, HSTS, X-Frame-Options, etc.
  securityHeaders: true,

  // Optional — ISR cache configuration (see below)
  cache: { ... },

  // Optional — image optimizer from @vertz/cloudflare/image
  imageOptimizer: imageOptimizer({ allowedDomains: ['cdn.example.com'] }),

  // Optional — middleware hook before SSR rendering.
  // Return a Response to short-circuit (e.g., redirect to /login).
  beforeRender: async (request, env) => {
    if (needsAuth(request)) {
      return Response.redirect('/login');
    }
  },
});

SSR module config

The zero-boilerplate form passes your app module directly:
ssr: {
  // Your app module — must export App, and optionally theme, styles, getInjectedCSS
  module: app,

  // Client-side entry script path. Default: '/assets/entry-client.js'
  clientScript: '/assets/entry-client.js',

  // HTML document title. Default: 'Vertz App'
  title: 'My App',

  // SSR query timeout in ms. Default: 5000 (generous for D1 cold starts)
  ssrTimeout: 5000,
}
For full control, pass a custom callback instead:
ssr: async (request) => {
  const html = await renderMyPage(request);
  return new Response(html, {
    headers: { 'Content-Type': 'text/html' },
  });
};

Route splitting

The handler splits requests by URL path:
RouteHandler
/api/*Server handler (entities, auth, services)
/_vertz/imageImage optimizer (if configured)
Everything elseSSR
Static assets (JS, CSS, images) are served by Cloudflare’s [assets] directive in wrangler.toml before the Worker runs — they never reach createHandler.

Custom basePath

If your server uses a non-default apiPrefix, match it in the handler:
createServer({ apiPrefix: '/v1' });

createHandler({
  app: (env) => createServer({ apiPrefix: '/v1' }),
  basePath: '/v1',
  ssr: { module: app },
});

Auth-aware routing

When your server uses auth (OAuth, sessions), createServer returns a ServerInstance with a requestHandler method that routes both auth and entity requests. The handler detects this automatically:
// requestHandler is auto-detected — auth routes just work
createHandler({
  app: (env) =>
    createServer({
      entities: [todos],
      db,
      auth: { providers: [github()] },
    }),
  ssr: { module: app },
});
No manual wiring needed. /api/auth/signin, /api/auth/callback, etc. are all handled. If your app is a plain AppBuilder without auth, the handler falls back to app.handler automatically.

Security headers

Security headers are enabled by default. Every response includes:
HeaderValue
Content-Security-Policydefault-src 'self'; script-src 'self' 'nonce-<random>'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;
Strict-Transport-Securitymax-age=31536000; includeSubDomains
X-Content-Type-Optionsnosniff
X-Frame-OptionsDENY
X-XSS-Protection1; mode=block
Referrer-Policystrict-origin-when-cross-origin
Each request gets a unique cryptographic nonce for CSP. Script tags in SSR output use this nonce. To disable security headers (not recommended):
createHandler({
  app: (env) => createServer({ ... }),
  ssr: { module: app },
  securityHeaders: false,
});

ISR caching

ISR (Incremental Static Regeneration) caches SSR responses in Cloudflare KV. This gives you near-static performance with dynamic content.

How it works

  1. First request — SSR renders the page, stores the HTML in KV, returns the response (X-Vertz-Cache: MISS)
  2. Subsequent requests (within TTL) — serves from KV instantly (X-Vertz-Cache: HIT)
  3. After TTL expires — serves the stale page immediately, re-renders in the background via ctx.waitUntil() (X-Vertz-Cache: STALE)

Setup

Add a KV namespace in wrangler.toml:
[[kv_namespaces]]
binding = "PAGE_CACHE"
id = "your-kv-namespace-id"
Configure the cache:
interface Env {
  DB: D1Database;
  PAGE_CACHE: KVNamespace;
}

export default createHandler({
  app: (env) => createServer({ ... }),
  ssr: { module: app },
  cache: {
    // Factory that returns the KV namespace from Worker env bindings
    kv: (env) => (env as Env).PAGE_CACHE,

    // Cache TTL in seconds. Default: 3600 (1 hour)
    ttl: 3600,

    // Serve stale while revalidating in background. Default: true
    // Set to false to force synchronous re-render on stale entries.
    staleWhileRevalidate: true,
  },
});

Cache behavior

  • Only SSR routes are cached — API requests (/api/*) are never cached
  • Nonces are stripped before caching and re-injected on each request (each response gets a fresh CSP nonce)
  • KV entries expire at 2x the TTL to allow stale-while-revalidate to work
  • KV lookup failures are non-fatal — the handler falls through to SSR

beforeRender middleware

Use beforeRender to run logic before SSR on non-API routes. Return a Response to short-circuit, or undefined to proceed with SSR:
createHandler({
  app: (env) => createServer({ ... }),
  ssr: { module: app },
  beforeRender: async (request, env) => {
    const session = await getSession(request);
    if (!session && isProtectedRoute(request.url)) {
      return Response.redirect('/login', 302);
    }
    // Return undefined to proceed with normal SSR
  },
});
beforeRender receives the Request and the Worker env bindings. It runs after API routing but before SSR and ISR cache checks.

Wrangler configuration

# wrangler.toml
name = "my-app"
main = "src/worker.ts"
compatibility_date = "2025-01-01"

# Static assets served before the Worker runs
[assets]
directory = "./dist/client"

# D1 database binding
[[d1_databases]]
binding = "DB"
database_name = "my-app-db"
database_id = "your-database-id"

# KV for ISR caching (optional)
[[kv_namespaces]]
binding = "PAGE_CACHE"
id = "your-kv-namespace-id"
The [assets] directive serves static files (JS bundles, CSS, images) directly from Cloudflare’s edge — they never hit the Worker. Only dynamic requests (API calls, page navigations) reach createHandler.

Deploy

# Build the app
vertz build

# Deploy to Cloudflare
bunx wrangler deploy

How it differs from static deployment

Static siteFull-stack (Workers)
ServerNone — static files onlyCloudflare Worker with SSR
DataBuild-time onlyPer-request (queries, auth)
PackageCustom build script@vertz/cloudflare
CachingEdge CDN (immutable assets)ISR via KV (optional)
Use caseLanding pages, marketingApps with dynamic data

Static Sites

Deploy static sites and landing pages

SSR

How Vertz SSR works under the hood