Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 3 min read

Google saw 1 word on my Next.js homepage. A Suspense boundary was hiding everything else.

Last night I ran an SEO audit on Ilytics, the analytics tool I've been building, and it came back with 0 words on every page. I assumed the audit tool was broken. The site looked fine in my browser. So I fetched the hom

Last night I ran an SEO audit on Ilytics, the analytics tool I've been building, and it came back with 0 words on every page.

I assumed the audit tool was broken. The site looked fine in my browser. So I fetched the homepage the way a crawler does, with curl and a Googlebot user agent, and counted the words in the HTML. There was exactly one: "Ilytics", from the page title. No headings, no text, no links to my other pages.

The fix ended up being about twenty lines. It's an easy mistake to make in the App Router, so I'm writing it up.

The provider that wrapped everything

My root layout wraps the whole app in an analytics provider, which is how most PostHog setups for Next.js look:

// app/layout.tsx
<PostHogProvider>
  {children}
</PostHogProvider>

Inside, the provider reads the URL so it can send a pageview whenever the path or query string changes. Because useSearchParams() needs a Suspense boundary, I had wrapped it in one:

"use client";

function PostHogInner({ children }) {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  // ...init, identify, pageview tracking...
  return <PHProvider client={posthog}>{children}</PHProvider>;
}

export function PostHogProvider({ children }) {
  return (
    <Suspense fallback={null}>
      <PostHogInner>{children}</PostHogInner>
    </Suspense>
  );
}

Why that empties the page

When a statically rendered page calls useSearchParams(), Next.js can't know the query string at build time. So everything up to the nearest Suspense boundary gets rendered in the browser instead of on the server, and the server HTML contains the boundary's fallback.

My boundary wrapped {children}, which in a root layout is the entire site. The fallback was null. So every page was pre-rendered as nothing, and JavaScript filled it in after load.

Visitors never noticed because it happens in a fraction of a second. Crawlers that read the server HTML got an empty page.

The fix is to shrink the boundary

Only the part that reads the query string needs to wait for the browser. I moved pageview tracking into its own component, gave it its own Suspense boundary, and rendered children outside of it:

"use client";

function PageviewTracker() {
  const pathname = usePathname();
  const searchParams = useSearchParams();

  useEffect(() => {
    ensurePostHog();
    posthog.capture("$pageview", { $current_url: window.location.href });
  }, [pathname, searchParams]);

  return null;
}

export function PostHogProvider({ children }) {
  // session and identify logic stays here, without useSearchParams
  return (
    <PHProvider client={posthog}>
      {children}
      <Suspense fallback={null}>
        <PageviewTracker />
      </Suspense>
    </PHProvider>
  );
}

The boundary now wraps a component that renders null anyway, so nothing is lost, and the actual pages render on the server again.

One thing broke along the way. React runs a child component's effects before its parent's, so the tracker started calling capture() before the parent had called posthog.init(). I moved the init into a small ensurePostHog() function that both components call, with a flag so it only runs once.

Before and after

I fetched the same pages as Googlebot before and after deploying. Visible words in the HTML:

  • Home: 1 before, 375 after
  • Pricing: 0 before, 211 after
  • FAQ: 0 before, 159 after
  • Docs: 0 before, 138 after

Each page also went from zero to one H1, and from no internal links in the HTML to between 6 and 15.

How to check your own site

Run this against any page and compare the number with what you see in the browser:

curl -s -A "Googlebot" https://your-site.com/ \
  | sed -E 's/<script[^>]*>.*<\/script>//g; s/<[^>]+>/ /g' \
  | wc -w

If it's close to zero, look for useSearchParams() or other client-only hooks inside a Suspense boundary that sits high up in your layout.

Google does render JavaScript eventually, but it's slower and less reliable than reading HTML, and many other crawlers don't run JavaScript at all. I'd rather not depend on it.

What I'd check next time

If you add any tracking or auth provider to a Next.js layout, check where its Suspense boundary sits before you ship. The Next.js docs on useSearchParams explain the client-side rendering behavior in more detail.

It's a little embarrassing that the thing hiding my analytics product from search was an analytics provider. At least it made for a good debugging session.

Arsany

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.