DEV Community

Cover image for Two Next.js traps: missing previews and missing server content
RadarMakers
RadarMakers

Posted on

Two Next.js traps: missing previews and missing server content

Social preview crawlers, the ones behind link cards in Slack, LinkedIn or Discord, need usable metadata in the server response, and they may cache what they get. On RadarMakers, a launch platform for indie makers built on the Next.js App Router, we hit two separate problems with that: pages whose cards had no image, and product pages whose server HTML had the right head but no content. Neither one produced an error. This post follows the real code through three stages, and ends with how we test for both.

Trap 1: a child openGraph object replaces the parent's

Next.js merges metadata between segments shallowly. If a layout sets openGraph.images and a child segment defines its own openGraph object, the child's object replaces the parent's as a whole. Whatever the child leaves out, including the image, is gone from the merged result.

Parent layout sets openGraph with images; a child page sets openGraph without images; the child object replaces the parent, so there is no og:image; the fix is to pass the images in the child openGraph

The broad version of this bug, where most of our pages had no default image, is covered in our earlier checklist post. Two routes retained a narrower version of the same problem: images included only under a condition.

The product layout, in an early version, added an image only when the product had a logo:

// app/product/[slug]/layout.tsx, early version, simplified excerpt
openGraph: {
  title: `${p.name} ยท ${p.tagline || "on RadarMakers"}`,
  description,
  url: `/product/${slug}`,
  type: "article",
  ...(p.logo_url ? { images: [{ url: p.logo_url, alt: p.name }] } : {}),
},
Enter fullscreen mode Exit fullscreen mode

A product without a logo got an openGraph object with no image, which replaced the parent's object with its default image. We first added a default image for products without logos, then switched to generated product previews.

Maker profiles had the same shape, with an avatar instead of a logo, and kept it longer. We fixed that on October 26, 2026, in commit b329766: a profile without an avatar now gets a generated image with the maker's name and username, and a profile with an avatar keeps the avatar.

// app/profile/[username]/page.tsx, excerpt
const generated = `/profile-og/${encodeURIComponent(data.profile.username)}?v=${ogVersion({ name, tagline: data.profile.username, logo_url: "" })}`;
const images = data.profile.avatar_url
  ? [{ url: data.profile.avatar_url, alt: name }]
  : [{ url: generated, width: 1200, height: 630, alt: `${name} on RadarMakers` }];
Enter fullscreen mode Exit fullscreen mode

The ?v= part is a version computed from the inputs, here the display name and username. When those change, the image URL changes, which helps avoid a stale cached image once a platform fetches the page metadata again. It does not force any platform to refetch, and it does not change when the same URL starts serving different bytes. Product images use the same idea, with the name, tagline and logo URL as inputs.

Trap 2: the head can be right while the body is still loading

The product page went through three stages.

Stage 1: client page with a generic head and a body that says Loading product. Stage 2: server layout metadata gives a product title and description, an image only when a logo exists, but the body still says Loading product. Stage 3: server page and server metadata, with the product head and the product content in the HTML

Stage 1: a client page. The page was a client component that fetched its data in useEffect and showed a loading state until it arrived. A simplified excerpt:

"use client";

export default function ProductPage({ params }) {
  const [loading, setLoading] = useState(true);
  useEffect(() => {
    // getProductBySlug(slug) and related requests, then setLoading(false)
  }, [slug]);
  if (loading) {
    return <p>Loading product...</p>;
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Client components are still rendered on the server, but this one had nothing to render on the server except its loading state, because its data only arrived in an effect after hydration. A client component also cannot export metadata, so every product shared the site's generic title and a shared link showed no product name.

Stage 2: metadata in a server layout. We added app/product/[slug]/layout.tsx with generateMetadata, which fetches the product on the server. The head now had the product title, description and, when a logo existed, an image. The body of the server HTML still said "Loading product...". A social crawler that only reads the head could now build a card. A crawler or user agent that reads the content without running JavaScript still saw a loading text. Google can execute JavaScript, but we did not want product content to depend on that.

Stage 3: the page fetches on the server. The page became a server component that loads the product and passes it to the interactive client view as an initial prop:

// app/product/[slug]/page.tsx, simplified excerpt; the real file also renders structured data and a team note
export const dynamic = "force-dynamic";

export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const product = await getProductBySlug(slug).catch(() => null);
  if (!product) notFound();
  const [comments, related] = await Promise.all([
    getProductComments(slug).catch(() => [] as Comment[]),
    withinDeadline(getRelatedProducts(slug)).catch(() => []),
  ]);
  return <ProductView slug={slug} initialProduct={product} initialComments={comments ?? []} relatedProducts={related ?? []} />;
}
Enter fullscreen mode Exit fullscreen mode

ProductView keeps everything interactive, such as voting and comment forms, and starts from initialProduct instead of an empty state. When we moved the fetch to the server, we kept the existing fallback for comment failures, and an unknown slug now goes through server side not found handling with notFound() instead of a client error state. Related products were added later with the same kind of fallback.

How we check for both now

A regression test for profile metadata. We load the real page module with a fake API and assert on what generateMetadata returns:

// tests/profile-og.test.mjs, excerpt
test("a profile without an avatar points social cards at its generated image", async () => {
  const m = await meta(profile());
  for (const images of [m.openGraph.images, m.twitter.images]) {
    assert.equal(images.length, 1);
    assert.match(images[0].url, /^\/profile-og\/jane\?v=[a-z0-9]+$/);
    assert.deepEqual([images[0].width, images[0].height], [1200, 630]);
  }
  assert.equal(m.twitter.card, "summary_large_image");
});
Enter fullscreen mode Exit fullscreen mode

Against the old profile module, three of the four tests in that file fail. These tests check the metadata objects our code returns. They do not test how Next.js merges segments, whether the image route renders, or what the product HTML contains, which is what the request checks below are for.

A request per deep link, the way a crawler makes it. Recent Next.js versions can stream metadata for ordinary browsers, so send a crawler user agent, check the status code, and look for the tags inside the head:

curl -s -o page.html -w "%{http_code}\n" -A "Slackbot-LinkExpanding 1.0" https://yoursite.com/some/deep/page
python3 -c "
import re
head = re.search(r'<head.*?</head>', open('page.html').read(), re.S).group(0)
for prop in ('og:title', 'og:image'):
    m = re.search(r'<meta property=\"%s\" content=\"([^\"]*)\"' % prop, head)
    print(prop, m.group(1) if m else 'MISSING')
"
Enter fullscreen mode Exit fullscreen mode

This prints the actual values, so you can check that the title is the product's own and that the image URL is absolute and points where you expect. A missing og:image can come from a conditional like the ones above, but also from a redirect, a failed metadata fetch or an error page, so treat the result as a starting point.

We recommend two more checks. Fetch the image URL that was printed, check that it returns 200 with an image content type, and open the image itself: a fallback route can answer 200 with a generic picture, which is exactly what our profile image route does when rendering fails. And for the body, open the page with JavaScript disabled and check that the main content is real elements, not a loading text.

Takeaways

  • When you override openGraph or twitter, explicitly provide or reuse the image fields you want. A conditional spread that sometimes adds no image is the easiest way to lose one.
  • Metadata in a server layout fixes the head, not the body. If the page only gets its data in useEffect, the server HTML is a loading state.
  • A version in the image URL helps a renamed product or profile get a fresh image when its page is fetched again. It is not a cache purge.
  • Test the metadata your code returns, and separately test the real response for a few deep links.

If you have hit a Next.js metadata problem that is not on this list, I would like to hear about it in the comments.

Top comments (0)