My Sitemap Advertised 11 Pages. The Site Had 56.
I run FastPass, a pay-to-reach tool where someone pays to guarantee a reply to their message. The public site is a Vite and React SPA, and I had done the thing you are supposed to do for SPA SEO: prerender every page to static HTML at build time with @prerenderer, so a crawler gets real content instead of an empty <div id="root">. Blog posts, a set of persona landing pages, and every professional profile in the directory all get snapshotted into HTML during the build.
So I assumed SEO was handled. Then I opened Google Search Console and read the sitemap report. It listed 11 URLs. The site ships around 50 indexable pages.
Two lists that were supposed to be the same
Here is the setup. In vite.config.ts, the prerender plugin takes an explicit list of routes to snapshot:
prerender({
routes: [
'/', '/blog', '/faq',
'/get-paid-to-answer-messages',
'/charge-for-your-time',
'/for-creators',
// ...more landing pages
...(await resolveBlogRoutes()),
...(await resolveDirectoryRoutes()),
],
})
That last line is the important one. resolveDirectoryRoutes() queries the database and returns one route per active directory profile, so the build produces static HTML for the landing pages and for every profile. Dozens of pages, all indexable.
The sitemap was generated by a completely separate script, scripts/generate-sitemap.ts, and it kept its own hardcoded idea of what the site contained:
const staticRoutes = [
{ path: '', priority: '1.0' },
{ path: 'directory', priority: '0.9' },
{ path: 'blog', priority: '0.9' },
{ path: 'faq', priority: '0.7' },
// plus a couple of legal pages
];
Seven static routes, plus the blog posts it fetched from the database. Eleven URLs total. The persona landing pages were not in it. Not a single directory profile was in it. The build was cheerfully generating static HTML that the sitemap never told Google about.
The proof was in Search Console
The tell was one line in the URL inspection. /get-paid-to-answer-messages was already indexed, which means Google had found it some other way (an internal link, most likely). Yet it was absent from the sitemap. Meanwhile the directory profiles, the long-tail pages that should catch searches for a specific person, had never been crawled at all. Google had no path to them: the sitemap did not list them, and the internal links to them sat behind client-side rendering.
I had built the indexable pages, then hidden the map to half of them.
The fix: one source of truth
The real bug was not the missing lines. It was that two systems each maintained their own list of the same thing, and nothing forced them to agree. The prerender step and the sitemap should not be able to disagree about which pages exist.
So I made the sitemap read from the exact helper the prerender step already uses:
import { fetchActiveDirectoryProfiles } from './lib/directory-profiles';
const directoryProfiles = await fetchActiveDirectoryProfiles();
directoryProfiles.forEach((profile) => {
sitemap += ' <url>\n';
sitemap += ` <loc>${baseUrl}/directory/${profile.target_slug}</loc>\n`;
sitemap += ' <changefreq>weekly</changefreq>\n';
sitemap += ' </url>\n';
});
fetchActiveDirectoryProfiles() is the same function resolveDirectoryRoutes() calls at prerender time. Now a new profile lands in the prerendered build and in the sitemap from the same query, so the two cannot drift apart. I also moved the persona landing pages into the static list. The sitemap went from 11 URLs to 56, live.
The takeaway
When two parts of your system both enumerate your routes (the prerenderer and the sitemap, the build and the router), do not maintain two lists. Derive one from the other, or feed both from a shared source. A hardcoded list is not wrong on the day you write it. It goes wrong three features later, quietly, and the only symptom is traffic that never arrives.
The pages that were hiding are live now at FastPass, starting with get paid to answer messages. If you are fighting the broader indie SEO battle, I wrote up what actually worked separately.