AGENTUPDATE JOURNAL

1000usdinchina.com Dev Retrospective (2) - Edge Serverless: Next.js 15 + Cloudflare Pages, D1 & Workers

1000usdinchina.com Dev Retrospective (2) - Edge Serverless: Next.js 15 + Cloudflare Pages, D1 & Workers
Table of Contents

1000usdinchina.com has no traditional backend and no always-on server. It runs entirely on Cloudflare's edge: Next.js 15 rendered through Cloudflare Pages, D1 (SQLite at the edge) for dynamic data, and Workers for compute. This post explains the architecture — and the one production cache bug that taught me how Next.js and Cloudflare actually negotiate caching.

This is post 2 of the series on building the app.

Table of contents

Why the edge

Three reasons drove the Next.js + Cloudflare choice:

  • Cost. No server to keep warm. A solo project with 100 cities of content can't carry a fleet of VMs. Edge functions bill per request and idle at $0.
  • Latency. Content renders close to the user, worldwide. A traveler in São Paulo and one in Seoul both get sub-100ms responses.
  • Scale-to-zero and scale-up are free. No traffic, no cost; a Reddit spike, no paging.
sequenceDiagram
    participant U as Browser
    participant E as Cloudflare Edge (PoP)
    participant W as Worker (Next.js / OpenNext)
    participant D as D1 (SQLite)
    U->>E: GET /en/routes/beijing-shanghai
    E-->>U: cache HIT (static / cached) — returns instantly
    Note over E,W: on MISS ↓
    E->>W: invoke render
    W->>D: SELECT route, cities, prices
    D-->>W: rows
    W-->>E: HTML
    E-->>U: HTML (+ cache per rules)

The static vs. dynamic boundary

The single most important architectural decision was what renders statically vs. what hits the database. The rule:

Renders statically (SSG) Renders dynamically (D1)
Attractions, hotels, high-speed-rail, city reference data /routes/* (trip routes)
Anything from the ETL pipeline /cases/* (saved/generated trips)

ETL-produced reference data almost never changes between deploys, so it's baked into static pages at build time — hundreds of pre-rendered routes that serve from cache. Genuinely dynamic content (a user's route, a generated case) queries D1 on demand.

This boundary is deliberate and was pushed back on more than once ("why not make routes static too?"). The answer: routes are combinatorial and user-driven; pre-rendering every permutation is wasteful, and D1 at the edge is fast enough that on-demand wins.

D1 at the edge + OpenNext

Next.js doesn't natively target Cloudflare's runtime, so the app builds through OpenNext, which adapts the Next.js output to Workers. D1 is bound to the Worker and queried with plain SQL:

// Edge route handler — Web APIs only, no Node built-ins (fs/path/crypto.randomBytes)
export const runtime = "edge";

export async function getRoute(env: Env, slug: string) {
  const { results } = await env.DB
    .prepare("SELECT * FROM routes WHERE slug = ?1")
    .bind(slug)
    .all();
  return results;
}

A hard constraint of the edge runtime: no Node-only APIs. No fs, no path, no crypto.randomBytes. Everything is Web Crypto, Workers KV, or D1. This shapes how you write every utility — and it's enforced, not optional.

i18n: currency coupled to language

A travel budget tool is useless if it shows the wrong currency. So language and currency are hard-coupled: choosing a language is choosing a currency.

const LOCALE_CURRENCY = {
  en: "USD", ja: "JPY", ko: "KRW", ru: "RUB", // ...
} as const;

// Zero-decimal currencies (JPY, KRW, VND, IDR) MUST round to whole units.
function formatPrice(amountCny: number, locale: Locale): string {
  const currency = LOCALE_CURRENCY[locale];
  const value = convert(amountCny, currency);
  const fractionDigits = ZERO_DECIMAL.has(currency) ? 0 : 2;
  return new Intl.NumberFormat(locale, {
    style: "currency", currency,
    minimumFractionDigits: fractionDigits, maximumFractionDigits: fractionDigits,
  }).format(value);
}

Get the zero-decimal handling wrong and Japanese users see "¥1,234.56" — instantly untrustworthy. Currency correctness is a feature, not a formatting afterthought.

The caching bug worth your time

I shipped middleware that set Cache-Control: public, s-maxage=86400 on /routes pages, expecting Cloudflare to cache them at the edge. It didn't. curl -I showed private, no-store, max-age=0 and no cf-cache-status.

The lesson: Next.js automatically marks dynamic SSR pages (those that query D1) as no-store at the rendering layer, which overrides any Cache-Control you set in middleware. Cloudflare respects the origin's no-store and refuses to cache — exactly as designed.

flowchart TD
    A[Middleware sets s-maxage=86400] --> B[Next.js render layer]
    B -->|overrides with no-store| C[Origin response: no-store]
    C --> D{Cloudflare}
    D -->|respects origin| E[NOT cached]
    F[Cloudflare Cache Rule: Override origin Edge TTL] -.->|the only fix| D
    D -->|with rule| G[Cached at edge ✓]

The only way to cache a dynamic SSR page that Next.js marks no-store is a Cloudflare Cache Rule with "Override origin" Edge TTL — set at the platform level, not in code. Middleware cache headers become dead code in this scenario. Knowing where the caching decision actually lives saved hours of guessing.

Key takeaways

  • Edge serverless (Next.js + Cloudflare Pages/D1/Workers) gives a solo project near-zero fixed cost and global low latency.
  • Draw a deliberate static/dynamic boundary: bake reference data, query only the truly dynamic parts from D1.
  • The edge runtime forbids Node APIs — design for Web Crypto / KV / D1 from day one.
  • Next.js no-store overrides middleware caching; only a Cloudflare Cache Rule can cache dynamic SSR pages.

FAQ

Can Next.js 15 run on Cloudflare Pages? Yes — via OpenNext, which adapts the Next.js build to the Workers runtime, with D1 and KV bound to the Worker.

Why use D1 instead of a hosted Postgres? D1 is SQLite at the edge, co-located with the Worker, with no connection pool to manage and no idle cost — a good fit for a serverless, scale-to-zero app.

Why doesn't middleware Cache-Control cache my dynamic pages? Next.js sets no-store on dynamic SSR pages at render time, overriding middleware. Use a Cloudflare Cache Rule with "Override origin" Edge TTL instead.


Next → Turning 4.5 GB of raw travel data into clean, compliant city JSON