Key takeaways
- PersistGate renders its loading fallback on the server, so nothing inside it reaches the HTML.
- A 200 status, full head tags and a Static build label say nothing about the body.
- Count visible text in the raw HTML, fetched with and without a Googlebot user agent.
- Let public routes skip the gate, and apply stored state only after hydration.
- Escape ‘<’ in JSON-LD built from user-written text before it goes into a script tag.
The public pages of an AI study platform we build and run looked prerendered. They returned 200, the head tags were all there, and in a browser the content appeared after a short loading screen. In the HTML the server sent, though, every route had the same body, a loading screen of about 1 KB with none of the page’s content in it.
The cause was redux-persist’s PersistGate, which wrapped the app and rendered its loading fallback on the server for every route. We replaced it with a gate that lets a list of public routes render straight away, made those routes hydrate against the store’s state from before stored data is applied, and made the catalogue page fully static with no Suspense boundary. The home page went from 1,358 characters of visible text in its server HTML to 13,244.
This post explains why the gate behaves this way and why the usual checks missed it. It then covers the fix, the JSON-LD escaping that server rendering makes necessary, and a quick way to check your own pages.
What the pages looked like from outside
Every signal you’d normally glance at was fine. The status was 200. The head tags were complete. A browser showed the full page a moment after it loaded.
The head and the body disagreed because they come from different places. In the App Router, head tags come from each route’s metadata export or generateMetadata function, and Next.js renders those tags itself[^metadata]. A gate wrapped around the layout’s children never sees them. The body is whatever your component tree renders on the server, and here the tree rendered a loading screen.
The build output doesn’t catch it either. next build marks a route ○ (Static) when it is prerendered at build time[^build]. A route whose HTML is a loading screen still gets that label, because the label records when the HTML was made. Nothing in the build looks at what ended up inside it.
Plenty of readers never run your JavaScript. Google does render pages, but it queues them for rendering first, and its own guide says server-side rendering is still worthwhile because “not all bots can run JavaScript”[^google-js]. Vercel reported in December 2024 that none of the major AI crawlers rendered JavaScript at the time[^vercel]. Link-preview bots in chat apps and social networks read the HTML as sent. Next.js calls these HTML-limited bots, notes that they can’t execute JavaScript, and serves them differently[^metadata][^bots]. To any reader that doesn’t run JavaScript, each public page was the same loading screen.
Why PersistGate renders the loading screen on the server
PersistGate holds back rendering until redux-persist has read stored state, usually from localStorage, back into the Redux store[^readme]. Version 6.0.0 is still the one npm installs by default as of 8 October 2026[^npm], and the logic that decides what it renders is short:
// PersistGate in redux-persist 6.0.0, reduced to what decides the output
class PersistGate extends PureComponent {
state = { bootstrapped: false }
componentDidMount() {
// subscribe to the persistor; set bootstrapped once stored state is back
}
render() {
return this.state.bootstrapped ? this.props.children : this.props.loading
}
}
The gate starts closed, and only componentDidMount can open it. React doesn’t run componentDidMount during server rendering[^react-class]. So the server renders the gate once, closed, and ships loading. In the browser the first render is closed as well, which keeps hydration consistent. Then the component mounts, storage is read and the children appear. Anything that reads the HTML without running scripts stops at the loading screen.
The library’s documentation now says this outright. The PersistGate page on the default branch, written for the version 7 beta, states that on the server the gate renders loading and that what’s inside it isn’t server rendered[^gate-docs]. The same symptom was reported against Next.js’s with-redux-persist example in August 2019[^next-8240].
Why the usual checks didn’t flag it
Every check that runs JavaScript sees a working page. Browsers do. Google’s tools do too. The URL Inspection tool in Search Console shows a page the way Google renders it, with a screenshot in the live test[^inspect]. Google’s renderer clears local storage between page loads[^wrs], so redux-persist finds nothing to restore, the gate opens as soon as that empty read finishes, and the rendered page looks complete.
The check that fails is the plain one: fetch the HTML and read it. The development server renders on the server too, so view-source: on a local page shows the same loading screen.
A gate that lets public routes through
The fix has three parts. The first replaces PersistGate with a small gate of our own. Public routes, the ones crawlers and first-time visitors land on, render their children straight away, on the server and in the browser. Every other route still waits for stored state, as before. A minimal version looks like this:
'use client'
// Public routes render at once; everything else waits for stored state.
import { usePathname } from 'next/navigation'
import { useEffect, useState, type ReactNode } from 'react'
import { persistor } from '@/lib/store' // created with { manualPersist: true }
const PUBLIC_ROUTES = [/^\/$/, /^\/about$/, /^\/pricing$/] // your public pages
export function StoreGate({ children, loading }: { children: ReactNode; loading: ReactNode }) {
const pathname = usePathname()
const [restored, setRestored] = useState(false)
useEffect(() => {
persistor.persist() // start reading storage only after hydration
const check = () => {
if (persistor.getState().bootstrapped) setRestored(true)
}
check()
return persistor.subscribe(check)
}, [])
const isPublic = PUBLIC_ROUTES.some((route) => route.test(pathname))
return isPublic || restored ? children : loading
}
The route check works on the server because a client component that calls usePathname is rendered into HTML on the first page load[^pathname]. The server and the browser have to reach the same answer, or hydration fails. The Next.js docs warn that rewrites can make the pathname differ between the two[^pathname], so keep rewritten paths off the public list.
Keep the list next to your sitemap. A public route missing from the list falls back to the loading screen without any error.
Hydrating against the state before rehydration
The second part is about timing. React needs the first render in the browser to produce the same output the server did[^hydrate]. With the stock gate that was automatic, since both sides rendered the loading screen. Once public routes render their content, the store must hold the same state during hydration that it held on the server. On the server there is no stored data, so that means the store’s initial state.
persistStore normally starts reading storage the moment it’s called, which is usually when the store module loads, before React hydrates anything. If stored data lands first, the browser renders from different state than the server did. For example, a header that shows a signed-in name differs from the signed-out header in the HTML, and React reports a hydration mismatch.
The redux-persist docs describe this case and a way to avoid it[^gate-docs]. Create the persistor with manualPersist: true, an option version 6 already has[^v6-readme], and call persistor.persist() in an effect, which only runs after hydration. The sketch above does that. React-Redux has a second tool for the same job: the serverState prop on <Provider> sets the state used for the hydration render[^serverstate].
There is a cost. Anything on a public page that depends on stored state renders its default first and updates a moment later. That is acceptable for a sign-in link that turns into an avatar. If a page’s main content depends on stored state, the page belongs behind the gate.
A catalogue page with no Suspense boundary
The third change was specific to the catalogue page. A Suspense boundary on a prerendered page marks a place where the HTML is allowed to stop. If the content inside can’t be prerendered, the HTML gets the fallback and the browser renders the rest. Next.js documents the standard case: on a prerendered route, a client component that calls useSearchParams is rendered on the client up to the closest Suspense boundary[^searchparams].
We made the catalogue page fully static with no Suspense boundary, so there is no fallback for the prerender to stop at and the catalogue content is in the HTML. It also makes a later regression loud. If someone adds useSearchParams to a static page without a boundary, the production build fails with an error[^searchparams]. With a boundary in place, the same change would ship a fallback and keep its Static label.
What the server HTML holds now
Visible text in the server HTML, in characters:
| Page | Before | After | Change |
|---|---|---|---|
| FAQ page | 1,047 | 5,116 | 4.9× |
| Home page | 1,358 | 13,244 | 9.8× |
| Catalogue page | 1,055 | 3,083 | 2.9× |
One course page, fetched after the fix with a Googlebot user agent, returned 431,484 characters.
How we measured
Visible text means the characters a reader could see on the page. Markup, script contents and styles don’t count. Every figure comes from the HTML the server returned, with no JavaScript run. The FAQ, home and catalogue pages have figures from before and after the fix. The course page figure is from after the fix only, fetched with Googlebot’s user-agent string.
That is four pages, so read the numbers as a check on what the HTML contains. They say nothing about rankings, traffic or Core Web Vitals, and this post doesn’t report any.
Escape JSON-LD before you inline it
Server rendering puts real content in the HTML, and that includes structured data. On the platform, course titles and descriptions are written by course creators, and JSON-LD built from that text describes courses to search engines. Once it’s in the server HTML, each of those strings is user input sitting inside a <script> element.
JSON.stringify produces valid JSON, but valid JSON isn’t safe inside HTML. The HTML parser ends a script element at the first `
Frequently asked questions
Does redux-persist’s PersistGate work with server-side rendering?
It renders on the server, but only its loading fallback, because stored state loads in the browser. Anything inside the gate is missing from the server HTML. Render public content outside the gate, or use a gate that lets public routes through.
Why does my Next.js page look complete in the browser but empty in view-source?
Something in the component tree renders a placeholder on the server and swaps in the content after JavaScript runs. Loading gates around the whole app, client-only checks such as a mounted flag, and Suspense boundaries whose content can’t be prerendered are the usual causes.
How do I see the HTML Googlebot gets from my site?
Fetch the page with curl using Googlebot’s user-agent string, count the visible text with scripts and styles removed, and compare it with a normal fetch. Sites that verify crawlers may still treat your request as a visitor. URL Inspection in Search Console shows how Google itself rendered the page.
Does Google index content that only appears after JavaScript runs?
Google renders JavaScript before indexing, but pages wait in a render queue first, and Google’s own documentation notes that not all bots can run JavaScript. Content that matters belongs in the server HTML.
How should I escape JSON-LD in a Next.js page?
Serialise it with JSON.stringify, then replace every < with \u003c before inlining it, as the Next.js JSON-LD guide shows. Escaping >, & and the U+2028 and U+2029 line separators as well matches what Next.js does for its own inline data.
Building something like this?
9io is a small team of senior engineers with a fractional CTO, and we work by the hour. Send us a note about your product. The reply comes from the person who'd do the work.