<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: pengcheng zhao</title>
    <description>The latest articles on DEV Community by pengcheng zhao (@pengcheng_zhao_9c8f5afbab).</description>
    <link>https://dev.to/pengcheng_zhao_9c8f5afbab</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4055492%2Fc622e51a-9a31-4244-9716-4502d71d7b52.jpg</url>
      <title>DEV Community: pengcheng zhao</title>
      <link>https://dev.to/pengcheng_zhao_9c8f5afbab</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pengcheng_zhao_9c8f5afbab"/>
    <language>en</language>
    <item>
      <title>Building a Global, Multilingual Memorial Site with Next.js 15 on Cloudflare — What I Learned</title>
      <dc:creator>pengcheng zhao</dc:creator>
      <pubDate>Thu, 30 Jul 2026 18:39:36 +0000</pubDate>
      <link>https://dev.to/pengcheng_zhao_9c8f5afbab/building-a-global-multilingual-memorial-site-with-nextjs-15-on-cloudflare-what-i-learned-5c9</link>
      <guid>https://dev.to/pengcheng_zhao_9c8f5afbab/building-a-global-multilingual-memorial-site-with-nextjs-15-on-cloudflare-what-i-learned-5c9</guid>
      <description>&lt;p&gt;A stray dog named Wangwang touched people around the world. What started as a small, personal act of remembrance grew into a multilingual digital memorial — a quiet, hopeful space where anyone can leave a message, light a candle, and stand up for animal welfare.&lt;/p&gt;

&lt;p&gt;I built it as a side project, but shipping it taught me a lot about deploying Next.js 15 to the edge, client-side i18n that actually works, and SEO hygiene. This post walks through the architecture and the gotchas, in case you're building something similar.&lt;/p&gt;

&lt;p&gt;Note: I won't paste any secrets here. Cloudflare bindings (D1 id, R2, PayPal keys) live in wrangler.toml and stay out of this post.&lt;/p&gt;

&lt;p&gt;The stack&lt;br&gt;
Next.js 15 + React 19, TypeScript&lt;br&gt;
Tailwind CSS 3.4&lt;br&gt;
OpenNext to deploy on Cloudflare Workers (@opennextjs/cloudflare)&lt;br&gt;
Cloudflare D1 (SQL database for messages) + R2 (user-uploaded images)&lt;br&gt;
react-simple-maps + d3-geo + topojson-client for the interactive world map&lt;br&gt;
7 languages: zh-CN, zh-TW, en, hi, es, fr, ar (with right-to-left support)&lt;br&gt;
Deploying Next.js to Cloudflare Workers with OpenNext&lt;br&gt;
This was the part with the most surprises. Next's default next build produces output tuned for Node servers or Vercel. To run it on Cloudflare's edge runtime, OpenNext repackages the app into a Worker.&lt;/p&gt;

&lt;p&gt;My wrangler.toml wires up the Worker entry, static assets, and the two bindings:&lt;/p&gt;

&lt;p&gt;name = "wangwang-memorial"&lt;br&gt;
compatibility_date = "2025-01-01"&lt;br&gt;
compatibility_flags = ["nodejs_compat"]   # React 19 / Next need Node APIs&lt;br&gt;
main = ".open-next/worker.js"&lt;/p&gt;

&lt;p&gt;[assets]&lt;br&gt;
directory = ".open-next/assets"&lt;/p&gt;

&lt;h1&gt;
  
  
  Cloudflare D1 (messages DB) and R2 (uploaded images).
&lt;/h1&gt;

&lt;h1&gt;
  
  
  Real ids/secrets are kept out of source control / this post.
&lt;/h1&gt;

&lt;p&gt;[[d1_databases]]&lt;br&gt;
binding = "DB"&lt;br&gt;
database_name = "wangwang_db"&lt;br&gt;
database_id = ""&lt;/p&gt;

&lt;p&gt;[[r2_buckets]]&lt;br&gt;
binding = "R2_BUCKET"&lt;br&gt;
bucket_name = "wangwang-images"&lt;/p&gt;

&lt;p&gt;[vars]&lt;br&gt;
NEXT_PUBLIC_PAYPAL_CLIENT_ID = ""&lt;br&gt;
R2_PUBLIC_URL = ""&lt;/p&gt;

