·8 min read

technical seo for next.js app router sites

next.jsapp routertechnical seometadatasitemapcanonicalweb development

next.js has better seo primitives than almost anything else you could build on. metadata is typed, sitemaps are code, opengraph images generate themselves, and everything server-renders by default so google sees real html rather than an empty div waiting for javascript.

which is exactly why the failures are hard to spot. these apis fail by producing something — a title, a canonical, a sitemap entry — that is well-formed, valid, and wrong. nothing throws. the build passes. the page looks fine in a browser.

these are the ones we have hit on our own sites and on client work, roughly in order of how much damage they do.

the sitemap and the router computing slugs separately

this is the expensive one, and it is not really a next.js bug — it is a consequence of sitemap.ts being a separate file from your route.

app/sitemap.ts is just a function returning urls. nothing validates that those urls resolve. it is entirely normal to write a generator that reads a content directory and maps filenames to urls:

fs.readdirSync(dir) .filter((f) => f.endsWith(".md")) .map((f) => ({ url: `${baseUrl}/guides/${f.replace(/\.md$/, "")}` }))

and it is equally normal for the route to key that content differently — stripping an ordering prefix, applying a slug map, reading a slug field out of frontmatter. the moment those two derivations diverge, your sitemap advertises urls the router does not serve.

what makes it costly is what next.js does with an unmatched dynamic segment. if your page calls notFound() you get a proper 404. but if it renders a "not found" state itself — a fallback ui, an early return, a component that handles the missing case gracefully — you get http 200 with not-found content, which is a soft 404. we shipped twenty-four of those and wrote up the whole diagnosis in we broke our own seo: 24 pages returning 200 and saying not found.

two rules that prevent it:

derive slugs in one place. export a single function that produces the canonical list of slugs, and have both generateStaticParams and sitemap.ts call it. if they cannot disagree, they will not.

call notFound(), do not render your own. the not-found.tsx convention gives you a real 404 status. a component that returns <p>Not found</p> gives you a 200 and a lie.

and check it after deploy, because this is trivially checkable and almost nobody does it:

curl -s https://example.com/sitemap.xml \ | grep -o '<loc>[^<]*</loc>' | sed 's/<[^>]*>//g' \ | while read u; do echo "$(curl -s -o /dev/null -w '%{http_code}' "$u") $u"; done

worth putting in ci.

static routes are not discovered

related, and it bites everyone once. app/sitemap.ts typically has a hand-written array of static pages and a dynamic block that reads content off disk. blog posts self-register. new static routes do not.

so you add app/pricing/page.tsx, ship it, and it never enters the sitemap because nobody edited the array. on our own site the missing page was /ottawa — the local landing page, the single url most likely to earn commercial traffic — and search console reported it as "url is unknown to google".

if you can, walk the app directory to enumerate static routes. if you keep the manual list, put a comment at the top saying it is manual, because the person adding the next route is unlikely to be you.

metadata objects replace, they do not merge

this one is subtle and it silently affected every page on one of our properties.

next.js merges metadata down the route tree shallowly. child values override parent values field by field, but a nested object is replaced wholesale rather than merged into.

so a root layout with an openGraph block containing images, plus a page exporting its own openGraph with a title and description and no images key, produces a page with no og:image at all. not the inherited one. none.

// app/layout.tsx openGraph: { siteName: "nanushi", images: ["/og.png"] } // app/services/page.tsx — og:image is now gone on this route openGraph: { title: "Services", description: "..." }

the pages most likely to hit this are the ones a developer bothered to write custom metadata for, which is to say your commercial pages — precisely the ones most likely to be shared. every share renders as a bare grey link.

the fix is to stop writing metadata blocks by hand. write one helper that takes a title, description and path and returns a complete Metadata object with every field filled in, and call it from every page. it is fifteen lines and it eliminates this entire class of bug.

canonicals: per page, absolute, and not in the layout

alternates.canonical is the right api:

export const metadata: Metadata = { alternates: { canonical: "https://www.example.com/services" }, }

three things to get right.

declare it on the page, never the layout. a canonical in a root layout is inherited by every page that does not override it, which quietly canonicalises your whole site to one url. this is the most damaging single line of metadata you can write, and it looks like good dry practice.

use an absolute url with the right host. if metadataBase is set you can use a relative path, but be certain metadataBase names the host google has actually chosen. apex versus www is a real decision with real consequences — see apex vs www: how one redirect quietly splits your rankings.

check it renders inside <head>. the metadata api handles this correctly. anything injecting a canonical from a client component may not, and a canonical outside the head does not count at all.

for dynamic routes, generateMetadata should build the canonical from the same slug the route matched, not from anything derived separately.

client components and what google sees

app router components are server components by default, which is the right default for seo. the failure mode is 'use client' creeping up the tree until an entire page is client-rendered, at which point google receives a shell and has to execute javascript to see the content. googlebot does render javascript, but it is a second pass on a separate queue, and other crawlers — including several ai crawlers now sending real referral traffic — do far less.

keep 'use client' at the leaves. a page that needs one interactive widget should be a server component rendering a client child, not a client component all the way down. and metadata cannot be exported from a client component at all, which is why the convention of a server page.tsx exporting metadata and rendering a PageClient.tsx exists. we use it throughout our own site.

opengraph images

app/opengraph-image.tsx generates a real image at build or request time and wires the meta tag into every route beneath it. one file, and every url on the site has a share image.

two caveats: the merge behaviour above will strip it from any route declaring its own openGraph without images, and the file requires the edge-compatible subset of css — flexbox works, grid does not, and every font must be loaded explicitly.

structured data

there is no metadata api for json-ld. the documented approach is a script tag:

<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />

put it in the server component, not a client one. and fetch every url your json-ld references. we asserted logo and image pointing at a /logo.png that returned 404. structured data claiming an asset that does not exist is worse than omitting the field — you have told google something specific and checkable, and google checks.

do not add aggregateRating for your own business on your own site. google's guidelines treat self-serving review markup as a violation and enforce it with manual actions. link to the real profile instead.

rendering strategy, briefly

force-dynamic on a page that could be static costs you ttfb on every crawl. revalidate on content that changes daily is usually right. and check what your marketing pages actually build as — a stray cookies() or headers() call anywhere in the tree opts the whole route into dynamic rendering, which is easy to do accidentally and produces no warning.

speed is a ranking signal but rarely the ranking problem; if your pages serve in under a second you should be looking elsewhere first. core web vitals explained for business owners covers what to measure.

the check that catches most of this

after any deploy that touches routing, content structure or metadata:

# does the canonical exist, is it right, and is it in the head? curl -sL https://example.com/services | grep -o 'rel="canonical" href="[^"]*"' # does every sitemap url actually return 200 with real content? # (the loop above)

next.js will not tell you when these are wrong, because from the framework's point of view nothing is wrong. valid html, valid xml, zero errors, and a site google cannot make sense of.


we build on the app router and we audit sites built on everything else. if you want a second pair of eyes on what google is actually seeing, see what our seo work covers — or read technical seo basics every website owner should understand for the non-framework version.

ready to start building real apps with a team of passionate developers? join nanushi today and level up your mobile development skills.

learn more