This site runs on Next.js 16 with the App Router and React 19. I moved it from a static site in January 2026, after an earlier move from WordPress. I write the code, I do the SEO, and I've broken both at least once.
When I looked at what ranks for "nextjs seo", I found tip lists, a programmatic SEO tutorial and forum threads. What I rarely found is the part that costs rankings: the specific ways a Next.js site ships HTML that looks fine in the browser and says nothing to Google. So this post is about my own setup and my own bugs, with the fixes.
What decides whether a Next.js site ranks is the HTML it ships.
Start with what the server sends
Google downloads the HTML, processes the text and links already in it, and only sees JavaScript-built content after rendering. That is why I care about one thing before anything else: is the content I want indexed in the HTML response?
The App Router gets you most of the way. Pages are Server Components by default, and even components marked "use client" are prerendered to HTML on the server. On this site the copy of every service page lives in client components, and it still ships in the HTML.
"use client" on its own does no harm. Three of my bugs came from what those components rendered on the server.
My check is boring on purpose. curl the URL, or open the page source, and search for a sentence from the main copy. If it isn't there, Google has to render the page to see it, and I want to know why.
Bug 1: sections that shipped as empty rectangles
In April 2026 the homepage loaded its sections through next/dynamic with Suspense. In the browser everything appeared. In the server HTML, each section was an empty dark loading fallback. With JavaScript off, visitors saw black rectangles where the content should be.
The fix was plain static imports, so the real sections ship in the initial HTML. The lesson: a loading state is content too, as far as the HTML is concerned. If the fallback is empty, that is what you serve.
Bug 2: a hero that Google saw as blank
Around the same time the hero section used a Motion animation with an initial opacity: 0. The text was in the HTML, but the screenshot in Google Search Console showed an empty hero, because the element only became visible once JavaScript ran the animation.
I removed the invisible starting state from the hero, so its text is visible in the HTML before any JavaScript runs.
Bug 3: the contact form and useSearchParams
My consultation page reads ?topic= and ?from= from the URL to prefill the contact form. That small feature went through three versions:
useSearchParamsinside Suspense withfallback={null}. The page was static, but the prerendered HTML had no form at all, because the fallback is what goes into the HTML.- On September 5, 2026 I switched to reading the async
searchParamsprop on the server. The form was back in the HTML without JavaScript. ButsearchParamsopts the page into dynamic rendering, so it now rendered on every request. - On September 29, 2026 I went back to
useSearchParamsin Suspense, this time with the same form, just without the prefill, as the fallback. The page is static again and the HTML contains a working form.
The Next.js docs describe this exact behavior: calling useSearchParams on a prerendered route makes the client tree up to the nearest Suspense boundary render on the client, and the fallback is what lands in the initial HTML. Nothing was broken in Next.js. My fallback was.
Metadata: one source per page
Titles, descriptions and canonicals live in each route's page.tsx, through metadata or generateMetadata. JSON-LD goes there too, in the Server Component, never in a client component. The FAQ on each service page comes from one data file that feeds both the visible accordion and the FAQPage schema, so the two can't drift apart.
For the blog, generateMetadata reads the post's frontmatter. The title tag uses a separate metaTitle field when the H1 is too long for search results.
Social images come from file-based opengraph-image.tsx templates for the site root and each blog post. The hero image inside an article is a separate illustration. I keep them apart on purpose, because a branded OG card used as the in-article hero just repeats the H1 as a picture.
hreflang for a Czech site with an English section
This site is Czech first, with an English section under /en. Three small helpers in src/lib/hreflang.ts handle every page:
- a Czech page gets a self-canonical,
csandenalternates, andx-defaultpointing at Czech - an English translation gets its own self-canonical, the same pair of alternates and the same
x-default - an English page with no Czech counterpart gets
enandx-defaultonly, and nocsalternate at all
Get the last case wrong and hreflang points at a page that isn't a translation, which sends Google a wrong signal. Blog posts declare their counterpart through a translationOf field in the frontmatter, and only then do they get the cs alternate. This post has no Czech version, so it doesn't.
The empty state had rules too. Until the first English post existed, /en/blog was noindex and stayed out of the sitemap. An empty listing page has nothing to rank for.
The sitemap is generated from code
src/app/sitemap.ts builds the sitemap from the same route list that powers the language switcher, plus the Markdown files for the blog and glossary. Legal pages that are noindex are left out. Translated pages carry their language alternates in the sitemap as well.
One mistake I made early: lastModified was set to new Date(), so every build stamped every URL as changed today. A date that changes on every deploy tells Google nothing. Now static pages have no lastModified, and blog posts take it from the date or dateModified in their frontmatter.
robots.ts is environment-aware. Only production allows crawling. Preview deployments disallow everything, so a staging URL can't end up in the index, and the production rules can't be forgotten on launch day because they're the same file.
Redirects and trailing slashes
My next.config.ts carries 36 redirect rules, mostly for older URL structures. With permanent: true, Next.js returns a 308, which Google treats as a permanent redirect, same as a 301.
Trailing slashes are where it gets fiddly. In my audit on September 5, 2026, the old address /sluzby/seo-audit/ went through two 308 hops before reaching /seo-audit. I tested a config change to cut it to one hop, it turned the old address into a 404, and I reverted it. The two-hop chain is documented and accepted until I rework the routing.
Script the checks
The most expensive bug on this site had nothing to do with rendering. A straight double quote inside a YAML string in one Czech post's frontmatter broke the parser, the post lookup returned nothing, and the page was statically rendered as a 404. It stayed that way for 13 days before I caught it.
Now I run these before every release:
check:frontmatterparses every post, checks the required fields and verifies thattranslationOfpoints at a post that existscheck:i18nscans the built English HTML for Czech strings that leaked into the English pagescheck:pricingmakes sure the prices hardcoded in pages match the one pricing data file
None of this is clever. It's the same idea as acceptance criteria in a dev ticket: a check that passes or fails without discussion.
What I check on a Next.js site
When a client brings me a Next.js site, this is my first pass:
- Raw HTML of each key template: main copy, internal links with a real
href, title, canonical, structured data. - Suspense fallbacks and loading states: what exactly do they render on the server?
- Animations and client state that hide content on first paint.
- Which routes are static and which became dynamic, and why.
- Metadata,
hreflangand the sitemap: generated from one source, or maintained by hand in three places? - Redirects: status codes, chains and trailing-slash behavior, tested with
curl -IL. robots.tsand meta robots per environment.- URL Inspection in Google Search Console for a sample of URLs, compared with what
curlreturned.
Rendering strategies, crawl budget and how Google's render queue works are covered in JavaScript SEO. If you're planning a move to Next.js, technical SEO is where I write the acceptance criteria before development starts.
