Chapter 4: Full-Stack Applications
How do I build and deploy a full-stack application with server-side rendering and static assets?
Traditional web architecture splits concerns across services: a CDN for static assets, application servers for rendering, separate API endpoints, perhaps a caching layer between them. Each boundary adds latency and operational complexity. A request might traverse CDN edge, origin load balancer, application server, and database, with each hop adding milliseconds and each service adding deployment surface area.
Workers collapse these layers. Static assets, server rendering, and API logic run in the same edge location. Edge rendering eliminates the network hop between user and application server. Edge data services eliminate the hop between application and database. The result is latency that traditional architectures struggle to match without significant complexity.
Collapsing these architectural layers, however, creates new decisions you must make thoughtfully. When everything can run at the edge, you must decide what should run there.
The rendering strategy decision
Before choosing frameworks or writing configuration, decide how your application should render. This decision shapes framework choice, data architecture, caching strategy, and cost model.
The options
Static generation produces HTML at build time. Every user receives identical content from cache, which delivers response times as fast as physically possible. Suits content that changes infrequently: marketing sites, documentation, blogs, landing pages.
Server-side rendering generates HTML at request time. Content can vary per user, per request, or based on real-time data. Slower than static but still fast at the edge. Suits dynamic content needing SEO or fast first paint: product pages with live pricing, personalised homepages, content changing too frequently for rebuild-on-change.
Single-page applications send a minimal HTML shell; JavaScript renders content client-side. Slowest initial load, but subsequent navigation is instant because the application is already running. Suits highly interactive applications where SEO doesn't matter: dashboards, editors, internal tools, authenticated experiences.
Hybrid approaches combine strategies: static for marketing pages, SSR for product pages, SPA for authenticated dashboards. Most real applications end up here.
The decision framework
Static until proven dynamic. Start with static generation as your default because it's simplest, fastest, and cheapest. Add complexity only when static fails your requirements.
Move to SSR when you can't pre-generate all content variations, when SEO matters for dynamic content, when you need fast first paint with personalisation, or when content changes too frequently for rebuilds. SSR burns CPU time on every request, proportional to traffic.
Move to SPA when the application is highly interactive, when SEO doesn't matter (authenticated users), when subsequent navigation speed matters more than initial load, or when you're building an application rather than a website. The tradeoff: you shift compute cost to users' devices, affecting mobile users disproportionately.
These choices aren't irreversible, but starting with the right strategy saves migration effort.
The cost lens
Rendering strategy has economic implications that compound at scale.
Static generation costs storage (pennies per gigabyte) plus bandwidth (free on Cloudflare). A million pageviews costs roughly what storing the pages costs, which is nearly nothing.
SSR costs CPU time per request. A million pageviews means a million renders, each consuming metered resources. Not prohibitive, but proportional to traffic in a way static generation isn't.
SPAs shift compute to the client. Your infrastructure costs drop, but users pay in battery life and data transfer. A 500 KB JavaScript bundle on every visit adds up for metered connections or constrained devices.
This economic lens clarifies decisions. Content that could be static but uses SSR because it feels sophisticated? You're paying for unneeded complexity. An SPA shipping megabytes of JavaScript to users who just want to read an article? You've externalised costs onto the people you're trying to serve.
Static asset serving
Workers serve static assets from Cloudflare's edge network, deployed alongside your Worker code on the same global infrastructure.
Configuration fundamentals
Enable static assets by specifying an asset directory:
[assets]
directory = "./public"
Files in that directory deploy as static assets. Requests matching file paths serve directly; non-matching requests invoke your Worker. Cloudflare handles content types, compression, and caching automatically.
The run_worker_first decision
By default, Cloudflare checks for matching static assets before invoking your Worker. If a file exists at the request path, it serves directly. This is faster and cheaper for static-heavy sites.
But authentication checks, request logging, header injection, and A/B testing all require your Worker to run before any response. Enable this with run_worker_first = true and an asset binding:
[assets]
directory = "./public"
binding = "ASSETS"
run_worker_first = true
Now every request hits your Worker. You call env.ASSETS.fetch(request) when you want to serve a static file; this is explicit rather than implicit.
Use default (asset-first) when: Your site is primarily static and you don't need to inspect static asset requests.
Use run_worker_first when: You must authenticate before serving any content, log all requests, inject headers, or have Worker logic determine static content serving.
Start with the default; add Worker interception when needed.
Asset bundling versus R2
Workers deployments can include up to 100,000 files totalling 500 MB on paid plans (20,000 files totalling 25 MB on free plans). These limits encode an assumption: bundled assets change with your code.
Pages applications are limited to 100,000 files and 500 MB per deployment (20,000 files and 25 MB on the free plan). Large asset bundles or media-heavy applications may need to offload static assets to R2.
Assets that should update independently of code deploys belong in R2. Large media files, user uploads, and CMS-managed assets shouldn't require a Worker deployment to update.
The deployment size limit is about coupling, not size. Assets that deploy with code should change with code. Assets that live longer or update more frequently belong in separate storage.
Server-side rendering at the edge
SSR on Workers generates HTML in edge locations close to users. This eliminates the network hop between user and renderer, but the benefit has conditions.
When edge ssr delivers
Edge SSR eliminates the user-to-renderer hop. But if your rendering logic fetches from an origin database, you've moved the renderer closer while data stays far. Edge SSR delivers full latency benefits only when data is also at the edge.
Edge SSR only delivers its full latency benefits when the data your pages need is also at the edge. If every render requires a round trip to a centralised database, you've moved the rendering but not eliminated the latency.
D1 databases replicate globally. KV provides eventually consistent reads from any location. R2 objects serve from edge cache. With these services, you get genuinely edge-native rendering: both compute and data close to users.
Applications requiring data that can't live at the edge (legacy databases, third-party APIs, large unreplicable datasets) get partial benefit from edge SSR. Better than origin-only rendering, but not the full latency elimination that edge-native data provides.
Edge SSR wins when: Users are geographically distributed and data is available at the edge through Cloudflare services.
Origin SSR wins when: Rendering requires large unreplicable datasets, CPU requirements exceed Worker limits, or you're migrating incrementally.
Hybrid works when: You can render some pages at the edge and fall back to origin for complex cases. Simple product pages use edge SSR; complex report generation uses origin.
Raw HTML versus frameworks
At its simplest, server-side rendering means generating HTML strings: query a database, interpolate results into a template, return the response. No framework required, just straightforward string concatenation that any developer can read and maintain.
Raw HTML generation suits internal tools, admin interfaces, simple applications with few routes, and teams preferring explicit control because the code is obvious, debugging is straightforward, and dependencies are minimal.
But raw generation doesn't scale to complex applications. Dozens of routes, nested layouts, client-side hydration. Frameworks provide structure that raw concatenation lacks.
The choice is about maintenance cost and team preferences, not fundamental capability. Frameworks trade dependency complexity for structural conventions that guide developers. Raw generation trades those conventions away for transparency and simplicity.
Streaming for perceived performance
Streaming sends HTML progressively rather than waiting for complete generation. Users see content appearing rather than staring at blank screens.
Streaming matters most when generation takes noticeable time. Pages rendering under 100 milliseconds gain nothing from streaming because progressive loading is invisible at that speed. Pages taking 500+ milliseconds benefit substantially, transforming from "waiting for blank screen" to "watching content load."
The threshold is perceptual: somewhere between 100 and 300 milliseconds, users notice delay. Below that, streaming is overhead. Above it, streaming distinguishes perceived speed from perceived sluggishness.
Modern frameworks handle streaming automatically when deployed to Workers. You configure streaming support; the framework manages chunked transfer encoding and progressive rendering.
HTMLRewriter: static performance, dynamic reality
HTMLRewriter is a streaming HTML parser and transformer built into Workers. It parses HTML properly and allows surgical modifications without buffering entire documents in memory. Unlike full SSR, it transforms existing content rather than generating HTML from scratch.
This hybrid model serves static content but transforms it at the edge. Base HTML caches indefinitely; transformations apply per-request. Static performance, dynamic capability.
Key use cases include: injecting user-specific data into static pages (authentication state, personalisation tokens), A/B testing without maintaining separate static versions, adding analytics scripts to every page, localising content based on request properties.
Use HTMLRewriter when page structure is static but specific values must vary per request. You're injecting dynamic values rather than restructuring the HTML.
Use SSR when page structure varies per request based on data or user properties. Content doesn't exist until request time.
HTMLRewriter processes streams with constant memory regardless of page size, which is dramatically more efficient than buffering for string replacement or regenerating via SSR. It's genuinely Cloudflare-specific; other platforms don't offer anything like it.
Framework integration
Complex applications benefit from component models, routing abstractions, and build optimisations. Workers integrate with major frameworks through adapters that translate framework conventions to edge execution.
Choosing a framework
The framework decision follows from rendering strategy and team expertise.
React Router (formerly Remix) offers the best Cloudflare integration: first-class support, excellent documentation, designed for edge deployment. The default choice for new React projects.
Astro excels at content-focused sites with its hybrid static/dynamic model. Pages not needing runtime data pre-render at build time; others render on demand. Natural fit for documentation, marketing sites, or content-heavy applications with some dynamic sections.
SvelteKit and Nuxt have official adapters and work well. If your team already knows Svelte or Vue, that learning curve advantage outweighs any integration smoothness difference.
Next.js works through OpenNext, a community-maintained adapter. More complex integration than purpose-built alternatives. For existing Next.js applications you're migrating, the investment makes sense. For new projects, ask whether ecosystem benefits outweigh integration complexity.
The framework tradeoff
Frameworks provide structure: routing conventions, component models, build optimisation, ecosystem access. Teams familiar with React Router or Astro can be productive immediately.
But frameworks are abstractions over Cloudflare's platform, and abstractions evolve. React Router's Cloudflare integration is excellent today. If project priorities shift or the integration changes, your code adapts to the framework's timeline, not yours.
For long-lived applications, consider how much framework surface area you're adopting. Thin frameworks cost less to maintain than thick ones that abstract away platform specifics. Choose consciously: know what you're gaining (structure, ecosystem, team familiarity) and trading (coupling to abstractions that may evolve differently than your application).
Binding access across frameworks
Regardless of framework, Cloudflare bindings (D1 databases, KV stores, R2 buckets, Durable Objects) are available through framework-specific context objects. React Router exposes them in context.cloudflare.env. Astro provides them in Astro.locals.runtime.env. SvelteKit uses platform.env.
Syntax differs; capability is identical. Your framework choice doesn't limit access to Cloudflare services, only how you access them.
The vite plugin
Many frameworks use Vite as their build tool. Cloudflare's Vite plugin provides development integration across frameworks: hot module replacement, automatic TypeScript type generation for bindings, local simulation of Cloudflare services.
For frameworks without dedicated Cloudflare adapters, the Vite plugin often provides sufficient integration: local development that approximates production behaviour without framework-specific machinery.
The plugin now integrates with @vitejs/plugin-rsc, the official Vite plugin for React Server Components. A childEnvironments option allows multiple environments within a single Worker, enabling RSC patterns where a parent environment imports modules from a child environment to access a separate module graph. For a typical RSC setup, you configure a viteEnvironment named "rsc" with childEnvironments: ["ssr"]. This is the lower-level infrastructure that frameworks like React Router build upon, and it means RSC support on Cloudflare no longer depends exclusively on Next.js adapters. Teams wanting React Server Components with more control over their framework layer can build directly on these primitives.
The plugin also supports auxiliary Workers: additional Workers defined alongside your main application and callable via service bindings. This enables splitting specific concerns (background processing, shared validation logic, isolated security-sensitive operations) without abandoning the unified build and deploy workflow. Your framework handles the main application; auxiliary Workers handle specialised tasks; everything builds and deploys together.
Framework ecosystem overview
Cloudflare maintains first-party support for major web frameworks with dedicated documentation, examples, and maintained adapters. Framework choice affects developer experience but not capability; all access the same platform through bindings.
Astro excels at content-focused sites with islands of interactivity. Its partial hydration model aligns with edge deployment: static content serves instantly, interactive components hydrate on demand.
Next.js runs on Workers through @cloudflare/next-on-pages or @opennext/cloudflare. Server components, API routes, and middleware execute at the edge. Some features require adaptation; consult the migration guide.
React Router v7 (formerly Remix) has official Cloudflare support through the Vite plugin. Loaders and actions execute at the edge with direct binding access. The recommended React full-stack framework for new Cloudflare projects.
SvelteKit provides its own Cloudflare adapter. Form actions, load functions, and API routes deploy as Workers automatically.
Nuxt supports Cloudflare through its Nitro deployment preset. Set the preset to cloudflare and deploy.
TanStack Start and RedwoodSDK are newer entries with Cloudflare-first designs, built around edge deployment patterns rather than adapted to them.
The migration decision
Teams on Vercel or Netlify considering Cloudflare must grapple with a fundamental migration decision. The platform differences are architectural, not just different deployment targets.
Vercel's model: compute runs in specific regions (or at the edge for Edge Functions), with managed infrastructure and framework magic handling deployment complexity.
Cloudflare's model differs fundamentally: compute runs everywhere by default, with explicit primitives (KV, D1, Durable Objects) for managing state. You control more; you configure more.
Migration from Vercel typically involves replacing Vercel-specific APIs (like @vercel/kv) with Cloudflare equivalents, adapting serverless function patterns to Workers' constraints (128 MB memory, no filesystem), reconfiguring environment variables through Wrangler, and updating build configuration.
Migration from Netlify follows similar patterns, with additional consideration for Netlify Functions versus Workers differences.
Start with a new feature or service on Cloudflare rather than migrating existing production systems. Learn the platform's patterns, then decide whether full migration makes sense. Platform differences compound in production; better to discover them on a new project than during a migration crisis. Chapter 25 provides a detailed playbook for Vercel and Netlify migrations.
Platform differences between Vercel/Netlify and Cloudflare are architectural, not just deployment targets. Starting with a new feature rather than migrating production code reduces risk and builds team familiarity.
Hyperscaler full-stack comparison
For teams coming from AWS, Azure, or GCP, the differences are more fundamental than Vercel or Netlify comparisons.
| Aspect | Cloudflare Workers | AWS Amplify | Azure Static Web Apps | Firebase Hosting |
|---|---|---|---|---|
| Edge compute | Global by default (300+ PoPs) | Lambda@Edge (regional, deployed to N. Virginia) | Azure Functions (regional) | Cloud Functions (regional) |
| SSR cold starts | Sub-millisecond | 100ms-1s (Lambda@Edge) | Hundreds of milliseconds | Hundreds of milliseconds |
| Deployment speed | Seconds | Minutes | Minutes | Minutes |
| Static hosting | Integrated with compute | CloudFront CDN | Global CDN | Global CDN |
| Pricing model | Requests + CPU time | Build minutes + bandwidth + hosting | Bandwidth + Functions invocations | Storage + bandwidth + Functions |
| Free tier | 100,000 requests/day | 5 GB storage, 15 GB bandwidth/month | 100 GB bandwidth/month | 10 GB storage, 10 GB bandwidth/month |
The architectural difference that matters: On hyperscalers, SSR typically runs in a specific region with CDN edge caching for static assets. Amplify deploys Lambda@Edge functions, but these are created in US East regardless of your app's deployment region. Azure and Firebase run functions regionally.
Cloudflare runs your SSR code at every edge location. A user in Sydney gets SSR from Sydney, not a cache hit from Sydney and a cache miss routed to us-east-1. This distinction matters for dynamic, personalised content that can't be cached.
When hyperscalers win: If you're deeply invested in AWS or Azure ecosystems with existing IAM, monitoring, and CI/CD pipelines, the native integration simplifies operations. Amplify's tight integration with AWS services (Cognito, AppSync, DynamoDB) reduces glue code. Azure Static Web Apps integrates seamlessly with Azure AD for enterprise authentication.
When Cloudflare wins: Global performance without regional configuration. Sub-millisecond cold starts mean SSR latency is predictable regardless of traffic patterns. The unified compute model (Workers handle everything) avoids the split between "edge functions" and "serverless functions" that other platforms require.
Single-page application patterns
SPAs present a routing challenge unique to client-side applications: every route should serve the same HTML shell, with JavaScript handling navigation.
Spa routing configuration
Configure this behaviour in your asset settings:
[assets]
directory = "./dist"
not_found_handling = "single-page-application"
Requests not matching static files serve /index.html instead of returning 404. Your client-side router interprets the URL and renders the appropriate view.
API routes alongside spas
Most SPAs need backend endpoints for data operations. A single Worker handles both: API routes return JSON; unmatched routes serve the SPA shell. Check the path prefix, handle API requests in your Worker logic, delegate everything else to static assets.
Everything ships in one deployment. Your SPA, its assets, and its API version together and route through the same Worker.
Edge authentication for spas
Traditional SPAs have a security gap: the JavaScript bundle downloads before any authentication check. Users receive your application code, then check authentication. For many applications this is fine; the code isn't secret and API endpoints are protected.
But some applications can't tolerate this: proprietary logic in the JavaScript, compliance requirements about code access, or simply the desire not to serve application code to unauthenticated users.
Edge authentication closes this gap. Your Worker validates authentication before serving anything: API responses, static assets, or the SPA shell. Unauthenticated requests redirect to login without receiving protected content.
The implementation validates session tokens or authentication headers at the Worker level, before invoking asset serving. This is only possible because the Worker intercepts requests before static asset delivery; it's an architectural option that origin-based SPAs don't have.
Pages and Workers: a consolidation story
If you've used Cloudflare before, you may know Pages as a separate product for static sites and full-stack applications. Cloudflare has consolidated investment around Workers. Pages continues to work, but new projects should use Workers directly.
Pages didn't fail; it succeeded so thoroughly that Workers absorbed it. Pages existed because Workers couldn't serve static assets; now they can. Pages had build integration and preview deployments; Workers have both.
The mental model: Workers are the platform. Static assets, server-side rendering, and API endpoints are capabilities, not separate products.
Migration path
For existing Pages projects, migration is mechanical. The pages_build_output_dir setting becomes an [assets] directory. Functions written in the Pages convention become explicit Worker handlers. Build commands work identically through the [build] configuration section.
The substantive change is routing. Pages used file-based routing: a file at /functions/api/items.ts handled requests to /api/items. Workers use explicit routing in your handler code. For complex applications, explicit routing is often clearer; you see the routing logic rather than inferring it from file positions.
Preview deployments, branch deployments, and Git integration continue to work.
Structuring full-stack applications
With static assets, SSR, and API routes unified in Workers, project structure becomes a design decision rather than a platform constraint.
The default: keep things together
For most applications, consolidation beats distribution across services. Your Worker handles routing, API logic, and SSR together. Static assets deploy alongside. Tests cover the whole application. Deployment is atomic and coordinated.
Splitting into multiple Workers creates coordination overhead: latency on each service binding call, debugging across service boundaries, deployment order dependencies. Microservice benefits (independent scaling, team autonomy, technology heterogeneity) require scale and organisational complexity that most applications don't have.
When splitting makes sense
Split a monolithic Worker into multiple Workers when you have specific reasons:
Deployment frequency divergence. One part deploys hourly, another monthly. Coupling means unnecessary deployments or blocked changes.
Resource requirement conflicts. One endpoint needs maximum CPU time while another needs minimal latency. Separate Workers allow independent configuration.
Team boundaries. Genuinely independent teams own different services and coordination cost exceeds the cost of service boundaries.
Security isolation. One service handles particularly sensitive operations and you want defense in depth through separation.
Don't split preemptively or because you might need independent scaling someday. Split only when coupling cost exceeds the coordination cost of service boundaries, not before.
If you're using the Vite plugin with a full-stack framework, auxiliary Workers offer a middle ground: define additional Workers in your Vite configuration that build and deploy alongside your main application. You get service binding performance without managing separate deployment pipelines. This works well for extracting specific concerns while maintaining a unified development workflow.
Caching strategy
Static assets cache automatically without configuration. Cloudflare handles cache headers and edge distribution transparently. Deployment handles invalidation by generating new asset URLs.
Server-rendered HTML benefits from caching when content doesn't vary per user. The question is staleness tolerance. How long can users see potentially outdated content? Minutes for some pages, seconds for others.
The Cache API allows explicit control: store rendered responses with appropriate TTLs, serve from cache when valid, regenerate when stale. The decision isn't whether to cache but how long and under what invalidation conditions.
API responses often shouldn't cache, but read-heavy endpoints with staleness tolerance benefit from short TTLs. Cache for 60 seconds and most users hit cache rather than compute. The tradeoff is staleness versus cost.
What comes next
This chapter covered building complete applications on Workers: rendering strategies, static assets, SSR at the edge, framework integration, and the consolidation of Pages into Workers.
Chapter 5 addresses local development that simulates edge behaviour, testing strategies for distributed systems, and debugging practices that make edge development productive.
Chapter 6 introduces Durable Objects, enabling capabilities that SSR and static assets can't provide: real-time features, WebSocket connections, coordination across requests.