Comparison table: Four rendering models, decided per route. Indexable? versus Compute / req versus Best for, across SSG, SSR in a Worker, Edge rendering, CSR fallback.

Rendering a React app on Cloudflare: SSR, SSG, and edge rendering on Workers

How to choose between static generation, per-request SSR in a Worker, and edge rendering for a React app, with real Astro and wrangler config and an honest note on when SSR is the wrong call.

SSG, SSR, or edge rendering: which does your React app actually need?#

This is written for the software engineer or tech lead who owns that call on a React app, usually at an early-stage or scaling product company where the same person answers for the marketing site's search ranking and for the Cloudflare bill at the end of the month. The pull is to pick one rendering model and apply it everywhere, because one model is easier to hold in your head. That is the mistake this guide exists to prevent.

The choice is not about which model is best. It is about what a specific route needs. Walk each route through three questions and the answer falls out: is the route public and indexable, does its content change per request, and does the HTML vary per region or per user? Follow the branches and you land on one model per route.

Choosing a rendering model per routeStart at a single route and answer three questions. A route that is not indexable takes the CSR branch; an indexable route that never changes is SSG; fresh-but-uniform HTML is SSR in a Worker; HTML that varies per region or per user is edge rendering.

Notice that the questions run from cheapest to most expensive. You only move down a branch when the route genuinely needs what the next model offers. Most apps have a handful of routes on each branch, which is why the honest answer is almost always a mix.

How do the models compare on SEO, speed, cache, cost, and complexity?#

Once you know a route's branch, this is the table to read across. Find your row and check the attributes that matter for that route.

Rendering models compared on the attributes that decide a route
ModelIndexable HTMLTime-to-first-byteCache-abilityPer-request computeBuild / infra complexityBest for
SSGYes, full HTMLLowest, served from cacheCached everywhere by defaultNoneLowestPublic content that is the same for everyone
SSR in a WorkerYes, full HTMLRender time added per requestNeeds explicit cache rulesOne render per requestMedium, needs the Cloudflare adapterFresh content that must be indexable
Edge renderingYes, per variantRender time added, close to the userCache per variant key onlyOne render per variant per requestHighest, variant logic plus cache keysPer-region or per-user variance
CSR fallbackNo, empty shellFast byte, slow first paintShell cached, data is notNone on the serverLowBehind-auth apps with no SEO surface

The two columns that decide most arguments are indexable HTML and per-request compute. If a route needs to be found in search, CSR is out. If it does not, you have just saved yourself the compute and the cache headache.

The SEO column in that comparison is the one teams most often get wrong, because it is decided by what the crawler receives rather than by what the framework claims. For the crawler-side view of the same decision, read our guide to what an SEO-friendly frontend architecture looks like from the crawler in. It covers what each rendering model hands a bot, which is the detail that makes SSG and SSR diverge in practice.

What does SSG on Workers give you, and where does it hit a ceiling?#

Static generation is the default, and it should be. You render every page to HTML once at build, and Cloudflare serves those files as static assets from its cache at the edge. There is no render at request time, so a visitor gets the answer as fast as the network can carry it, and the cost to serve is close to nothing.

On Cloudflare this means building your React app to HTML and deploying it to Workers Static Assets, not Cloudflare Pages. Atyantik ships public sites this way on purpose: the static output is served by the same Workers platform that runs any dynamic route, so there is one deployment target and one runtime to reason about, not two.

astro.config.mjs · js
import { defineConfig } from 'astro/config'
import react from '@astrojs/react'

// No adapter: every route prerenders to static HTML at build,
// then deploys to Workers Static Assets (not Cloudflare Pages).
export default defineConfig({
  integrations: [react()],
  output: 'static',
})
wrangler.toml · toml
# wrangler.toml: serve the prebuilt HTML from Workers Static Assets.
name = "field-reports"
compatibility_date = "2024-09-23"

[assets]
directory = "./dist"

The [assets] block is the whole story: point it at your build output and Cloudflare serves those files directly. No adapter, no per-request code, no cold start.

The ceiling is exactly what the word static implies. The HTML is frozen at build time. If a route shows data that must be fresh at the moment of the request, or content personalized to the visitor, SSG cannot help you without a rebuild for every possible state. That is the line where you step up to SSR.

What does SSR in a Worker buy you, and what does it cost?#

Server-side rendering runs your React tree on the server for each request and sends back finished HTML. On Cloudflare that server is a Worker isolate. You add the Astro Cloudflare adapter (a Remix or React Router loader running in a Worker gets you the same shape) and mark the routes that need it as non-static.

astro.config.mjs · js
import { defineConfig } from 'astro/config'
import react from '@astrojs/react'
import cloudflare from '@astrojs/cloudflare'

export default defineConfig({
  integrations: [react()],
  adapter: cloudflare(),
  output: 'static',        // static by default...
})
src/pages/app/reports.astro · astro
---
// src/pages/app/reports.astro
// Opt this one route into per-request rendering in the Worker.
export const prerender = false

const res = await fetch('https://api.example.com/reports', {
  headers: { authorization: Astro.request.headers.get('authorization') ?? '' },
})
const reports = await res.json()
---
<ReportsTable client:load reports={reports} />

What you buy is fresh HTML a crawler can read without executing your JavaScript. What you pay is a render on every request and the loss of a plain cache hit. The isolate cold start on Workers is a few milliseconds rather than the seconds a container takes, so the tax is small, but it is not zero, and you now own cache decisions you did not have with pure SSG. Reach for SSR on the specific routes that need fresh indexable HTML, not across the whole app.

Edge rendering: when does per-request personalization justify the complexity?#

Edge rendering is SSR with a twist: the render runs at the isolate closest to the visitor and can produce different HTML for different people. A German visitor and a Japanese visitor get pages rendered near them, in the variant that fits them, without a round-trip to a central origin.

The judgment call is whether the variance is real. Geo-targeted content, an A-B experiment that must be server-rendered to avoid a flash, or a signed-in home page that differs per user are the cases that pay off. If every visitor would see the same HTML anyway, edge rendering buys you nothing over SSG and costs you variant logic, per-variant cache keys, and a harder debugging story. The complexity is only worth carrying when you can point at the content that actually differs.

What does each model actually do to time-to-first-byte?#

The numbers are easier to feel side by side than to read in a table. A static asset answers from cache almost immediately. An SSR render adds its compute on top. A client-rendered route returns its first byte fast, but that byte is an empty shell, and real content waits on the JavaScript bundle downloading, parsing, and then fetching data.

Time to meaningful HTML by model~30x faster
Fastest

~10 ms

SSG (static asset from cache)

~50 ms

SSR in a Worker (render per request)

~300 ms+

CSR (shell, then JS + data)

These are illustrative, order-of-magnitude typical values for the platform behaviour described, not a single published benchmark and not Atyantik client data. The point is the ratio, not the exact figures: a static hit answers in single-digit-to-low milliseconds, SSR adds render compute, and CSR's fast first byte hides that the reader (and a crawler) sees nothing meaningful until the bundle has run.

Time to meaningful HTML by model (time to meaningful HTML (typical, illustrative))
Optiontime to meaningful HTML (typical, illustrative)
SSG (static asset from cache)~10 ms
SSR in a Worker (render per request)~50 ms
CSR (shell, then JS + data)~300 ms+

Source: Cloudflare Workers Static Assets (platform behaviour, figures illustrative)

Which model serves which route? See it live#

Pick a route type below and follow the request. The panel shows which model serves it, where the work happens, and whether the response is a cache hit or per-request compute. It is the decision tree above, turned into the three routes a single app really has.

Route type to rendering model
SSGCache hit
  1. Browser
  2. Edge cache
  3. Prebuilt HTML

The same HTML for every visitor, so it is prerendered once at build and served as a static asset from Cloudflare’s cache at the edge. No render runs per request. This is the cheapest route to serve and the fastest to answer.

Toggle a route to see its model, request path, and whether it hits cache or runs compute. Keyboard: arrow keys move between routes, Home and End jump to the ends.

Worked example: one React app, three rendering strategies#

Take one product: a dashboard with a public marketing front. It does not pick a single rendering model; it uses the right one per route, in one codebase and one deploy.

The marketing front (/, /pricing) is the same for everyone and needs to rank, so it is SSG, prerendered and served from Static Assets. The authenticated dashboard (/app/reports) shows fresh data and must render its HTML server-side, so it is SSR in the Worker, marked prerender = false. If a signed-in home page needs to differ per user or region, that one route graduates to edge rendering. Everything else stays static.

When you ship on Workers this way, the split tends to come out lopsided on purpose. The large majority of routes prerender, only a handful run per-request SSR, and edge rendering is usually a single route or none. On a dashboard-plus-marketing build the SSR routes are typically the small minority against a much larger set of static ones, and that ratio is the actual decision most teams miss. They reach for an SSR framework, render everything server-side because the framework makes that the path of least resistance, and never notice how few routes needed it.

The bill follows the same ratio, which is the part you cannot easily google before you have run it. A prerendered route costs one static-asset read served from cache. An SSR route costs a Worker invocation plus its CPU time on every hit, and an edge route adds a cache entry per variant on top. Render a marketing page server-side when it could be static and you have signed up for a recurring per-request charge to produce output you could have frozen once at build. The per-route split is not a purity exercise; it is the difference between paying for compute you use and paying for compute you did not need.

bash
# One app, three strategies, one deploy.
#
#   /                 SSG   prebuilt HTML, served from Static Assets
#   /pricing          SSG   prebuilt HTML, served from Static Assets
#   /app/reports      SSR   prerender = false, renders in the Worker
#   /home (signed in) EDGE  variant render in the nearest isolate
#
# Astro serves the static routes from [assets]; the Worker handles
# the routes you marked prerender = false.

Astro serves the static routes straight from the [assets] directory and hands the prerender = false routes to the Worker. You did not stand up a separate SSR server or a second platform; you opted individual routes into rendering and left the rest cheap. That is the pattern to aim for: static by default, SSR where SEO and freshness both matter, edge only where the content truly varies.

Where to go next#

Rendering strategy is one lever on how fast a site feels. If speed is the broader goal, see how we approach Core Web Vitals and performance. If you are choosing a stack for a new build, this same static-first approach is how we ship a production MVP, and it is the backbone of how we build on Cloudflare. For a companion piece on the same platform, read building a React PWA on Cloudflare.

If you want a second set of hands on a React app you are rendering on Workers, you can hire React developers from our team, or talk to us about the fit. No pressure, and no lock-in: everything above is standard, documented web platform work you own outright.

Portrait of Tirth Bodawala

Tirth Bodawala

Chief Technology Officer, Atyantik Technologies

Tirth leads software engineering at Atyantik Technologies, a software product studio building web platforms, mobile apps, and AI-integrated systems since 2015. He writes about shipping software that holds up in production, from rendering performance to the platform decisions behind a launch.

More from Tirth BodawalaAbout AtyantikHire React developers

Keep reading