Twenty One Media
webJune 2, 2026

Keep Your JSON-LD in Server Components

When we rebuilt the local SEO layer for our site, one constraint shaped every schema decision: JSON-LD must be in the initial HTML response, not injected after hydration.

Why this matters

Googlebot renders JavaScript, but not always on first crawl. Bingbot is more conservative. If your structured data lives in a useEffect or inside a component that only runs on the client, there's a real window where crawlers see your page with no schema at all.

In Next.js App Router, the failure mode is subtle. You can mark a component "use client" and it will still SSR its output: the initial HTML includes the markup. That's fine for semantic HTML like <address> tags. But if you put your JSON-LD inside a useEffect (easy to do accidentally), you're back to client-side injection. The server render sends an empty <script> tag.

The safest approach: keep every <script type="application/ld+json"> block in a component with no "use client" directive at all.

Our setup

We have two components that emit structured data. Both are pure server components.

JsonLd.tsx is as thin as possible:

export function JsonLd({ data }: { data: unknown }) {
  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
    />
  );
}

No React hooks. No client boundary. Just a server-rendered <script> tag baked into the initial HTML payload.

LocalBusinessSchema.tsx builds the ProfessionalService schema and passes it to JsonLd:

// No "use client" — JSON-LD must be in initial HTML so Bingbot/crawlers see it
export function LocalBusinessSchema({ city }: Props) {
  const data = {
    "@context": "https://schema.org",
    "@type": "ProfessionalService",
    "@id": `${localBusiness.url}/#business`,
    areaServed: city ? [city] : cityServiceArea,
    // ...
  };
  return <JsonLd data={data} />;
}

This renders in layout.tsx, before any client boundary. Every page gets the schema in its HTML before any JavaScript runs. The @id is stable across all pages: https://twenty1-media.com/#business. That tells Google's Knowledge Graph these pages all describe the same entity, not six separate businesses.

The footer split

Our footer is "use client" because of interactive elements. It still renders an <address> block with our NAP data:

<address className="mt-4 text-sm not-italic">
  {localBusiness.name}
  <br />
  Serving {localBusiness.region}
  <br />
  <a href={`tel:${localBusiness.telephone}`}>{localBusiness.telephone}</a>
</address>

This is fine. Semantic HTML in a client component still SSRs and is visible to crawlers. But we didn't put JSON-LD here. The structured data stays in server components where it can't accidentally get moved into a useEffect. The footer reads from the same local.ts source of truth as the schema, so the NAP is always consistent, it's just in two different formats: readable HTML for humans, structured data for crawlers.

City and service pages

Both CityLanding and ServiceHub are server components. Each renders its own FAQPage schema via a shared faqPageSchema() builder in lib/jsonLd.ts. Because they're server components, the <script type="application/ld+json"> block is in the HTML Bingbot or Googlebot receives on first crawl, no JavaScript execution required.

// CityLanding.tsx and ServiceHub.tsx both end with:
<JsonLd data={faqPageSchema(faqs)} />

One function, two component types, consistent schema output.

The rule we landed on

If it's structured data, it lives in a server component. If it's interactive UI, it can be a client component. These two sets don't have to overlap, and keeping them separate means there's no way a refactor accidentally moves schema markup behind a hydration boundary.

Most local SEO guides tell you to add JSON-LD and move on. In a Next.js App Router project, where you choose matters as much as what you emit.

If you need local SEO architecture like this for your service business site, start with a free consultation.