Skip to content
3 min read

Content inside a modal is invisible to Google

A dialog only mounts its children when it opens, so everything written inside one is missing from the HTML a crawler reads. Here is how to find it and what to do instead.

Next.jsSEOReactServer components

A portfolio I was working on had twenty seven projects, and every one of them had a proper write up: an overview, a feature breakdown, the stack, the numbers. Thousands of words of real content. None of it was indexed. Not one page ranked for any of it.

The reason turned out to be a single design decision made a year earlier. Clicking a project card opened a dialog, and the write up lived inside that dialog.

Why the dialog hides it

Radix, Headless UI, and most other dialog primitives render nothing until the dialog opens. That is the correct behaviour for a component library. It keeps the DOM small and avoids paying for markup nobody is looking at. But it means the children of a closed dialog never reach the server rendered HTML.

A crawler requests the page, receives the HTML, and sees a grid of cards with a title and a one line summary each. The rest does not exist as far as it is concerned. Google can execute JavaScript, but it will not click your cards, and the AI crawlers that matter for answer engines usually do not run JavaScript at all.

How to check your own site

Do not open devtools and look at the DOM. By then React has already hydrated and everything looks fine. Ask the server for the raw document instead.

curl -s https://yoursite.com/ > page.html
grep -c "a distinctive sentence from inside the dialog" page.html

If that returns zero, the content is not in your HTML. Run the same check against a phrase you know is visible on the page, so you can tell a missing string apart from a bad grep.

The fix is a URL, not a rendering trick

You could force the dialog to render its children while closed and hide them with CSS. Do not. It bloats every page with markup for content nobody asked for, and hidden text carries less weight anyway.

Give the content its own route. On that portfolio each project became a page at /work/[slug], prerendered at build time from the same data the dialog used:

export function generateStaticParams() {
  return projects.map((project) => ({ slug: slugify(project.title) }));
}

export async function generateMetadata({ params }) {
  const { slug } = await params;
  const project = projects.find((p) => slugify(p.title) === slug);
  if (!project) return {};

  return {
    title: project.title,
    description: project.description,
    alternates: { canonical: `/work/${slug}` },
  };
}

That one change turned a single indexable URL into twenty eight. Each page carries its own title, its own description, its own canonical, and one H1 that names the project. The dialog can stay on the homepage as a quick preview. It is no longer the only way to read the content.

What to take from this

  • If content is worth writing, it is worth a URL. Anything reachable only through a click has no address to rank, and no way for anyone to share it.
  • Test the document your server sends, not the DOM your browser ends up with. They are different, and the difference is exactly what search engines see.
  • Prerender where you can. A static page removes the question of whether a crawler waited long enough for your data.
  • Check the sitemap after any restructure. New routes are worthless if nothing points at them.

The writing had been there the whole time. It just had nowhere to live.