DEV Community

Cover image for Why Every Link You Share Shows Your Homepage Logo
Bishoy Bishai
Bishoy Bishai

Posted on • Originally published at bishoy-bishai.github.io

Why Every Link You Share Shows Your Homepage Logo

Picture this. Someone finds your product. They're excited. They share it on LinkedIn.

The preview that appears: your site's generic homepage logo, the default title "My App — Build Something Great," and a description that reads "The best platform for modern developers."

Not the product they found. Not the image. Not the name. Not the price.

The person they shared it with clicks — or they don't. Usually they don't. Because the preview looked like spam.

That's not a marketing failure. That's a technical failure that happened because somewhere in the codebase, the <head> was treated as a special zone outside the normal component architecture. Someone hardcoded the meta tags in index.html once, and nobody thought to make them dynamic, because "the head" felt different from "the component tree."

It isn't. And the moment you stop treating it as different, a whole category of SEO and social sharing problems disappears.

I've worked on products where fixing the meta tag architecture, taking three days of careful implementation, measurably improved the click-through rate on shared links. Not from any new feature. Not from any marketing campaign. From making the preview show the right thing.


The Mental Model Shift: Two UIs, One Page

Here's the frame that changed everything for me.

Your React application has two user interfaces. The one your human users see — the <body>. And the one your non-human users see — the <head>.

The non-human users are: Googlebot, Bingbot, the LinkedIn link preview crawler, the Twitter card parser, the Slack unfurler, the WhatsApp link expander. Every time someone pastes your URL somewhere, one of these crawlers visits your page, reads the <head>, and uses that data to build a preview. That preview is often the first impression a new user gets of your content — before they even click.

Your <body> UI is beautifully component-driven. Data flows down. Props are explicit. Every product page shows the right product. Every user profile shows the right user.

Your <head> UI, in most codebases I've seen, is one of three things:

  1. Hardcoded in index.html — same title, same description, same og:image for every single page
  2. Managed by a library that feels complexreact-helmet, set up once, then inconsistently maintained
  3. Done correctly in some pages and forgotten in others — the product of three different developers implementing it differently across six months

None of these are what you'd accept for your <body> UI. So why accept it for the <head>?

The answer is simple: <head> is just a React component. Feed it data. Render it conditionally. Test it. Treat it like any other piece of your component tree.


Why Static Meta Tags Are a Product Bug

Let me be specific about what goes wrong

Scenario 1 — The Social Share Problem

Your e-commerce site has 5,000 product pages. Every product has a unique name, unique description, unique image. You have one og:image tag in your index.html: your company logo.

Every time anyone shares any product anywhere, the preview shows your logo and your site name. Not the product. Not the price. Not the image that would make someone click.

Your competitors' links preview with the product photo, the name, and the price. Their click-through rate on shared links is significantly higher than yours. You'll never know this from your analytics because you're not tracking it as a separate failure mode.

Scenario 2 — The Search Result Problem

Google uses the <title> and <meta name="description"> to populate search results. If every page has the same title and description, Google has no basis for showing different descriptions per page — it will either generate them from page content (inconsistent) or show the same one for everything (terrible UX).

A user searching for "waterproof hiking boots size 10" sees your result: "My Outdoor Store — The Best Gear for Modern Adventurers." They see your competitor's result: "Men's Waterproof Hiking Boots — Size 10 Available | Free Shipping." They click the competitor.

Scenario 3 — The Duplicate Content Signal

When hundreds of your pages share the same title and description, Google may interpret this as thin content or duplicate content — signals that negatively affect your domain's overall authority. This is a slow, invisible damage to your rankings that compounds over time.

None of these problems are dramatic. They're all slow and invisible. And they all have the same root cause: the <head> was never treated as data-driven.


The Pattern: RouteHeadMeta

The core pattern is simple. Every route that has unique data should have a component responsible for translating that data into head meta tags. That component is just a React component — it receives props, it returns markup.

Let's build it properly, starting with the shared SEO interface that every framework approach will use:

// types/seo.ts
// Define the SEO data shape separately from your domain data.
// Your product has a name. The SEO builder decides how that name
// becomes a page title. Keep the transformation logic in one place.

export interface SEOMeta {
  title: string;
  description: string;
  canonicalUrl: string;
  ogImage?: string;
  ogType?: 'website' | 'article' | 'product';
  noIndex?: boolean;
  structuredData?: Record<string, unknown>;
}

interface Product {
  id: string;
  name: string;
  description: string;
  tagline?: string;
  seoDescription?: string;
  slug: string;
  price: number;
  inStock: boolean;
  images: Array<{ url: string; ogUrl?: string }>;
  category?: { defaultImage?: string };
}

