Skip to main content
ASoc
Comparison

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.

The ASoc Team9 min read

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:

MarkerCountWhat it is
static27Prerendered at build, served as files
SSG with params3Prerendered per param (products, posts, OG images)
ƒ dynamic8Rendered per request
Pages generated414Across 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

RouteWhy it's dynamicNeeds Node?
/api/webhooks/lemonsqueezyVerifies an HMAC over the raw request bodyYes — pinned
/api/downloadService-role client + Storage signed URLsYes — pinned
/auth/callbackExchanges an auth code, sets cookiesNo
/dashboardPer-user data behind a sessionNo
/dashboard/settingsPer-user data behind a sessionNo
/login, /signup, /reset-passwordRead auth state to redirect signed-in usersNo

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 ƒ routesEither platform; pick on price and DX. You are hosting files
ƒ routes that only read cookies and call an HTTP APIEither platform; the code is portable
ƒ routes importing node:* or BufferCheck each one against the Node compatibility layer, or budget the rewrite
A proxy/middleware on every requestModel per-request cost first; it dominates a static-heavy site's bill
Bleeding-edge App Router featuresVercel is where they land first
Traffic where egress is the dominant costCloudflare's bandwidth posture is the reason people migrate

Mistakes and how they show up

SymptomCauseFix
Webhook returns 500 only in production after a migrationnode:crypto unavailable in the target runtimeCheck the compatibility layer explicitly, or port to WebCrypto and keep the length check
Signature verification silently passes everythingPorted to a === string compare during the rewriteConstant-time compare or nothing; a fast-path compare leaks the secret over enough attempts
Site 500s on every route after deployMiddleware/proxy missing an environment variableEnv 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" sitePer-request middleware on all 414 pagesScope the proxy's matcher to the routes that actually need a session
Static pages fine, one route 404sAdapter didn't map a Route Handler the same wayRe-read the route table after the first deploy; compare it to next build's
Preview deploys work, production doesn'tSecrets configured in one environment onlyDiff 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.

Keep reading

Comparison8 min read

Vercel vs. DigitalOcean: What Owning the Server Actually Costs

Two Route Handlers, zero Dockerfiles, zero nginx configs — what this storefront's own deploy setup says about Vercel vs a DigitalOcean Droplet.

Read more
Comparison8 min read

Vercel vs. GitHub Pages: 344 Static Pages, 8 That Need a Server

GitHub Pages has no compute layer at all — not slower, absent. A fresh build of this storefront counts exactly which routes that categorically rules out, and why it isn't a rendering-mode question.

Read more