Compute · Pages

Ship a full-stack frontend with Pages

After this lesson you'll be able to deploy a static site with a serverless API route on Cloudflare Pages, using either a git push or wrangler pages deploy, and know when file-based Functions routing applies instead of Workers routing.

What Pages actually is

Cloudflare Pages is git-connected static hosting — push to a repo, Cloudflare builds your site and serves it from its global network — plus Pages Functions, which are Workers that run alongside your static assets to handle dynamic routes. A plain static site (marketing page, docs site, built SPA) needs only the static half. A full-stack app adds a functions/ directory: files in it become serverless API endpoints deployed as Workers, sharing the same project, domain, and deploy pipeline as the static assets.

Under the hood, a Pages Function is a Worker — Cloudflare's own docs state that Pages Functions are billed as Workers and run on the same runtime (workerd, V8 isolates). Pages isn't a separate compute product; it's a deployment and routing convenience layer in front of Workers, purpose-built for "static site plus some API routes" projects.

Pages and Workers are converging. Workers now supports serving static assets directly (via the assets field in wrangler.jsonc), which overlaps substantially with what Pages does. Cloudflare has been steering new full-stack projects — especially framework apps with SSR — toward Workers with static assets, while Pages remains fully supported for existing projects and git-based static/JAMstack workflows. If you're starting fresh and want the most direct control over routing and bindings, it's worth comparing both (the next lesson covers exactly that decision). This lesson focuses on Pages as it exists today: a mature, still-recommended path for git-triggered static + Functions deployments.

The build and deploy flow

A Pages project is created one of three ways:

  1. Git integration — connect a GitHub/GitLab repo; every push to your production branch triggers a build (using a framework preset or your own build command) and a deploy; every push to any other branch gets its own preview URL at <branch>.<project>.pages.dev.
  2. Direct upload via Wrangler — you build locally (or in your own CI) and push the output folder straight to Cloudflare with wrangler pages deploy. No Cloudflare-hosted build step.
  3. C3 (npm create cloudflare) — scaffolds a new project from a framework template and wires up deployment for you.

With git integration, Cloudflare's build environment detects your framework (or you set a preset explicitly), runs your build command, and publishes the output directory. With direct upload, that build step is on you — Wrangler just ships whatever static folder you point it at, plus a functions/ directory if one exists alongside it.

Framework support

Pages supports most static-site generators and frontend frameworks out of the box via build presets: Next.js, Astro, SvelteKit, Nuxt, Remix, Hugo, Gatsby, plain React/Vite, and others. Frameworks that support server-side rendering (Next.js, SvelteKit, Astro, Remix) use an adapter that compiles their server-rendering logic down into a Pages Function, so SSR runs at the edge alongside your static assets rather than needing a separate Node server. You pick the framework preset when connecting your repo (or C3 picks it for you), and Cloudflare sets the correct build command and output directory automatically.

Pages Functions: file-based routing

Inside a Pages project, a top-level functions/ directory maps files to routes automatically — no explicit route table:

Each file exports one or more HTTP-method-specific handlers:

// functions/api/subscribe.js
export async function onRequestPost(context) {
  const { request, env } = context;
  const { email } = await request.json();

  if (!email || !email.includes("@")) {
    return Response.json({ error: "Invalid email" }, { status: 400 });
  }

  // env would hold bindings configured for this Pages project (KV, D1, secrets, etc.)
  await env.SUBSCRIBERS.put(email, new Date().toISOString());

  return Response.json({ ok: true });
}

export async function onRequestGet() {
  return new Response("Send a POST with { email } to subscribe.", { status: 405 });
}

onRequest (method-agnostic), onRequestGet, onRequestPost, and so on are all valid exports. The context object carries request, env (your bindings), params (from dynamic route segments), and next() (to fall through to the next matching middleware or, ultimately, your static asset).

Worked example: a static page that POSTs to a Function

A minimal project: one HTML page with a form, one Function handling the submission.

my-site/
├── public/
│   └── index.html
└── functions/
    └── api/
        └── subscribe.js
