DEV Community

Takuya Morimoto
Takuya Morimoto

Posted on

How I Built 19 Per-Topic OG Images with Japanese Fonts at Build Time (Next.js + Satori)

The Problem

Our team runs bitcoin.ne.jp — a free, bilingual Bitcoin education library with 19 deep-dive topics. Every learn topic was sharing the same generic Open Graph image. Click-through-rate on social shares was flat, and the SERP previews looked interchangeable.

I wanted one OG image per topic, with the topic title baked in, in Japanese — but the site is statically exported (output: 'export' on Next.js 16 and Cloudflare Pages). No runtime image generation. No Vercel Edge function. Just static files at build time.

This is the story of how I got there with Next.js's file-convention opengraph-image.tsx + Satori (via next/og) + a Japanese font loaded from node_modules at build time, and the two non-obvious gotchas that ate an afternoon.

The Constraint

output: 'export'   // Static. Truly static.
Enter fullscreen mode Exit fullscreen mode

The usual @vercel/og runtime route is out. But Next.js's opengraph-image.tsx file convention has a quietly-amazing property: when the route is a dynamic segment with generateStaticParams, Next pre-renders every variant at build time and writes a PNG per slug to the output directory.

That's exactly what I needed.

The Skeleton

// src/app/learn/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';
import { LEARN_SLUGS, getLearnTopic } from '@/lib/i18n/learn';

export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';

export function generateStaticParams() {
  return LEARN_SLUGS.map((slug) => ({ slug }));
}

export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const topic = getLearnTopic('ja', slug);
  return new ImageResponse(
    <div style={{ width: '100%', height: '100%', display: 'flex' }}>
      {/* design here */}
    </div>,
    size,
  );
}
Enter fullscreen mode Exit fullscreen mode

generateStaticParams returns the same 19 slugs that the topic page route uses. Build runs ImageResponse 19 times. 19 PNGs land in out/learn/{slug}/opengraph-image.

Gotcha 1: Satori has no Japanese font

Satori (the engine inside @vercel/og) ships with a default font that covers Latin glyphs. The first build with Japanese topic titles like "ビットコインの歴史" rendered tofu ▢▢▢▢▢▢.

Satori's docs are clear: you must pass any non-Latin font explicitly. The font has to be a TTF/OTF/WOFF (not WOFF2 — Satori doesn't support brotli decompression).

I installed the font as a dev dependency:

npm install --save-dev @fontsource/noto-sans-jp
Enter fullscreen mode Exit fullscreen mode

Then loaded it from node_modules at build time using fs/promises:

import { readFile } from 'node:fs/promises';
import { join } from 'node:path';

async function loadFont(weight: 400 | 700) {
  const path = join(
    process.cwd(),
    'node_modules/@fontsource/noto-sans-jp/files',
    `noto-sans-jp-japanese-${weight}-normal.woff`,
  );
  return readFile(path);
}

export default async function Image({ params }) {
  const { slug } = await params;
  const topic = getLearnTopic('ja', slug);
  const [fontRegular, fontBold] = await Promise.all([loadFont(400), loadFont(700)]);

  return new ImageResponse(
    <div style={{ /* ... */ }}>
      {topic?.title}
    </div>,
    {
      ...size,
      fonts: [
        { name: 'Noto Sans JP', data: fontRegular, weight: 400, style: 'normal' },
        { name: 'Noto Sans JP', data: fontBold, weight: 700, style: 'normal' },
      ],
    },
  );
}
Enter fullscreen mode Exit fullscreen mode

Each WOFF is ~1.4 MB, but Satori subsets to only the glyphs actually rendered, so the final PNG is around 30 KB. The font cost is paid once per build, not at runtime.

The result: "Lightning Network入門" (mixing Latin and Japanese) renders cleanly. Mixing scripts works because Noto Sans JP includes both.

Gotcha 2: Satori cannot render the Bitcoin ₿ symbol

I had a small Bitcoin orange circle next to the wordmark, with the ₿ glyph in the middle. After fixing the Japanese font, the circle rendered, but the ₿ inside became tofu.

Why? Even Noto Sans JP doesn't ship with the U+20BF BITCOIN SIGN. Satori, by default, tries to fetch a fallback font for unknown glyphs from a Twitter Emoji CDN — but in a sandboxed build environment it failed:

Failed to load dynamic font for ₿ . Error: Failed to download dynamic font. Status: 400
Enter fullscreen mode Exit fullscreen mode

Three options:

  1. Ship a font that includes ₿ (e.g., a custom symbol font) — heavy.
  2. Replace ₿ with a plain "B" — ugly.
  3. Inline an SVG of the Bitcoin logo — clean.

I went with option 3. Satori supports inline <svg> natively, so I dropped in the actual Bitcoin path:

<svg width={48} height={48} viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
  <path
    fill="#F7931A"
    d="m63.033,39.744c-4.274,17.143-21.637,27.576-38.782,23.301-17.138-4.274-27.571-21.638-23.295-38.78,4.272-17.145,21.635-27.579,38.775-23.305,17.144,4.274,27.576,21.64,23.302,38.784z"
  />
  <path
    fill="#FFF"
    d="m46.103,27.444c0.637-4.258-2.605-6.547-7.038-8.074l1.438-5.768-3.511-0.875-1.4,5.616c-0.923-0.23-1.871-0.447-2.813-0.662l1.41-5.653-3.509-0.875-1.439,5.766c-0.764-0.174-1.514-0.346-2.242-0.527l0.004-0.018-4.842-1.209-0.934,3.75s2.605,0.597,2.55,0.634c1.422,0.355,1.679,1.296,1.636,2.042l-1.638,6.571c0.098,0.025,0.225,0.061,0.365,0.117-0.117-0.029-0.242-0.061-0.371-0.092l-2.296,9.205c-0.174,0.432-0.615,1.08-1.609,0.834,0.035,0.051-2.552-0.637-2.552-0.637l-1.743,4.019,4.569,1.139c0.85,0.213,1.683,0.436,2.503,0.646l-1.453,5.834,3.507,0.875,1.439-5.772c0.958,0.26,1.888,0.5,2.798,0.726l-1.434,5.745,3.511,0.875,1.453-5.823c5.987,1.133,10.489,0.676,12.384-4.739,1.527-4.36-0.076-6.875-3.226-8.515,2.294-0.529,4.022-2.038,4.483-5.155zm-8.022,11.249c-1.085,4.36-8.426,2.003-10.806,1.412l1.928-7.729c2.38,0.594,10.012,1.77,8.878,6.317zm1.086-11.312c-0.99,3.966-7.1,1.951-9.082,1.457l1.748-7.01c1.982,0.494,8.365,1.416,7.334,5.553z"
  />
</svg>
Enter fullscreen mode Exit fullscreen mode

Crisp at any DPI, no font fallback needed.

Cloudflare Pages: serve the file with the right Content-Type

The Next file convention writes the PNG without a .png extension — the path is /learn/history/opengraph-image (no extension). Cloudflare Pages doesn't auto-detect content type from magic bytes; it relies on the URL extension.

Without a hint, the file gets served as application/octet-stream and SNS crawlers ignore it.

Fix it explicitly in public/_headers:

/learn/*/opengraph-image
  Cache-Control: public, max-age=604800, immutable
  Content-Type: image/png
Enter fullscreen mode Exit fullscreen mode

After this, Twitterbot, Facebook, and LINE all happily fetch the per-topic images.

Cleaning up the metadata

The topic page route had explicit openGraph.images pointing to the generic image. The file convention only auto-injects when there's no explicit override. So I removed it:

 openGraph: {
   title,
   description,
   url: `https://bitcoin.ne.jp/learn/${slug}`,
   siteName: 'ビットコイン図書館',
   locale: locale === 'ja' ? 'ja_JP' : 'en_US',
   type: 'article',
-  images: [{ url: '/og-image.png', width: 1200, height: 630 }],
+  // images: auto-injected by opengraph-image.tsx file convention
 },
Enter fullscreen mode Exit fullscreen mode

After the change, the rendered HTML carries the per-topic URL with a hash query for cache busting:

<meta property="og:image" content="https://bitcoin.ne.jp/learn/history/opengraph-image?db640b18..." />
Enter fullscreen mode Exit fullscreen mode

Next adds the hash automatically. CDNs cache it indefinitely; new builds invalidate via the new hash.

What it looks like

Each topic now has a Japanese title in 84px bold Noto Sans JP, the inline-SVG Bitcoin mark, a "学ぶ" kicker, and a "ビットコイン図書館 · 無料 · 日英対応 · 広告なし" tagline along the bottom. 19 unique cards, all baked at build time, all under 35 KB.

Lessons Learned

  1. The opengraph-image.tsx file convention is the static-export sweet spot. Combine it with generateStaticParams and you get one PNG per dynamic route, no runtime needed.
  2. Always pass non-Latin fonts to Satori explicitly. @fontsource/* dev-dependencies make this trivial — load the WOFF, hand it to ImageResponse.
  3. Satori falls back over the network for unknown glyphs. That fallback fails in sandboxed builds. Replace exotic glyphs with inline <svg>.
  4. Cloudflare Pages won't infer Content-Type from magic bytes for extensionless files. Set it in _headers.
  5. Don't dual-source your OG image metadata. If both an explicit openGraph.images and the file convention exist, the explicit one wins. Pick one.

The result

The per-topic OG images launched, the file convention works as advertised, and ETF / regulation / halving topics now stand out individually in social previews.

If you want to see the images in the wild, every topic page on our product has its own card:

bitcoin.ne.jp — ビットコイン図書館 (free educational library)


bitcoin.ne.jp

Top comments (0)