A few months ago, Big JSON Viewer was a Vite single-page app. npm run build produced an HTML shell, a couple JS bundles, and that was it. When Googlebot hit our page, the served HTML was an empty <div id="root"> and a script tag. Google does execute JavaScript when it indexes, but it executes it on a delay, with a smaller compute budget than the first pass, and any content rendered by client-only React is downweighted relative to content that’s in the raw HTML.
We were ranking nowhere for the queries we should have been ranking for. So we migrated to Next.js with App Router. This is the honest write-up of what changed, what helped, and what we still need to do.
The before state
Pre-migration, the app was:
- A Vite-built SPA with client-side routing (using window location pathname directly — no router library).
- Nine locales, all loaded as runtime JavaScript chunks (
en.ts,zh-CN.ts, etc.) and swapped based on a localStorage preference plus Accept-Language fallback. - Per-locale
<title>and meta tags written from JS after hydration via a smalldocument.headpatcher we wrote. - A static
sitemap.xmlgenerated at build time by a custom Vite plugin.
What Googlebot actually saw on its first pass was the empty shell with the default <title> from index.html. After JS executed, the per-locale meta tags arrived, but they were below whatever signal-discount JS-rendered content gets.
What we changed
The Next.js App Router migration kept most of the app wholesale — the canvas viewer, the Dockview workspace, the i18n provider, the share modal — and wrapped it in a thin Next.js shell.
- Routes. The Vite app served one HTML file regardless of URL. In Next.js, each locale gets its own route under
app/[locale]/page.tsxwithgenerateStaticParams. English stays at the bare rootapp/page.tsx. The share routeapp/s/[fileId]/page.tsxis dynamic but explicitly markednoindexbecause the content is user-private. - Metadata. Per-locale title, description, canonical, hreflang alternates, OG, Twitter, robots rules — all produced from a server-side
generateMetadatausing one sharedSEO_METAdictionary. - Sitemap and robots. Moved to Next.js’s built-in
app/sitemap.tsandapp/robots.tsconventions. The sitemap emits hreflang alternates per locale URL. - JSON-LD. Server-rendered
SoftwareApplication,FAQPage,WebSite, andOrganizationstructured data blocks, in the static HTML. - Marketing section. A server component that renders
<h1>,<h2>sections, feature bullets, and FAQ from the same dictionary, hidden visually with standard a11y CSS so it doesn’t disrupt the tool-first UX but stays accessible to assistive tech and crawlers.
What actually moved the needle
The single biggest factor was visible content in the raw HTML. Going from empty <div id="root"> to a page with a real <h1>, three feature <h2>s, and three FAQ pairs — all with the right keywords — moved every page from “thin content” to indexable. Search Console started showing impressions for terms like “large json viewer browser” and “open large json file” that we’d been invisible for.
The per-locale routes also helped more than expected. Each locale at its own URL with its own canonical lets Google rank pages per language independently, rather than treating all locales as the same page with a stylesheet switch. Bing took longer to catch up but eventually discovered the localized routes via the hreflang alternates in the sitemap.
JSON-LD for FAQPage made our FAQ entries eligible for the FAQ rich result in Google. That’s a click-through rate boost, not a ranking boost, but it moves real traffic.
What we expected to help but didn’t
Keywords meta tag. We kept it because it’s harmless, but every change to it has produced exactly zero measurable effect. Google has stated for years that they don’t use it. Believe them.
Static Site Generation vs. Server-Side Rendering. Our pages don’t need server rendering — the content is the same for every visitor — so we went full SSG with generateStaticParams. We initially thought there might be a difference in how Google handles SSG output vs SSR output. There isn’t. What matters is whether the bytes that arrive on the wire have real content; whether they came from a build step or a request handler is invisible.
Open Graph image. We made a 1200×630 OG card and added it. Social sharing now produces nice preview cards. The number of social shares this drove was approximately zero; we’re a niche developer tool, not a viral product. Still worth it for the cases where the card does get seen.
What was just the framework doing the work
Some things were framework-default behaviors that we didn’t actively design but benefited from:
- Page-level code splitting. Each route only loads its own JS. Our pages are all small (the SPA bundle is the same, but the per-page overhead is tiny).
- Automatic
<meta charset>,<meta name="viewport">, and other boilerplate that Next.js puts in the head without asking. - The Image component (not relevant for us — we don’t serve product images — but it would have been free if we did).
- Built-in static prerendering, including correct content encoding and pre-compressed bundles served by the deploy platform.
What we still need to do
A few things on the roadmap that the migration enabled but didn’t finish:
- Move all routes under
[locale]so the root layout can vary<html lang>based on the route. We currently patch it client-side with a one-line script; that works but the static HTML still ships withlang="en"on non-English pages. Pre-hydration crawlers (and Bing more than Google) prefer SSR-correct lang. - Add long-tail content. A blog (this one!), use-case pages, comparisons. Landing pages don’t rank for long-tail queries; each long-tail term needs its own page.
- Build a presence on external surfaces. Github README, package READMEs, comparison pages on other devs’ blogs. SEO is a quality signal; backlinks are a trust signal. The former is necessary but not sufficient.
The honest verdict
Migrating to Next.js was the right call, but not for the reason most blog posts claim. The framework didn’t make us SEO-friendly; the framework made it cheap to be SEO-friendly. The actual SEO work was writing the content, structuring it as headings, marking it up with structured data, and giving each locale its own URL. Vite could have done all of that with more glue code. Next.js just made the glue free.
For a tool with content as small as ours — a landing page, a few app routes — the migration was a week-long project. If you’re considering the same move and you’re currently shipping an empty <div id="root"> on every URL, yes, do it; the SEO floor is too low to leave alone. If your current setup already prerenders content but you want some specific Next.js feature, weigh it against the migration cost; the move isn’t free.