7 SEO Mistakes Hiding in Your Next.js Starter Template
Every one of these passes the build, renders fine, and throws no errors. That is exactly why they survive to production.
· 9 min read
Starter templates are optimized to look finished. They ship with a sitemap.ts, a robots.ts, a metadata object — all the boxes an SEO checklist would tick. That is what makes them dangerous: the presence of the file is mistaken for the correctness of the file, and nobody looks again.
These are seven we found in ours. Every one passed the build. Every one rendered without error.
1. A canonical tag in the root layout
The big one. alternates: { canonical: "/" } in app/layout.tsx is inherited by every route, so every page on your site declares the homepage as its canonical URL — and Google drops them from the index accordingly. We did this to ourselves and lost every page but one.
Check:
curl -s https://yoursite.com/any-non-homepage | grep -i canonicalThe href must be that page's own URL. Fix: never set canonical in a layout. Set it per-page.
2. `lastModified: new Date()` in the sitemap
Nearly every Next.js sitemap example does this, and it means every URL on your site claims to have been modified at build time — so a page you have not touched in a year says it changed five minutes ago, every time you deploy.
Google is good at spotting this. Once it decides your lastmod values are noise, it stops trusting them site-wide — and you have thrown away a real crawl-scheduling signal for nothing.
// wrong — every deploy re-stamps every URL
{ url: "/about", lastModified: new Date() }
// right — the date the content actually changed
{ url: "/about", lastModified: new Date("2026-03-02") }3. Metadata on a `"use client"` page
You cannot export const metadata from a client component. Next.js does not error — it simply ignores it, and your page silently falls back to the root layout's title and description.
The trap is that "use client" at the top of page.tsx is often unnecessary. It gets added because *one child* needs interactivity, and the fastest way to make an error go away is to slap the directive on the parent. Now the whole page is a client component and its metadata is gone.
4. Placeholder metadata that shipped
title: "Create Next App". It is funnier than it is rare — search that exact phrase on Google and you will find thousands of live, indexed, production sites.
The subtler version is worse, because it does not look like a placeholder: a real-sounding title inherited from whatever template you forked. We shipped a legal page titled *"Refund Policy — Paddle Web Payments Starter"* to a site that has never used Paddle.
5. Orphaned routes from the template
Starters ship pages you never intended to have. Ours included a /refund-policy route that was linked from nothing, appeared in no sitemap, and existed purely because the template had a billing flow we deleted.
These are not harmless. Google can find them through old links or sheer crawling, index a page with a competitor's product name on it, and spend crawl budget on routes you forgot existed.
# every route you actually ship
find src/app -name "page.tsx" | sed 's|src/app||;s|/page.tsx||'Compare that list to your sitemap. Anything in one and not the other deserves an explanation.
6. Internal links to routes that do not exist
Our FAQ section had a prominent Get Started button pointing at /pricing. There is no /pricing route. It had been 404ing for every visitor who clicked it, and for Googlebot, for as long as the site had been live.
TypeScript will not catch this — href is a string. The template's own links survive your deletions, and a link to a page you removed looks exactly like a link to a page you kept.
# crawl your own build for broken internal links
next build && next start &
curl -s localhost:3000 | grep -o 'href="/[^"]*"' | sed 's/href="//;s/"//' | sort -u \
| while read -r l; do
echo "$(curl -s -o /dev/null -w '%{http_code}' "localhost:3000$l") $l"
done7. A sitemap that is a hand-written array
Templates ship a sitemap.ts containing three literal URLs. It is correct on day one and wrong forever after, because the one file nobody remembers to touch when adding a page is the sitemap.
Generate it from the same source your routes come from — a content directory, a CMS query, a database. If adding a page requires remembering to add it somewhere else, it will eventually not be there.
import { guides } from "@/content/guides";
export default function sitemap(): MetadataRoute.Sitemap {
return [
{ url: absoluteUrl("/"), lastModified: HOME_UPDATED },
...guides.map((g) => ({
url: absoluteUrl(`/guides/${g.slug}`),
lastModified: new Date(g.updated),
})),
];
}The pattern
None of these throw. None fail CI. None are visible in the browser. Every one of them is a *semantic* error — the code does precisely what it says, and what it says is wrong. Your type checker cannot help you, because there is nothing type-unsafe about telling Google a true statement about the wrong page.
The only place they surface is Search Console, and only if you look. Start with the Page indexing report, and check the canonical on a non-homepage URL right now — it takes ten seconds and it is the one most likely to be costing you real traffic.