Skip to content

fix: render JSON-LD in static HTML by splitting layout into server/cl…#42

Merged
nityatimalsina merged 1 commit intomainfrom
fix/biome-formatting
Mar 10, 2026
Merged

fix: render JSON-LD in static HTML by splitting layout into server/cl…#42
nityatimalsina merged 1 commit intomainfrom
fix/biome-formatting

Conversation

@nityatimalsina
Copy link
Contributor

…ient components

Previously, layout.tsx had 'use client' which caused all JSON-LD <script> tags rendered inside {children} to land in RSC flight data rather than raw HTML. Search engines parse <script type="application/ld+json"> from raw HTML only.

Root cause: Next.js serializes the output of Server Components passed as {children} into a client component as RSC payload — not inline HTML. So any <script> tag rendered inside a 'use client' layout's body never appears in the static .html file as a real <script> tag.

Fix — split layout into two layers:

  1. layout.tsx → Server Component (no 'use client')

    • Renders , , shell
    • Inlines the site-level WebSite JSON-LD schema directly in via a plain <script type="application/ld+json"> tag — guaranteed raw HTML
    • Replaces styled-jsx (<style jsx global>) with dangerouslySetInnerHTML on a plain <style> tag (styled-jsx is client-only; breaks server components)
    • Calls getPageMap() and passes result as a prop to DocsClientLayout
    • Removes the eslint-disable for no-async-client-component (no longer needed)
  2. DocsClientLayout.tsx → new 'use client' component

    • Wraps Providers (QueryClientProvider from @tanstack/react-query), the nextra Layout, Navbar, Footer, Version, SocialIcons, and Canonical
    • These components require client-side hooks; they cannot be server-rendered directly, but wrapping them here keeps the outer layout as a server component
    • Receives pageMap (pre-fetched server-side) and children as props
  3. page.tsx → renders DocsAutoJsonLd before the MDX Wrapper

    • page.tsx is a Server Component; its direct JSX output renders as raw HTML when it is a child of a Server Component layout (not inside a client boundary)
    • DocsAutoJsonLd emits a per-page TechArticle or HowTo schema based on filePath
    • Result: each static .html file now contains two <script type="application/ld+json">
      tags — one for WebSite (from layout head) and one for TechArticle/HowTo (from page)
  4. Navbar.tsx → add 'use client'

    • Uses useEffect and window (scroll listener) — must be a client component
    • Was implicitly client-side before because the whole layout was 'use client'
    • Now needs the directive explicitly since layout is a server component
  5. Calcom.tsx → switch to dynamic import with ssr: false

    • @calcom/embed-react calls useQuery internally
    • With the layout no longer 'use client', Next.js static export tried to prerender this component server-side and threw: 'Attempted to call useQuery() from the server'
    • dynamic(..., { ssr: false }) ensures it only ever runs client-side
  6. DocsAutoJsonLd.tsx → extract buildDocsSchema() helper

    • Schema-building logic extracted to a named export so it can be reused independently of the React component if needed in future
    • DocsAutoJsonLd component now delegates to buildDocsSchema internally
  7. mdx-components.ts → remove DocsAutoJsonLd from wrapper

    • JSON-LD is now rendered in page.tsx (correct server component context)
    • Removes duplicate rendering that would have produced two per-page schemas
    • Fixes description type: string | null (Nextra's $NextraMetadata allows null)

Verified: next build produces 17/17 static pages; each .html file contains two raw <script type="application/ld+json"> tags confirmed via grep.

…ient components

Previously, layout.tsx had 'use client' which caused all JSON-LD <script> tags
rendered inside {children} to land in RSC flight data rather than raw HTML.
Search engines parse <script type="application/ld+json"> from raw HTML only.

Root cause: Next.js serializes the output of Server Components passed as
{children} into a client component as RSC payload — not inline HTML. So any
<script> tag rendered inside a 'use client' layout's body never appears in the
static .html file as a real <script> tag.

Fix — split layout into two layers:

1. layout.tsx → Server Component (no 'use client')
   - Renders <html>, <head>, <body> shell
   - Inlines the site-level WebSite JSON-LD schema directly in <head> via a
     plain <script type="application/ld+json"> tag — guaranteed raw HTML
   - Replaces styled-jsx (<style jsx global>) with dangerouslySetInnerHTML
     on a plain <style> tag (styled-jsx is client-only; breaks server components)
   - Calls getPageMap() and passes result as a prop to DocsClientLayout
   - Removes the eslint-disable for no-async-client-component (no longer needed)

2. DocsClientLayout.tsx → new 'use client' component
   - Wraps Providers (QueryClientProvider from @tanstack/react-query), the
     nextra Layout, Navbar, Footer, Version, SocialIcons, and Canonical
   - These components require client-side hooks; they cannot be server-rendered
     directly, but wrapping them here keeps the outer layout as a server component
   - Receives pageMap (pre-fetched server-side) and children as props

3. page.tsx → renders DocsAutoJsonLd before the MDX Wrapper
   - page.tsx is a Server Component; its direct JSX output renders as raw HTML
     when it is a child of a Server Component layout (not inside a client boundary)
   - DocsAutoJsonLd emits a per-page TechArticle or HowTo schema based on filePath
   - Result: each static .html file now contains two <script type="application/ld+json">
     tags — one for WebSite (from layout head) and one for TechArticle/HowTo (from page)

4. Navbar.tsx → add 'use client'
   - Uses useEffect and window (scroll listener) — must be a client component
   - Was implicitly client-side before because the whole layout was 'use client'
   - Now needs the directive explicitly since layout is a server component

5. Calcom.tsx → switch to dynamic import with ssr: false
   - @calcom/embed-react calls useQuery internally
   - With the layout no longer 'use client', Next.js static export tried to
     prerender this component server-side and threw:
     'Attempted to call useQuery() from the server'
   - dynamic(..., { ssr: false }) ensures it only ever runs client-side

6. DocsAutoJsonLd.tsx → extract buildDocsSchema() helper
   - Schema-building logic extracted to a named export so it can be reused
     independently of the React component if needed in future
   - DocsAutoJsonLd component now delegates to buildDocsSchema internally

7. mdx-components.ts → remove DocsAutoJsonLd from wrapper
   - JSON-LD is now rendered in page.tsx (correct server component context)
   - Removes duplicate rendering that would have produced two per-page schemas
   - Fixes description type: string | null (Nextra's $NextraMetadata allows null)

Verified: next build produces 17/17 static pages; each .html file contains
two raw <script type="application/ld+json"> tags confirmed via grep.
Copilot AI review requested due to automatic review settings March 10, 2026 05:16
@nityatimalsina nityatimalsina merged commit b64ea92 into main Mar 10, 2026
2 checks passed
@nityatimalsina nityatimalsina deleted the fix/biome-formatting branch March 10, 2026 05:16
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Splits the docs layout into a Server Component shell plus a nested Client Component layout to ensure JSON-LD <script type="application/ld+json"> renders into the static HTML output (rather than only in RSC flight data), improving SEO for statically exported docs pages.

Changes:

  • Convert src/app/layout.tsx into a Server Component, inline site-level WebSite JSON-LD in <head>, and delegate client-only layout concerns to a new DocsClientLayout.
  • Move per-page JSON-LD emission to the MDX page (page.tsx) and refactor schema generation via buildDocsSchema.
  • Make client boundaries explicit (Navbar.tsx) and prevent server prerender errors (Calcom.tsx via dynamic(..., { ssr: false })).

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/app/layout.tsx Server layout shell; injects site-level JSON-LD in head; passes pageMap into client layout.
src/components/lightpanda/DocsClientLayout.tsx New client wrapper hosting Providers + Nextra Layout and shared UI.
src/app/[[...mdxPath]]/page.tsx Renders DocsAutoJsonLd in a Server Component context (raw HTML).
src/components/lightpanda/DocsAutoJsonLd.tsx Extracts buildDocsSchema() helper and keeps component as thin renderer.
src/mdx-components.ts Removes JSON-LD injection from MDX wrapper; updates metadata typing for description.
src/components/lightpanda/Navbar.tsx Adds explicit 'use client' directive.
src/components/lightpanda/Calcom.tsx Switches to next/dynamic with ssr: false to avoid server-side hook usage.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +55 to +71
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'Lightpanda Documentation',
url: 'https://lightpanda.io/docs',
description: 'Official documentation for Lightpanda headless browser — installation, quickstart guides, API reference, and cloud deployment.',
about: { '@id': 'https://lightpanda.io/#software' },
publisher: {
'@type': 'Organization',
name: 'Lightpanda',
url: 'https://lightpanda.io',
logo: {
'@type': 'ImageObject',
url: 'https://cdn.lightpanda.io/website/assets/images/opengraph/og.png',
},
},
}).replace(/</g, '\\u003c'),
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WebSite JSON-LD is hard-coded with URLs, names, and descriptions that appear to duplicate data already centralized in siteDetails (used elsewhere in this PR). Consider sourcing these values from the existing site metadata constants to avoid drift if domains/branding change.

Copilot uses AI. Check for mistakes.
Comment on lines 26 to +29
return ThemeWrapper
// eslint-disable-next-line @typescript-eslint/no-explicit-any
? createElement(ThemeWrapper, { metadata, ...rest } as any, jsonLd, children)
: content
? createElement(ThemeWrapper, { metadata, ...rest } as any, children)
: createElement('div', null, children)
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wrapper no longer injects JSON-LD, but the nearby comment still says it “auto-generates JSON-LD”. This is now misleading and may confuse future edits; update the comment to reflect the current behavior (or remove it) so documentation matches the implementation.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants