Skip to main content

Generating Dynamic Bilingual Open Graph Images with Next.js

Lessons from 13 iterations designing a locale-aware OG image readable on LinkedIn, using next/og and the Edge Runtime.

next.jsseoopen-graphi18nedge-runtime

When we launched the qaryon.io site, the Open Graph image was a basic static composition: a purple gradient, the brand name, a tagline. Rendered server-side with no custom fonts, it looked blurry and generic in LinkedIn previews. Thirteen commits later, we had a dynamic, bilingual image, perfectly readable as a thumbnail, generated on-the-fly on the Edge Runtime. Here is what we learned.

Why OG images matter for a telecom integrator

In the B2B unified communications ecosystem, LinkedIn is the primary acquisition channel. When a solutions architect shares a link to your site, the OG image is the first thing their peers see. A blurry image with illegible text at 300x157 pixels communicates a lack of technical rigor — the exact opposite of what a consulting firm wants to project.

Social platforms systematically resize the 1200x630 OG image into thumbnails of varying sizes. Text that looks fine at full resolution often becomes unreadable once compressed. This problem is amplified in a bilingual context: French and English content differ in length, and a layout that works for "Architecte Solutions." may overflow with "Solution Architect.".

The initial architecture: next/og and its constraints

Next.js exposes the next/og API (built on Satori) to generate images via a React component rendered as SVG then converted to PNG. The opengraph-image.tsx file convention in a route segment is all you need — Next.js automatically generates the <meta property="og:image"> tags.

Our first version relied on the default sans-serif:

// src/app/opengraph-image.tsx — Version 1 (root-level, no locale)
export default async function Image() {
  return new ImageResponse(
    <div style={{
      background: "linear-gradient(135deg, #1a1033 0%, #2d1b4e 50%, #1a1033 100%)",
      width: "100%", height: "100%",
      display: "flex", flexDirection: "column",
      alignItems: "center", justifyContent: "center",
      fontFamily: "sans-serif",
    }}>
      <div style={{ fontSize: 48, fontWeight: 300, letterSpacing: "8px", color: "#ffffff" }}>
        qaryon
      </div>
      <div style={{ fontSize: 36, fontWeight: 700, color: "#ffffff" }}>
        Expert Télécom.
      </div>
      <div style={{ fontSize: 36, fontWeight: 700, color: "#a855f7" }}>
        Solutions Concrètes.
      </div>
    </div>,
    { width: 1200, height: 630 }
  )
}

Three immediate problems:

  1. No custom fonts — Satori falls back to a system font that doesn't match the brand identity (Raleway for the logo, Inter for headings).
  2. File at the wrong level — Placed in src/app/, the image didn't inherit from the [locale] segment and couldn't adapt text for FR/EN.
  3. Text too small — At 36px, the tagline vanished in LinkedIn thumbnails.

Loading fonts on the Edge Runtime

The main constraint of next/og on the Edge Runtime: no filesystem access. Fonts must be fetched from a public URL. We opted for Google Fonts CDN, loading variants in parallel:

export const runtime = "edge"

const ralewayLight = fetch(
  "https://fonts.gstatic.com/s/raleway/v37/1Ptxg8zYS_SKggPN4iEgvnHyvveLxVuEooCP.ttf"
).then((res) => res.arrayBuffer())

const interBold = fetch(
  "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYMZg.ttf"
).then((res) => res.arrayBuffer())

const [ralewayData, interBoldData] = await Promise.all([ralewayLight, interBold])

Note: Google Fonts URLs point to specific .ttf files. Satori does not support WOFF2 or variable fonts. You need to find the exact URL for the desired weight — in our case Raleway 300 (Light) and Inter 700 (Bold).

Moving the image into the [locale] segment

The first critical refactor was migrating src/app/opengraph-image.tsx to src/app/[locale]/opengraph-image.tsx. This move has two effects:

  1. Access to the locale parameter — the component receives params with the active locale.
  2. OG tag emitted correctly — Next.js only generates the <meta property="og:image"> tag when the file is in the same segment (or a parent) as the page.
type Props = {
  params: Promise<{ locale: string }>
}

const copy = {
  fr: { headline: ["Architecte", "Solutions."], cta: "Discutons de votre projet" },
  en: { headline: ["Solution", "Architect."], cta: "Let's discuss your project" },
} as const

