Vercel vs Cloudflare Pages: Count the Routes That Need a Runtime
414 prerendered pages any CDN serves the same way, 8 dynamic routes, and exactly 2 that pin the Node runtime for node:crypto. Those two lines are the whole decision.
Pick between Vercel and Cloudflare Pages by counting, in your own build output, how many routes need a server and what runtime each one needs. This site's latest build prerendered 414 pages across 30 prerendered routes; 8 routes are dynamic; exactly 2 of those pin the Node runtime because they use node:crypto. Those two lines are the entire migration risk.
The census that answers it
Both platforms serve static assets from a global network, and for the overwhelming majority of this site that is all either of them is doing. Run next build and read the route table — this is the real one from this repository:
| Marker | Count | What it is |
|---|---|---|
○ static | 27 | Prerendered at build, served as files |
● SSG with params | 3 | Prerendered per param (products, posts, OG images) |
ƒ dynamic | 8 | Rendered per request |
| Pages generated | 414 | Across the 30 prerendered routes |
414 files and 8 functions. Any host with a CDN serves the 414 identically — that is not a differentiator, and it is why "which is faster" is usually the wrong first question. The decision lives entirely in the second column.
The eight routes, and what each one needs
| Route | Why it's dynamic | Needs Node? |
|---|---|---|
/api/webhooks/lemonsqueezy | Verifies an HMAC over the raw request body | Yes — pinned |
/api/download | Service-role client + Storage signed URLs | Yes — pinned |
/auth/callback | Exchanges an auth code, sets cookies | No |
/dashboard | Per-user data behind a session | No |
/dashboard/settings | Per-user data behind a session | No |
/login, /signup, /reset-password | Read auth state to redirect signed-in users | No |
Six of the eight are dynamic because they depend on a cookie, not because they need anything Node-specific. Those six are the portable kind of dynamic: they need a server, not a particular one.
The two that pin the runtime are the ones to read before choosing a platform:
// src/app/api/webhooks/lemonsqueezy/route.ts
// Node runtime (default for Route Handlers) — required for `node:crypto`.
export const runtime = "nodejs";
// src/app/api/download/route.ts
// Route Handlers default to Node (required here for the service-role client
// and Storage API); this is explicit for clarity.
export const runtime = "nodejs";
The code behind the pin
Here is the whole reason one of those pins exists — 10 lines that verify a payment webhook:
// src/lib/lemonsqueezy/signature.ts
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifySignature(
rawBody: string,
signatureHeader: string,
secret: string,
): boolean {
if (!signatureHeader) return false;
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(signatureHeader, "hex");
return a.length === b.length && b.length > 0 && timingSafeEqual(a, b);
}
Three Node-isms in ten lines: createHmac, Buffer, and timingSafeEqual. Cloudflare's platform runs Workers on V8 isolates rather than Node processes, so this is exactly the code that has to be checked against the Node compatibility layer — or rewritten against WebCrypto (crypto.subtle.importKey + sign, with a constant-time comparison) before it runs there.
Note the second and third lines of that function are not incidental. timingSafeEqual throws on a length mismatch, so a malformed header must be caught by the explicit length check rather than an exception — a detail that survives a rewrite only if whoever ports it knows why it's there. That is the real migration cost: not the platform, the ten lines nobody re-reads.
The thing that runs on every single request
One more file matters more than all eight routes, because it runs in front of them and in front of the 414 static pages too:
// src/proxy.ts — 42 lines
export async function proxy(request: NextRequest) {
let response = NextResponse.next({ request });
const supabase = createServerClient(/* … */);
// refreshes the session, writes rotated auth cookies onto the response
}
42 lines of Supabase session refresh, executed per request. Whatever the platform charges for per-request compute, this is the line item that meters, and it is the one to model before comparing price sheets — a static-heavy site with a proxy is not a static site as far as billing is concerned.
It is also the site's single largest availability dependency: this repo carries a standing warning that deploying without the Supabase environment variables set will error site-wide, because the proxy runs before everything.
What this comparison is not about
Three axes that come up in every Vercel-versus-X thread, and where they actually belong:
- Bandwidth and price. Real, and downstream of the census above. 414 static files are cheap everywhere; per-request compute is what varies. Model your proxy, not your page count.
- Edge locations and TTFB. Both networks are global. For prerendered HTML the difference is real but small, and it is dominated by whether your LCP image is right-sized — which is a build-time decision, not a hosting one.
- Next.js feature support. Vercel builds Next.js, so App Router features land there natively; Cloudflare runs Next.js through an adapter, which is a lag to check against the specific features you use, not a blanket disqualifier.
This blog has covered three neighbouring versions of this decision, each on a different axis, and none of them re-derive this one: Vercel vs GitHub Pages is the case where the compute layer is absent entirely, Vercel vs Render is about whether anything you run needs to stay running, and Vercel vs AWS Amplify is about the bundled backend. The Cloudflare question is narrower and more mechanical: what runtime do your handful of server routes need?
The decision, as a table
| If your build shows… | Then |
|---|---|
Zero ƒ routes | Either platform; pick on price and DX. You are hosting files |
ƒ routes that only read cookies and call an HTTP API | Either platform; the code is portable |
ƒ routes importing node:* or Buffer | Check each one against the Node compatibility layer, or budget the rewrite |
| A proxy/middleware on every request | Model per-request cost first; it dominates a static-heavy site's bill |
| Bleeding-edge App Router features | Vercel is where they land first |
| Traffic where egress is the dominant cost | Cloudflare's bandwidth posture is the reason people migrate |
Mistakes and how they show up
| Symptom | Cause | Fix |
|---|---|---|
| Webhook returns 500 only in production after a migration | node:crypto unavailable in the target runtime | Check the compatibility layer explicitly, or port to WebCrypto and keep the length check |
| Signature verification silently passes everything | Ported to a === string compare during the rewrite | Constant-time compare or nothing; a fast-path compare leaks the secret over enough attempts |
| Site 500s on every route after deploy | Middleware/proxy missing an environment variable | Env vars are per-platform; the proxy runs before every page, so a missing one is total, not partial |
| Bill higher than expected on a "static" site | Per-request middleware on all 414 pages | Scope the proxy's matcher to the routes that actually need a session |
| Static pages fine, one route 404s | Adapter didn't map a Route Handler the same way | Re-read the route table after the first deploy; compare it to next build's |
| Preview deploys work, production doesn't | Secrets configured in one environment only | Diff the environment variable sets, not the code |
Frequently asked questions
Is Cloudflare Pages faster than Vercel? For prerendered HTML, both serve from a global network and the difference is small enough that your image pipeline matters more. Cloudflare's isolate model has a genuine advantage on cold-start latency for per-request work — which only shows up if you have per-request work, which the census at the top of this post is how you find out.
Can I run a Next.js App Router app on Cloudflare Pages? Yes, through an adapter rather than native support. The practical questions are whether the features you use are covered and whether any route needs Node built-ins. Both are answerable in ten minutes from your own build output.
Do I need to rewrite my API routes to move to Cloudflare?
Only the ones using Node built-ins. In this codebase that is two of eight — and both for the same reason (node:crypto and a service-role SDK). The other six read a cookie and call an HTTP API, which runs anywhere.
What about the CSP and security headers?
This site sets them in next.config.ts's headers(). That is framework-level config on Vercel; on other platforms verify after the first deploy that the headers actually arrive — a missing Content-Security-Policy is invisible until something gets injected.
Templates in this post
Every template here builds to the same shape the census above describes — mostly static routes, a small number of dynamic ones — so the hosting question resolves the same way for all of them. ASoc Atelier is a designer's portfolio and studio site with a booking funnel, the case where a single form endpoint is the only server-side code on the site. ASoc Axiom, an AI-services landing page, is fully static apart from its contact path. ASoc Beacon, a mobile-device-management marketing site, is the one whose buyers most often bolt on authenticated routes later — which is when the runtime question stops being theoretical.
Browse the full sets: Next.js landing page templates and Tailwind landing page templates.
