Next.js App Router SEO Checklist: SSR Visibility, Alternates, hreflang and AI Crawlers
Neaptide · October 30, 2026 · 8 min read
Verify what Next.js App Router actually serves in raw HTML: visible SSR headings without opacity:0, complete alternates metadata, crawlable locale links and root redirects.
On this page

A Next.js App Router site can pass visual QA in a browser while still hiding primary headings behind initial animation styles, dropping layout-level alternate links in child routes, or keeping language switchers inside unmounted client state. Audit the raw HTTP response and server-rendered HTML — not just the hydrated DOM in DevTools — before concluding that a page is ready for Googlebot and AI search crawlers.
Server Components render HTML on the server by default, but client wrappers, per-page generateMetadata() overrides and edge middleware can still alter what crawlers receive on the first request. The checklist below documents five concrete issues we audited and fixed on Neaptide’s own multilingual Next.js 15 codebase, along with repeatable checks your engineering team can run before every release.
What did our live Next.js 15 App Router audit find and fix?
In October 2026, we inspected the server-rendered HTML, HTTP headers, metadata output and sitemap of Neaptide’s six-language Next.js 15 website. The table below records the five technical defects identified in the code and verified after deployment.
| Layer inspected | Defect in initial SSR output | Implemented fix & verified result |
|---|---|---|
| Reveal.tsx & Hero.tsx animation wrappers | Primary <h1>, lead copy and cards rendered with inline style="opacity:0;transform:translateY(12px)" until hydration (~1.2s delay) | Removed opacity:0 from SSR state; headings and body copy render visible in raw HTML immediately |
| lib/meta.ts generateMetadata() alternates | Page-level alternates: { canonical, languages } replaced layout alternates and dropped <link rel="alternate" type="application/rss+xml"> | Added types: { "application/rss+xml": "https://www.neaptide.ai/feed.xml" } inside shared pageMetadata() |
| components/LangSwitcher.tsx locale menu | Locale options lived inside an unmounted dropdown using <button> elements with no crawlable href | Rendered all 6 locales in SSR DOM as <a href={...} hrefLang={l} lang={l}> links |
| middleware.ts root (/) redirect | Bots hitting / without geo or Accept-Language headers had no explicit Vary header on redirect | Added deterministic fallback to x-default (/en) plus Vary: Accept-Language, Cookie, x-vercel-ip-country, cf-ipcountry |
| lib/schema.ts & app/sitemap.ts | Three service pages hardcoded areaServed: United States; sitemap omitted inline case and article images | Aligned Service schema with global areaServed and added localized cover + evidence images to sitemap.xml |
These observations describe technical defects and verified code changes on our own site. Fixing them removes rendering and discovery bottlenecks; organic rankings and AI citations still depend on query competition, external authority and ongoing measurement.
Why does style="opacity:0" in entrance animations hurt LCP and non-JS crawlers?
Many Next.js marketing sites wrap hero titles, lead paragraphs and service cards in client reveal components or Framer Motion initial states with opacity: 0. Because Next.js pre-renders Client Components on the server using their initial state, the server HTML arrives with style="opacity:0" on the main <h1> and above-the-fold text.
That creates two avoidable problems. First, the browser cannot count the hero heading or lead block as painted for Largest Contentful Paint (LCP) until JavaScript downloads, hydrates and runs the intersection observer or animation timer — often adding 800–1,200 ms on mobile connections. Second, crawlers and AI retrieval bots that inspect initial HTML without executing client scripts see crucial headings marked invisible in inline styles.
Keep server-rendered text visible from the first byte. Use a subtle transform (such as transform: translateY(12px)) without opacity: 0 in the SSR markup, or apply entrance opacity only after a client-side class confirms JavaScript is active and respects prefers-reduced-motion.
How can generateMetadata alternates silently drop layout tags?
In the Next.js App Router, metadata fields are shallowly merged down the route tree. When a root or locale layout defines alternates (for example, an RSS feed link), and a child page’s generateMetadata() returns its own alternates object with canonical and languages, the child object replaces the parent’s alternates object entirely.
Centralise canonical, hreflang and feed alternates in a single helper called by every indexable page so no route accidentally drops x-default, a language variant or the RSS feed.
export function pageMetadata(locale: Locale, opts: PageMetaInput): Metadata {
const canonicalUrl = `${SITE.url}${localeHref(locale, opts.path)}`;
const languages: Record<string, string> = {};
for (const l of LOCALES) {
languages[l] = `${SITE.url}${localeHref(l, opts.path)}`;
}
languages["x-default"] = `${SITE.url}${localeHref(X_DEFAULT_LOCALE, opts.path)}`;
return {
alternates: {
canonical: canonicalUrl,
languages,
types: {
"application/rss+xml": `${SITE.url}/feed.xml`,
},
},
};
}Why must language switchers and root redirects be explicit for crawlers?
A language switcher that only mounts its menu items after a click event — or switches locales via router.push() inside a <button> — gives search engines zero internal HTML links between language versions. Even when <link rel="alternate" hreflang="..."> exists in <head>, rendering crawlable <a href="/fr/..." hrefLang="fr" lang="fr"> links in the header DOM strengthens internal discovery across every localized route.
Similarly, check what happens when a crawler requests the root path / without cookies, geo IP headers or an Accept-Language header. Your middleware should deterministically redirect to your x-default locale (such as /en) and send Vary: Accept-Language, Cookie, x-vercel-ip-country, cf-ipcountry so CDNs and caches do not serve a geo-targeted redirect to a global crawler.
How to verify a Next.js App Router release in six steps
Run these six verification checks against your staging or production URLs before signing off an SEO release:
- Raw SSR visibility: fetch the page with curl -s and confirm the single <h1> and primary body copy appear in the HTML without style="opacity:0" or hidden attributes.
- Canonical & hreflang parity: verify that every localized URL outputs a self-referential <link rel="canonical">, all active language alternates, an x-default link and any RSS/Atom alternate link.
- Crawlable navigation & locale switcher: confirm header, footer and language switcher links are rendered as standard <a href="..."> elements in the initial HTML.
- Root and prefixless route behaviour: test / and any legacy paths with curl -I and an empty Accept-Language header to confirm a single permanent redirect (308 or 301) to the intended destination.
- Structured data alignment: check that WebPage, Service, Article, BreadcrumbList and FAQPage JSON-LD match the visible text, language, dates and geographic scope of the page.
- Sitemap & llms.txt coverage: confirm every indexable route and its key images appear in /sitemap.xml, and keep /llms.txt synchronised for AI retrieval engines.
curl -s https://www.neaptide.ai/en | grep -E '<h1|rel="canonical"|hrefLang|application/rss\+xml'
curl -I -H 'Accept-Language:' https://www.neaptide.ai/If your team wants an engineering-level review of a Next.js marketing site, B2B platform or multilingual catalogue, Neaptide delivers a technical SEO and GEO audit with reproducible HTTP checks and developer-ready tickets.