// Builder function — transforms domain data into SEO data.
// Single responsibility: knows how products become meta tags.
// Framework-agnostic — used by all four approaches below.
export function buildProductSEO(product: Product, baseUrl: string): SEOMeta {
  return {
    title: buildPageTitle(product.name, 'YourBrand'),
    description: buildDescription(product),
    canonicalUrl: `${baseUrl}/products/${product.slug}`,
    ogImage: buildOgImage(product, baseUrl),
    ogType: 'product',
    structuredData: {
      '@context': 'https://schema.org',
      '@type': 'Product',
      name: product.name,
      description: product.description,
      image: product.images.map(img => img.url),
      offers: {
        '@type': 'Offer',
        price: product.price,
        priceCurrency: 'USD',
        availability: product.inStock
          ? 'https://schema.org/InStock'
          : 'https://schema.org/OutOfStock',
      },
    },
  };
}

// Title: max 60 chars for search results. Cut at word boundary.
function buildPageTitle(productName: string, brandName: string): string {
  const full = `${productName}${brandName}`;
  if (full.length <= 60) return full;

  const maxNameLen = 60 - ` — ${brandName}`.length;
  const cut = productName.slice(0, maxNameLen).trimEnd();
  const lastSpace = cut.lastIndexOf(' ');
  const clean = lastSpace > 0 ? cut.slice(0, lastSpace) : cut;
  return `${clean}... — ${brandName}`;
}

// Description: lead with the most important detail. Max 155 chars.
function buildDescription(product: Product): string {
  const sources = [
    product.seoDescription,
    product.tagline,
    product.description,
  ];
  const raw = sources.find(s => s && s.trim().length > 0);

  if (!raw) {
    // No description exists — use a generic fallback.
    // In production, this should also trigger a CMS content alert.
    return `Discover ${product.name} at YourBrand. Free shipping on orders over $50.`;
  }

  if (raw.length <= 155) return raw;
  return raw.slice(0, 152).trimEnd() + '...';
}

// og:image: never undefined for a product page.
// Use the first available image source from priority list.
function buildOgImage(product: Product, baseUrl: string): string {
  if (product.images[0]?.ogUrl) return product.images[0].ogUrl;
  if (product.images[0]?.url) return product.images[0].url;
  if (product.category?.defaultImage) return product.category.defaultImage;
  return `${baseUrl}/images/og-product-default.jpg`;
}
Enter fullscreen mode Exit fullscreen mode

Now the framework-specific implementations — all using the same buildProductSEO function.


Approach 1: Next.js App Router (The Current Standard)

In Next.js App Router, the framework handles meta tag injection via generateMetadata. You export a function that returns a metadata object — Next.js puts it in the <head> on the server before the HTML leaves.

// app/products/[slug]/page.tsx

import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { buildProductSEO } from '@/types/seo';
import { getProduct } from '@/lib/api';

interface PageProps {
  params: { slug: string };
}

// generateMetadata runs server-side before the page renders.
// Meta tags are baked into the HTML — Googlebot reads them in Wave 1.
// No JavaScript required. No flash. No client-side injection.
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
  try {
    const product = await getProduct(params.slug);

    if (!product) {
      // Missing product — return minimal metadata, tell Google not to index
      return {
        title: 'Product Not Found — YourBrand',
        robots: { index: false, follow: false },
      };
    }

    const seo = buildProductSEO(product, 'https://yourbrand.com');

    return {
      title: seo.title,
      description: seo.description,
      alternates: { canonical: seo.canonicalUrl },
      openGraph: {
        title: seo.title,
        description: seo.description,
        url: seo.canonicalUrl,
        type: 'website',
        images: seo.ogImage
          ? [{ url: seo.ogImage, width: 1200, height: 630, alt: product.name }]
          : undefined,
      },
      twitter: {
        card: 'summary_large_image',
        title: seo.title,
        description: seo.description,
        images: seo.ogImage ? [seo.ogImage] : undefined,
      },
      robots: seo.noIndex ? { index: false, follow: false } : undefined,
    };
  } catch (error) {
    // Network error or database down — return safe defaults
    console.error('generateMetadata failed:', error);
    return { title: 'YourBrand' };
  }
}