export default async function Image({ params }: Props) {
  const { locale } = await params
  const { headline, cta } = copy[locale as keyof typeof copy] ?? copy.fr
  // ...
}

A note for Next.js 15+ projects: params is a Promise. Forgetting to await it causes a silent error where locale is undefined and the fallback copy.fr masks the bug.

Iterating toward readability: from 52px to 108px

This was the most instructive part of the project. Here is how the headline size evolved across commits:

| Iteration | Title size | Elements | Thumbnail result | |-----------|-----------|----------|-----------------| | 1 | 52px | Logo + title + subtitle + domain | Text unreadable | | 2 | 72px | Logo + title + CTA + services | Title OK, rest unreadable | | 3 | 88px | Logo + title + CTA | Title readable, CTA borderline | | 4 (final) | 108px | Logo + 2-line title + CTA | Everything readable |

The lesson: for OG images, start from thumbnail size and work backwards. LinkedIn displays these images at roughly 520x272 pixels in the feed. At that resolution, text below 80px is barely legible. We ended up removing every secondary element (subtitle, services list, domain link, separator) to keep just three blocks: brand name, main headline, and a CTA.

The watermark: a subtle detail that adds depth

One element that brings significant visual depth without compromising readability: a giant decorative "q" in the background.

{/* Large decorative q watermark */}
<div style={{
  position: "absolute",
  right: -40,
  bottom: -80,
  fontSize: 520,
  fontFamily: "Raleway",
  fontWeight: 300,
  color: "rgba(168, 85, 247, 0.06)",
  lineHeight: 1,
  display: "flex",
}}>
  q
</div>

The 6% opacity is the result of multiple trials. At 4% (our first choice), the watermark was invisible after LinkedIn's JPEG compression. At 10%, it interfered with the headline readability. The radial gradient in the background (rgba(168, 85, 247, 0.10) centered at 50% 55%) adds a subtle glow that avoids the flat look of a solid #0c0a12 background.

The Revert trap: when "better" is the enemy of "good"

An anti-pattern we encountered: trying to optimize vertical space usage. We switched justifyContent from center to space-between to spread content across the full height. The result at full resolution looked more spacious, but in thumbnail view the headline and CTA ended up at opposite edges, breaking the visual hierarchy.

The commit fdedaaf Revert "fix(seo): spread OG image content across full height" is our reminder that OG images must be tested in context — in a simulated LinkedIn share — not at full-screen resolution in the browser.

Brand consistency: coloring the "on" suffix

The final refinement was applying the primary color (#a855f7) to the "on" suffix of "qaryon" in the OG image, matching the site's Logo component:

<span style={{
  fontFamily: "Raleway", fontWeight: 300,
  fontSize: 32, letterSpacing: "6px", color: "#ffffff",
  display: "flex",
}}>
  qary<span style={{ color: "#a855f7" }}>on</span>
</span>

This detail ensures the OG image is immediately recognizable as part of the same visual ecosystem as the website. For an integrator who regularly shares technical content, this consistency builds credibility.

Checklist for your own bilingual OG image

If you're building a multilingual Next.js site in a B2B context:

  1. Place opengraph-image.tsx in src/app/[locale]/ to inherit the language parameter.
  2. Load fonts via fetch + Promise.all — no local files on Edge Runtime.
  3. Use only .ttf files — Satori doesn't support WOFF2 or variable fonts.
  4. Target at least 80px for headlines to remain readable in LinkedIn thumbnails (~520px wide).
  5. Limit yourself to 3 visual blocks: identity, main message, call-to-action.
  6. Test as a thumbnail, not at full resolution. Tools like opengraph.xyz or the LinkedIn Post Inspector are essential.
  7. Define a default fallback (?? copy.fr) to prevent crashes on unexpected locales.
  8. Don't forget export const runtime = "edge" — without this line, the component runs in Node.js and font fetches may fail on Vercel production.

Conclusion

Thirteen iterations for a 1200x630 pixel image may seem excessive. But in a B2B telecom context where every LinkedIn share is a business opportunity, OG image quality is a signal of technical competence. The next/og approach with Edge Runtime offers an excellent effort-to-result ratio: a single TypeScript file, no external image generation pipeline, and dynamic rendering that adapts to the visitor's language.

The final code is 154 lines. Every line was earned by removing something unnecessary.