DEV Community

Abid Ali
Abid Ali

Posted on

Debugging Open Graph Previews in Next.js: Why Your Links Look Broken on WhatsApp, LinkedIn and X

A few months ago I shipped a Next.js side project, proudly pasted the link into a WhatsApp group, and got... a grey box with the domain name. No image, no title. On LinkedIn it showed a cropped favicon. On X it showed nothing at all.

The page looked great in the browser, so the problem was obviously somewhere I wasn't looking. This post is everything I learned while debugging it, written so you can skip the afternoon I lost.

How link previews actually work

When you paste a URL into WhatsApp, Slack, LinkedIn or X, their servers send a crawler to fetch your page. That crawler:

  1. Makes a plain HTTP GET request (usually without running JavaScript).
  2. Reads the <head> for Open Graph (og:*) and Twitter card (twitter:*) meta tags.
  3. Downloads the og:image, checks its size and type.
  4. Caches the result, sometimes for days.

Every one of those steps is a place where things can quietly go wrong. The key insight: what the crawler sees is not what your browser sees.

Step 1: Set metadata with the Next.js Metadata API

If you're on the App Router, stop hand-writing <meta> tags in a custom head. Use the Metadata API so tags are rendered on the server and land in the initial HTML.

The most important line is metadataBase. Without it, relative image paths end up relative, and many crawlers simply ignore relative og:image URLs.

// app/layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  metadataBase: new URL("https://example.com"),
  title: {
    default: "My Project",
    template: "%s | My Project",
  },
  description: "A short, human description of what this site does.",
  openGraph: {
    type: "website",
    siteName: "My Project",
    locale: "en_US",
    images: [
      {
        url: "/og/default.png", // resolved against metadataBase
        width: 1200,
        height: 630,
        alt: "My Project preview",
      },
    ],
  },
  twitter: {
    card: "summary_large_image",
  },
};
Enter fullscreen mode Exit fullscreen mode

For dynamic pages like blog posts, use generateMetadata so each page gets its own title, description and image:

// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { getPost } from "@/lib/posts";