// Next.js deduplicates fetch calls within the same request.
// Both generateMetadata and the page component can call getProduct(params.slug)
// — only ONE network request is made. Trust the framework.
export default async function ProductPage({ params }: PageProps) {
  const product = await getProduct(params.slug);
  if (!product) notFound();

  const seo = buildProductSEO(product, 'https://yourbrand.com');

  return (
    <main>
      {/* Structured data as a script tag in the component —
          generateMetadata handles title/og/twitter, this handles JSON-LD */}
      {seo.structuredData && (
        <script
          type="application/ld+json"
          dangerouslySetInnerHTML={{ __html: JSON.stringify(seo.structuredData) }}
        />
      )}
      <h1>{product.name}</h1>
      <p>{product.description}</p>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Approach 2: Remix (The meta Export)

Remix's meta export is a function at the route level that returns an array of meta descriptors. It runs server-side. Same philosophy, different API.

// routes/products.$slug.tsx

import type { MetaFunction, LoaderFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import { buildProductSEO } from '~/types/seo';

export async function loader({ params }: LoaderFunctionArgs) {
  const product = await getProduct(params.slug!);
  if (!product) throw new Response('Not Found', { status: 404 });
  // One fetch — both meta and component use this same data.
  // No double-fetching. No extra network request.
  return json({ product });
}

export const meta: MetaFunction<typeof loader> = ({ data }) => {
  if (!data?.product) {
    return [
      { title: 'Product Not Found — YourBrand' },
      { name: 'robots', content: 'noindex' },
    ];
  }

  const seo = buildProductSEO(data.product, 'https://yourbrand.com');

  return [
    { title: seo.title },
    { name: 'description', content: seo.description },
    { tagName: 'link', rel: 'canonical', href: seo.canonicalUrl },
    { property: 'og:title', content: seo.title },
    { property: 'og:description', content: seo.description },
    { property: 'og:url', content: seo.canonicalUrl },
    { property: 'og:type', content: seo.ogType ?? 'website' },
    ...(seo.ogImage ? [{ property: 'og:image', content: seo.ogImage }] : []),
    { name: 'twitter:card', content: 'summary_large_image' },
    { name: 'twitter:title', content: seo.title },
    { name: 'twitter:description', content: seo.description },
    ...(seo.ogImage ? [{ name: 'twitter:image', content: seo.ogImage }] : []),
  ];
};

export default function ProductPage() {
  const { product } = useLoaderData<typeof loader>();
  return (
    <main>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Approach 3: Next.js Pages Router

Still fully valid and widely used. Meta tags go via next/head inside the component.

// pages/products/[slug].tsx

import Head from 'next/head';
import type { GetStaticProps, GetStaticPaths } from 'next';
import { buildProductSEO } from '@/types/seo';

interface PageProps {
  product: Product;
}

export const getStaticProps: GetStaticProps<PageProps> = async ({ params }) => {
  const product = await getProduct(params!.slug as string);
  if (!product) return { notFound: true };
  return { props: { product }, revalidate: 3600 };
};

export const getStaticPaths: GetStaticPaths = async () => {
  const slugs = await getAllProductSlugs();
  return {
    paths: slugs.map(slug => ({ params: { slug } })),
    fallback: 'blocking',
  };
};

export default function ProductPage({ product }: PageProps) {
  const seo = buildProductSEO(product, 'https://yourbrand.com');

  return (
    <>
      {/* next/head with SSG/ISR = server-rendered meta tags.
          Same SEO benefit as App Router's generateMetadata. */}
      <Head>
        <title>{seo.title}</title>
        <meta name="description" content={seo.description} />
        <link rel="canonical" href={seo.canonicalUrl} />
        <meta property="og:title" content={seo.title} />
        <meta property="og:description" content={seo.description} />
        <meta property="og:url" content={seo.canonicalUrl} />
        <meta property="og:type" content={seo.ogType ?? 'website'} />
        {seo.ogImage && <meta property="og:image" content={seo.ogImage} />}
        <meta name="twitter:card" content="summary_large_image" />
        <meta name="twitter:title" content={seo.title} />
        <meta name="twitter:description" content={seo.description} />
        {seo.ogImage && <meta name="twitter:image" content={seo.ogImage} />}
        {seo.structuredData && (
          <script
            type="application/ld+json"
            dangerouslySetInnerHTML={{ __html: JSON.stringify(seo.structuredData) }}
          />
        )}
      </Head>
      <main>
        <h1>{product.name}</h1>
        <p>{product.description}</p>
      </main>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Approach 4: Bare React SPA with react-helmet-async

For client-side only apps — internal tools, dashboards, SPAs without SSR. Use react-helmet-async, not react-helmet (the original is no longer maintained).

// npm install react-helmet-async

// src/main.tsx — wrap your entire app
import { HelmetProvider } from 'react-helmet-async';

function App() {
  return (
    <HelmetProvider>
      <Router>
        <Routes>
          <Route path="/products/:slug" element={<ProductPage />} />
        </Routes>
      </Router>
    </HelmetProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode
// src/pages/ProductPage.tsx

import { Helmet } from 'react-helmet-async';
import { useProduct } from '@/hooks/useProduct';
import { buildProductSEO } from '@/types/seo';

export function ProductPage() {
  const { slug } = useParams<{ slug: string }>();
  const { product, loading, error } = useProduct(slug!);

  // Render meta in loading state too — prevents title flash to "undefined"
  if (loading) {
    return (
      <>
        <Helmet><title>Loading...  YourBrand</title></Helmet>
        <ProductSkeleton />
      </>
    );
  }

  if (error || !product) {
    return (
      <>
        <Helmet>
          <title>Product Not Found  YourBrand</title>
          <meta name="robots" content="noindex" />
        </Helmet>
        <NotFoundMessage />
      </>
    );
  }

  const seo = buildProductSEO(product, window.location.origin);

  return (
    <>
      <Helmet>
        <title>{seo.title}</title>
        <meta name="description" content={seo.description} />
        <link rel="canonical" href={seo.canonicalUrl} />
        <meta property="og:title" content={seo.title} />
        <meta property="og:description" content={seo.description} />
        <meta property="og:url" content={seo.canonicalUrl} />
        {seo.ogImage && <meta property="og:image" content={seo.ogImage} />}
        <meta name="twitter:card" content="summary_large_image" />
        <meta name="twitter:title" content={seo.title} />
        <meta name="twitter:description" content={seo.description} />
        {seo.ogImage && <meta name="twitter:image" content={seo.ogImage} />}
      </Helmet>
      <main>
        <h1>{product.name}</h1>
        <p>{product.description}</p>
      </main>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Important caveat: react-helmet-async injects meta tags client-side, after JavaScript runs. For public-facing pages where SEO matters, this means you're back in two-wave indexing territory. For internal tools where SEO is irrelevant — completely fine.


The Uncomfortable Truth About Meta Tags

Here's what most implementation guides don't say:

Meta tags without content strategy are decoration.

You can implement the most elegant, perfectly fallback-handled, SSR-rendered meta tag system in the world. If the content team hasn't written unique descriptions for your 5,000 product pages, your meta description will be auto-generated from page content — and Google will probably generate its own anyway, because yours isn't compelling enough.

The technical implementation is the container. What goes in it is what matters.

Two questions that actually determine whether your meta tags help:

  1. Is the title unique per page, specific, and under 60 characters? If your product names in the database are 80+ characters, you have a content problem that truncation logic only partially solves.

  2. Is the description compelling for a human reading a search result or a social preview? "A great product with amazing features" is a placeholder someone forgot to replace.

The developer's job is to make the system capable of showing the right thing. The content team's job is to ensure the right thing exists. Both jobs are required. Only one of them is in this article.


Real Objections

** "react-helmet-async is fine for my Next.js project, why bother with generateMetadata?"**

If your pages are server-rendered (SSG, ISR, or SSR), react-helmet-async injects meta tags client-side — after React hydrates. This creates a brief window where the server HTML has different meta content than what React inserts, which can cause hydration mismatches. Some crawlers are HTML-only and will miss client-injected meta tags entirely. generateMetadata and next/head with SSR inject server-side. That's the correct behavior for a server-rendered app.

** "Won't both generateMetadata and the page component double-fetch the product?"**

No — Next.js deduplicates fetch calls within the same request. If both generateMetadata and your page component call getProduct(params.slug), only one network request is made. This is by design. Don't try to pass data between generateMetadata and the page component directly — the API doesn't support it. Call the same function from both; trust the deduplication.

"What about ISR — does the meta get stale when the product data changes?"

Yes — ISR regenerates the page including meta tags at the revalidation interval. If a product's price changes between revalidations, the structured data price might be stale for up to revalidate seconds. For price-sensitive data, use cache: 'no-store' on that specific fetch, or shorten the revalidation window. The tradeoff between freshness and performance is real — know where you land on it per page type.


For more


The <head> is a React component. Feed it data. Give it fallbacks. Treat it with the same care you give the rest of your component tree.

The user who shares your product page on LinkedIn deserves a preview that shows the right product. The Googlebot that crawls your page deserves a title that describes what it found. The developer who comes after you deserves a system predictable enough to maintain without fear.

All three of those requirements are met by the same thing: treating the <head> as data.

Simple? Yes. Easy? Only after the fallbacks. Welcome to the craft.


✨ Let's keep the conversation going!

If you found this interesting, I'd love for you to check out more of my work or just drop in to say hello.

✍️ Read more on my blog: bishoy-bishai.github.io

Let's chat on LinkedIn: linkedin.com/in/bishoybishai

📘 Curious about AI?: You can also check out my book: Surrounded by AI


Top comments (0)