fix: render JSON-LD in static HTML by splitting layout into server/cl…#42
fix: render JSON-LD in static HTML by splitting layout into server/cl…#42nityatimalsina merged 1 commit intomainfrom
Conversation
…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.
There was a problem hiding this comment.
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.tsxinto a Server Component, inline site-level WebSite JSON-LD in<head>, and delegate client-only layout concerns to a newDocsClientLayout. - Move per-page JSON-LD emission to the MDX page (
page.tsx) and refactor schema generation viabuildDocsSchema. - Make client boundaries explicit (
Navbar.tsx) and prevent server prerender errors (Calcom.tsxviadynamic(..., { 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.
| __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'), |
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
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.
…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:
layout.tsx → Server Component (no 'use client')
DocsClientLayout.tsx → new 'use client' component
page.tsx → renders DocsAutoJsonLd before the MDX Wrapper
tags — one for WebSite (from layout head) and one for TechArticle/HowTo (from page)
Navbar.tsx → add 'use client'
Calcom.tsx → switch to dynamic import with ssr: false
DocsAutoJsonLd.tsx → extract buildDocsSchema() helper
mdx-components.ts → remove DocsAutoJsonLd from wrapper
Verified: next build produces 17/17 static pages; each .html file contains two raw <script type="application/ld+json"> tags confirmed via grep.