<!-- public/index.html -->
<form id="signup">
  <input type="email" name="email" placeholder="you@example.com" required>
  <button type="submit">Subscribe</button>
</form>
<p id="result"></p>

<script>
  document.getElementById("signup").addEventListener("submit", async (e) => {
    e.preventDefault();
    const email = e.target.email.value;
    const res = await fetch("/api/subscribe", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email }),
    });
    const data = await res.json();
    document.getElementById("result").textContent = data.ok
      ? "Subscribed!"
      : data.error;
  });
</script>

Use the same functions/api/subscribe.js shown above. Deploy directly with Wrangler — no separate config file is required for a static + Functions project this simple:

npx wrangler login
npx wrangler pages project create my-site
# → prompts for production branch, e.g. "main"

npx wrangler pages deploy public
# → Uploading... 2 files
# → Deploying functions bundle...
# → Success! Uploaded to https://my-site.pages.dev

Wrangler uploads the public/ folder as static assets and automatically picks up the sibling functions/ directory, bundling it into a Worker that runs in front of (and alongside) the static content. Push the same repo to GitHub with git integration connected instead, and every commit to main repeats this deploy automatically, with pull-request branches getting their own preview URLs.

Pricing

Static asset hosting on Pages — requests, bandwidth, and file serving — has no published request or bandwidth caps or egress charges on any plan. The limits that do apply are on the build and Functions side:

FreePro / Business
Builds per month5005,000 (Pro) – 20,000 (Business)
Concurrent builds15 (Pro) – 20 (Business)
Files per projectup to 20,000up to 100,000
Max file size25 MiB25 MiB
Custom domains100250 (Pro) – 500 (Business/Enterprise)

Pages Functions invocations are billed identically to Workers requests and CPU time (Free: 100,000 requests/day, 10ms CPU/invocation; Paid: 10 million requests/month included then metered, 30 million CPU-ms/month included then metered, $5/month minimum) — because a Pages Function is a Worker. Builds time out after 20 minutes. These figures are current as of this writing; check the primary source below, since build/file limits in particular have shifted over Pages' history.

Use cases

Common pitfall: treating functions/ routing like Workers routing. A plain Worker's routes are declared explicitly — either in the dashboard, as routes/route entries in wrangler.jsonc, or matched in code against request.url yourself. Pages Functions routes come from file paths under functions/, with no explicit route list to inspect. Two mistakes follow from this: (1) developers write a single "catch everything" handler expecting to parse the full path themselves (the Workers habit), when they should instead be splitting logic across files and using [param]/[[catchall]] filename conventions; (2) developers add a file like functions/api/users.js expecting it to also match /api/users/123 — it won't, because a single bracket-free filename matches only its exact path. Use [id].js for one dynamic segment or [[path]].js for a catch-all, and remember a _middleware.js file in any Functions directory runs for every request under that path before the specific route handler.
Primary source

Cloudflare Pages docs — start here for framework guides and Functions routing details; cross-check exact build/file limits at Pages limits and Functions billing at Workers pricing, since Pages Functions bill as Workers.

You add functions/api/orders.js to a Pages project and expect it to handle both /api/orders and /api/orders/42. What actually happens?
Without scrolling up: what makes a Pages Function different from a "plain" Worker at the runtime level, and what makes it different at the routing level?
Reveal

At the runtime level, nothing — a Pages Function is a Worker, running on the same workerd/V8-isolate runtime and billed identically. At the routing level, a plain Worker's routes are declared explicitly (dashboard, wrangler.jsonc routes, or manual URL parsing in code), while a Pages Function's routes come from its file path under functions/, with bracket syntax ([id], [[catchall]]) for dynamic segments instead of a route table.

Anything above unclear — the Pages/Workers convergence, framework adapters, or the file-based routing brackets? Ask your AI teacher before moving on to deciding between Pages and Workers for a new project.
← Previous: Designing Workflows that fail gracefully Next: Pages or Workers? Choosing the right compute model →