type Props = { params: Promise<{ slug: string }> };

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) return { title: "Post not found" };

  return {
    title: post.title,
    description: post.excerpt,
    alternates: { canonical: `/blog/${slug}` },
    openGraph: {
      type: "article",
      url: `/blog/${slug}`,
      title: post.title,
      description: post.excerpt,
      publishedTime: post.publishedAt,
      images: [{ url: post.coverImage, width: 1200, height: 630 }],
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

One gotcha that bit me: in Next.js, a nested openGraph object replaces the parent's openGraph instead of deep-merging. If your page defines openGraph.title but no images, the layout's default image disappears. Either repeat the image or spread a shared object:

// lib/seo.ts
export const sharedOpenGraph = {
  siteName: "My Project",
  images: [{ url: "/og/default.png", width: 1200, height: 630 }],
};
Enter fullscreen mode Exit fullscreen mode
openGraph: { ...sharedOpenGraph, title: post.title }
Enter fullscreen mode Exit fullscreen mode

Step 2: Get the image right

Most "broken" previews I've debugged come down to the image. A checklist that works across platforms:

  • Size: 1200×630 px (1.91:1). This is the safe default for Facebook, LinkedIn, WhatsApp and X's large card.
  • File size: keep it under ~300 KB. WhatsApp in particular gets flaky with heavy images. 5 MB is the hard limit for X.
  • Format: PNG or JPEG. WebP is improving, but SVG won't work anywhere.
  • Absolute HTTPS URL: https://example.com/og.png, not /og.png.
  • Publicly reachable: no auth, no hotlink protection, no bot-blocking firewall rules on the image path.
  • Keep text centered: some platforms crop to a square thumbnail on small screens.

Next.js can also generate images on the fly with opengraph-image.tsx. This was a big win for me because every blog post now gets a unique card with zero design work:

// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
import { getPost } from "@/lib/posts";

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

export default async function Image({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);

  return new ImageResponse(
    (
      <div
        style={{
          width: "100%",
          height: "100%",
          display: "flex",
          flexDirection: "column",
          justifyContent: "center",
          padding: 80,
          background: "#0f172a",
          color: "white",
        }}
      >
        <div style={{ fontSize: 64, fontWeight: 700 }}>{post?.title}</div>
        <div style={{ fontSize: 28, marginTop: 24, opacity: 0.7 }}>
          example.com/blog
        </div>
      </div>
    ),
    size
  );
}
Enter fullscreen mode Exit fullscreen mode

When this file exists, Next.js adds the og:image, og:image:width and og:image:height tags for you.

Step 3: Check what the crawler actually receives

Don't trust DevTools. The browser runs your JavaScript, the crawler usually doesn't. Fetch the raw HTML the same way a bot would:

curl -sL -A "facebookexternalhit/1.1" https://example.com/blog/my-post \
  | grep -Ei '<meta (property|name)="(og|twitter):'
Enter fullscreen mode Exit fullscreen mode

If that returns nothing, your tags are being injected client-side (common with older react-helmet setups or tags set inside useEffect). Move them to the Metadata API.

Also check the image directly:

curl -sI https://example.com/og/default.png
Enter fullscreen mode Exit fullscreen mode

You want a 200, a content-type: image/png (or jpeg), and a reasonable content-length. A 403 here usually means a WAF or bot protection rule is blocking crawlers.

Doing this for every platform gets tedious, so I ended up building OG Scope, a free Open Graph checker that previews how a link renders on WhatsApp, LinkedIn and X. It fetches the raw HTML the way a crawler does, lists the tags it found, and flags common problems like relative image URLs or missing dimensions. Use it, or curl, or the official debuggers. Just don't rely on what your browser shows you.

Step 4: Deal with caching

So you fixed everything and the preview is still broken. Welcome to caching.

  • Facebook / WhatsApp: use the Meta Sharing Debugger and hit "Scrape Again". WhatsApp shares Meta's cache, but the app itself also caches previews locally per chat.
  • LinkedIn: paste the URL into the LinkedIn Post Inspector. This forces a re-fetch.
  • X: there's no public validator anymore. Re-posting the link usually picks up changes after a while.
  • Slack / Discord: they cache aggressively. Adding a query string forces a fresh fetch.

The query string trick works almost everywhere, because the cache key is the full URL:

https://example.com/blog/my-post?v=2
Enter fullscreen mode Exit fullscreen mode

To make that safe, set a canonical URL (via alternates.canonical as shown above) so search engines don't treat ?v=2 as a duplicate page.

If you swap the image but keep the same filename, crawlers may serve the old one from their CDN cache. Version the image URL instead:

images: [{ url: `/og/default.png?v=${process.env.NEXT_PUBLIC_BUILD_ID}` }]
Enter fullscreen mode Exit fullscreen mode

Step 5: The usual suspects (quick reference)

Symptom Likely cause
No preview at all Tags rendered client-side, or the page returns non-200 to bots
Title shows but no image Relative og:image, image blocked, or too large
Wrong image Old cache, or nested openGraph overrode the layout default
Image cropped badly Not 1.91:1, or important content near the edges
Works on Facebook, not X Missing twitter:card (X falls back to OG, but needs the card type)
Preview on staging, not production metadataBase still pointing at localhost or a preview URL

That last one is sneakier than it looks. On Vercel, I set metadataBase from an environment variable so preview deployments don't publish production URLs and vice versa:

const baseUrl =
  process.env.NEXT_PUBLIC_SITE_URL ??
  (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "http://localhost:3000");

export const metadata: Metadata = { metadataBase: new URL(baseUrl) };
Enter fullscreen mode Exit fullscreen mode

Wrapping up

Broken link previews feel random, but they almost always come from one of a few causes: tags the crawler can't see, an image it can't use, or a cache that hasn't caught up. Render metadata on the server, use absolute 1200×630 images, check the raw HTML, and bust the cache when you change things.

Before I share anything now, I run it through an Open Graph preview checker once. It takes ten seconds and saves the "why does our link look like that?" message in the team chat.

What's the weirdest preview bug you've hit? Let me know in the comments.

Top comments (0)