@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:
Route Handler /api/*Server handler (entities, auth, services) /_vertz/imageImage optimizer (if configured) Everything else SSR
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 are enabled by default. Every response includes:
Header Value Content-Security-Policydefault-src 'self'; script-src 'self' 'nonce-<random>'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;Strict-Transport-Securitymax-age=31536000; includeSubDomainsX-Content-Type-OptionsnosniffX-Frame-OptionsDENYX-XSS-Protection1; mode=blockReferrer-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
First request — SSR renders the page, stores the HTML in KV, returns the response (X-Vertz-Cache: MISS)
Subsequent requests (within TTL) — serves from KV instantly (X-Vertz-Cache: HIT)
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 site Full-stack (Workers) Server None — static files only Cloudflare Worker with SSR Data Build-time only Per-request (queries, auth) Package Custom build script @vertz/cloudflareCaching Edge CDN (immutable assets) ISR via KV (optional) Use case Landing pages, marketing Apps with dynamic data
Static Sites Deploy static sites and landing pages
SSR How Vertz SSR works under the hood