&lt;p&gt;routes = [&lt;br&gt;
  { pattern = "wangwangdog.org", custom_domain = true },&lt;br&gt;
  { pattern = "&lt;a href="http://www.wangwangdog.org" rel="noopener noreferrer"&gt;www.wangwangdog.org&lt;/a&gt;", custom_domain = true },&lt;br&gt;
]&lt;br&gt;
The deploy commands come straight from package.json:&lt;/p&gt;

&lt;p&gt;"preview": "opennextjs-cloudflare build &amp;amp;&amp;amp; opennextjs-cloudflare preview",&lt;br&gt;
"deploy":  "opennextjs-cloudflare build &amp;amp;&amp;amp; opennextjs-cloudflare deploy"&lt;br&gt;
Gotcha #1: SSG 404s on dynamic routes&lt;br&gt;
On Cloudflare, my dynamic blog routes (/blog/[slug]) sometimes returned 404 because the edge cache expected pre-rendered HTML that wasn't there. The fix was to force on-demand rendering for those routes:&lt;/p&gt;

&lt;p&gt;// src/app/blog/[slug]/page.tsx&lt;br&gt;
// Force the Worker to render per-request (SSR) instead of relying on&lt;br&gt;
// pre-rendered static HTML that may be missing on Cloudflare.&lt;br&gt;
export const dynamic = "force-dynamic";&lt;br&gt;
Pages that are truly static (the marketing pages) are left alone; only the data-driven ones opt into force-dynamic. Net result: no more missing pages, and the DB-backed content stays fresh.&lt;/p&gt;

&lt;p&gt;Client-side i18n that survives hydration&lt;br&gt;
I went with a lightweight client-side dictionary rather than Next's heavier routing-based i18n. A single translate(lang, key) function reads from a per-language dictionary, and a React context exposes it:&lt;/p&gt;

&lt;p&gt;// src/lib/i18n.ts (excerpt)&lt;br&gt;
export type Lang = "zh-CN" | "zh-TW" | "en" | "hi" | "es" | "fr" | "ar";&lt;br&gt;
export const DEFAULT_LANG: Lang = "zh-CN";&lt;br&gt;
// ...a dictionary per language, e.g. { "nav.home": "首页", ... }&lt;br&gt;
The provider keeps the choice in localStorage and — importantly — sets the document direction so Arabic renders RTL:&lt;/p&gt;

&lt;p&gt;// src/components/I18nProvider.tsx (excerpt)&lt;br&gt;
useEffect(() =&amp;gt; {&lt;br&gt;
  const el = document.documentElement;&lt;br&gt;
  el.lang = lang;&lt;br&gt;
  el.dir = lang === "ar" ? "rtl" : "ltr";   // ← easy to forget, critical for Arabic&lt;br&gt;
}, [lang]);&lt;br&gt;
The trick to avoid hydration mismatches: start with the default language on the server, then read the saved language in a useEffect after mount. That way server and first client render agree, and the switch happens a frame later.&lt;/p&gt;

&lt;p&gt;SEO: complete Open Graph + Twitter cards&lt;br&gt;
Search and social previews were broken by default — child pages inherited the homepage's OG title and had no share image. I centralized everything in one buildMetadata() helper so every page gets a correct title, description, canonical URL, OG block, and Twitter card:&lt;/p&gt;

&lt;p&gt;// src/lib/seo.ts (excerpt)&lt;br&gt;
export function buildMetadata({&lt;br&gt;
  title, description, path, image,&lt;br&gt;
}: {&lt;br&gt;
  title: string;&lt;br&gt;
  description: string;&lt;br&gt;
  path: string;&lt;br&gt;
  image?: string;&lt;br&gt;
}) {&lt;br&gt;
  const url = &lt;code&gt;${SITE_URL}${path}&lt;/code&gt;;&lt;br&gt;
  return {&lt;br&gt;
    title,&lt;br&gt;
    description,&lt;br&gt;
    alternates: { canonical: url },&lt;br&gt;
    openGraph: {&lt;br&gt;
      title, description, url,&lt;br&gt;
      siteName: SITE_NAME,&lt;br&gt;
      locale: "zh_CN",&lt;br&gt;
      type: "website",&lt;br&gt;
      images: [{ url: image ?? DEFAULT_OG_IMAGE, width: 1200, height: 630 }],&lt;br&gt;
    },&lt;br&gt;
    twitter: {&lt;br&gt;
      card: "summary_large_image",&lt;br&gt;
      title, description,&lt;br&gt;
      images: [image ?? DEFAULT_OG_IMAGE],&lt;br&gt;
    },&lt;br&gt;
  };&lt;br&gt;
}&lt;br&gt;
And the root layout declares metadataBase so Next resolves absolute OG URLs without warnings:&lt;/p&gt;

&lt;p&gt;export const metadata = {&lt;br&gt;
  metadataBase: new URL(SITE_URL),&lt;br&gt;
  title: { default: "Remember Wangwang", template: "%s | Remember Wangwang" },&lt;br&gt;
  // ...openGraph + twitter filled in via buildMetadata for each route&lt;br&gt;
};&lt;br&gt;
Gotcha #2: canonical domain with a 301&lt;br&gt;
To avoid duplicate-content penalties between www and the apex domain, a tiny middleware does a permanent redirect:&lt;/p&gt;

&lt;p&gt;// src/middleware.ts (excerpt)&lt;br&gt;
const WWW_HOST = "&lt;a href="http://www.wangwangdog.org" rel="noopener noreferrer"&gt;www.wangwangdog.org&lt;/a&gt;";&lt;br&gt;
const APEX_HOST = "wangwangdog.org";&lt;/p&gt;

&lt;p&gt;export function middleware(request: NextRequest) {&lt;br&gt;
  const host = request.headers.get("host")?.toLowerCase() ?? "";&lt;br&gt;
  if (host === WWW_HOST) {&lt;br&gt;
    const url = request.nextUrl.clone();&lt;br&gt;
    url.host = APEX_HOST;&lt;br&gt;
    return NextResponse.redirect(url, 301);&lt;br&gt;
  }&lt;br&gt;
  return NextResponse.next();&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;// Run only on pages, skip static assets &amp;amp; _next internals&lt;br&gt;
export const config = {&lt;br&gt;
  matcher: "/((?!api|_next/static|_next/image|favicon.ico|.&lt;em&gt;\.).&lt;/em&gt;)",&lt;br&gt;
};&lt;br&gt;
The interactive world map&lt;br&gt;
The "Global Voices" page plots where messages come from. react-simple-maps makes this painless on top of d3-geo projections and a TopoJSON world file:&lt;/p&gt;

&lt;p&gt;&lt;br&gt;
  &lt;br&gt;
    {({ geographies }) =&amp;gt;&lt;br&gt;
      geographies.map((geo) =&amp;gt; )&lt;br&gt;
    }&lt;br&gt;
  &lt;br&gt;
  {markers.map((m) =&amp;gt; (&lt;br&gt;
    &lt;br&gt;
      &lt;br&gt;
    &lt;br&gt;
  ))}&lt;br&gt;
&lt;br&gt;
Because it's all SVG, it stays crisp on any screen and works without a heavy mapping dependency.&lt;/p&gt;

&lt;p&gt;What I'd tell my past self&lt;br&gt;
force-dynamic is your friend on Cloudflare for DB-backed, on-demand content — don't fight the edge cache.&lt;br&gt;
Set el.dir for RTL languages the moment you add Arabic/Hebrew; it's a one-liner that's easy to skip.&lt;br&gt;
Centralize SEO metadata. A single buildMetadata() beats copy-pasting OG tags per page and prevents the "all shares show the homepage title" bug.&lt;br&gt;
301 your canonical domain early — fixing it after indexing is annoying.&lt;br&gt;
Keep secrets in wrangler.toml / env vars, never in blog posts or client bundles.&lt;br&gt;
Try it&lt;br&gt;
The site is live at wangwangdog.org. If you've shipped a Next.js app to Cloudflare (or fought with i18n), I'd love to hear how you approached it in the comments.&lt;/p&gt;

&lt;p&gt;Happy to share more code snippets or the full wrangler.toml structure if there's interest.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>cloudflare</category>
      <category>webdev</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
