Blog/Next.js SEO: what I set up and broke on my own App Router site

Next.js SEO: what I set up and broke on my own App Router site

Published September 30, 20266 min read
Next.js SEO: what I set up and broke on my own App Router site

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:

  1. useSearchParams inside Suspense with fallback={null}. The page was static, but the prerendered HTML had no form at all, because the fallback is what goes into the HTML.
  2. On September 5, 2026 I switched to reading the async searchParams prop on the server. The form was back in the HTML without JavaScript. But searchParams opts the page into dynamic rendering, so it now rendered on every request.
  3. On September 29, 2026 I went back to useSearchParams in 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, cs and en alternates, and x-default pointing 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 en and x-default only, and no cs alternate 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:frontmatter parses every post, checks the required fields and verifies that translationOf points at a post that exists
  • check:i18n scans the built English HTML for Czech strings that leaked into the English pages
  • check:pricing makes 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:

  1. Raw HTML of each key template: main copy, internal links with a real href, title, canonical, structured data.
  2. Suspense fallbacks and loading states: what exactly do they render on the server?
  3. Animations and client state that hide content on first paint.
  4. Which routes are static and which became dynamic, and why.
  5. Metadata, hreflang and the sitemap: generated from one source, or maintained by hand in three places?
  6. Redirects: status codes, chains and trailing-slash behavior, tested with curl -IL.
  7. robots.ts and meta robots per environment.
  8. URL Inspection in Google Search Console for a sample of URLs, compared with what curl returned.

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.

SEO consultation

Not sure which SEO priority comes first?

A paid consultation gives you a prioritised plan built on your own data.

Fill in a short form. I reply within 24 hours with available slots. All I need is your URL.

In a hurry? Call +420 735 507 511.

P.S. If SEO doesn't make sense for you right now, I'll tell you straight away. Cheaper than a budget spent on the wrong priority.

Share:

FAQ

Frequently asked questions

Is Next.js good for SEO?

Yes, if the content you want indexed is in the HTML the server sends. The App Router renders on the server by default, which is the safest starting point. Next.js won't stop you from hiding that content behind client-only code, though, so you still have to check the output.

Does 'use client' hurt SEO in Next.js?

Not by itself. Client components are still prerendered to HTML on the server. The risk is what the component renders on the server: an empty loading state, a Suspense fallback or content that only appears after a click.

Do I still need a package like next-seo with the App Router?

Not for the basics. The App Router has a built-in Metadata API for titles, descriptions, canonicals, language alternates and Open Graph, plus file conventions for sitemap.ts and robots.ts. I run this site without an SEO package.

How do I set up hreflang in Next.js?

Through alternates.languages in the page metadata, and optionally in sitemap.ts. I keep it in small helper functions so every page uses the same rules for canonical, language versions and x-default, and pages without a translation don't point at one.

What should I check first on a Next.js site?

The raw HTML of your key templates. Run curl or view the page source and look for the main copy, links with href, title, canonical and structured data. Then compare it with the URL Inspection result in Google Search Console.