The Next.js technical SEO checklist we run on every site
Our Next.js technical SEO checklist for the App Router: metadata, OG images, JSON-LD, sitemaps, robots, real 404s and Core Web Vitals.
By FutureGen Systems
Most technical SEO problems on Next.js sites aren't exotic. They are pages that inherit the wrong canonical from the root layout, a sitemap that lists URLs which no longer exist, a "not found" page that returns 200, or FAQ schema describing questions nobody can see. This is the Next.js technical SEO checklist we run on every App Router site, and the one we just applied to futuregensystems.com itself. Each item has a short snippet from this site's code where it helps.
1. One metadata helper for every page
In the App Router, a page that exports only title and description silently inherits the root layout's Open Graph block and canonical. Share that page on LinkedIn and it describes your homepage. The fix is to never hand-write metadata: every route calls one helper that builds the canonical, Open Graph and Twitter card together, so they always agree.
export function buildMetadata({ title, description, path, image, type = "website", noIndex = false }: PageSeoInput): Metadata {
const url = absoluteUrl(path);
const ogImage = image ?? ogImageUrl(title);
return {
title,
description,
alternates: { canonical: url },
openGraph: { title, description, url, siteName: SITE_NAME, type,
images: [{ url: ogImage, width: 1200, height: 630 }] },
twitter: { card: "summary_large_image", title, description, images: [ogImage] },
...(noIndex ? { robots: { index: false, follow: true } } : {}),
};
}
Two details that matter:
- Normalise URLs in one place.
absoluteUrl()strips trailing slashes and duplicate slashes, so/faq,/faq/and//faqall produce the same canonical. noindex, follow, notnoindex, nofollow, for pages you want out of the index. Crawlers should keep walking their links.
2. Generated Open Graph images per page
One generic banner for every share is a wasted opportunity. We render a card per page with next/og: a route handler at /og takes title and an optional eyebrow and returns a 1200x630 PNG in the site's own fonts and colours. The metadata helper points every page at it by default:
export function ogImageUrl(title: string, eyebrow?: string): string {
const params = new URLSearchParams({ title });
if (eyebrow) params.set("eyebrow", eyebrow);
return `/og?${params.toString()}`;
}
Clamp the title length in the route, scale the font size down for long titles, and send a long Cache-Control header, since social platforms fetch these images repeatedly.
3. JSON-LD that describes what's actually on the page
Structured data is where well-meaning sites get into trouble. Our rules:
- Organization once, with name, URL and logo. No ratings, review counts, founding claims or awards you can't back up.
- BreadcrumbList on every page below the homepage, built from the same crumb array that renders the visible breadcrumb.
- BlogPosting on articles, with the real
datePublished, headline, author or publisher, and section. - FAQPage only when the questions and answers are rendered in the HTML. If the FAQ lives behind a tab that never renders, or exists only in the schema, leave it out. Schema that describes invisible content is the quickest way to lose rich results.
The helpers are small and typed:
export function breadcrumbSchema(crumbs: Crumb[]) {
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: crumbs.map((crumb, i) => ({
"@type": "ListItem",
position: i + 1,
name: crumb.name,
item: absoluteUrl(crumb.path),
})),
};
}
On the homepage the FAQ schema is generated from the same FAQS array that renders the visible questions, so the two can't drift apart. For CMS-authored FAQ schema, we only emit it if it really is a FAQPage object.
No inflated claims. Structured data is not the place for marketing copy. If a number, rating or credential isn't true and visible on the page, it doesn't go in JSON-LD.
4. A sitemap built from the same data as the routes
A hand-maintained sitemap drifts. Ours is generated in app/sitemap.ts from the same data files the routes use, so a page that exists is listed and a page that was removed isn't:
// Slugs come from src/data/services.ts so the sitemap cannot drift
// out of sync with the routes that actually exist.
const serviceEntries = serviceSlugs.map((slug) => ({
url: `${BASE_URL}/services/${slug}`,
changeFrequency: "monthly",
priority: 0.8,
}));
const projectEntries = projects.map((p) => ({
url: `${BASE_URL}/projects/${p.slug}`,
changeFrequency: "monthly",
priority: 0.7,
}));
We also de-duplicate the final list by URL. The same page listed twice with different priorities is a mixed signal you don't need.
5. Robots rules for filter and search permutations
Filter and search parameters create near-infinite URL variations that duplicate real pages and waste crawl budget. Block the permutations and let the canonical routes carry the content:
export default function robots(): MetadataRoute.Robots {
return {
rules: [{
userAgent: "*",
allow: "/",
disallow: ["/api/", "/private/", "/blogs?category="],
}],
sitemap: `${SITE_URL}/sitemap.xml`,
};
}
Here, /blogs?category=x duplicates the dedicated /blogs/x route, which is the canonical version. Remember that robots.txt controls crawling, not indexing. For pages that are already indexed, use noindex in their metadata instead, and keep them crawlable until they drop out.
6. Real 404s, not soft ones
A soft 404 is a "not found" page served with status 200. Search engines treat it as thin content and may keep it indexed. In the App Router, call notFound() whenever the data for a dynamic route doesn't exist, before rendering anything:
const { slug } = await params;
const project = getProjectBySlug(slug);
if (!project) notFound();
Do the same when an upstream API returns 404. Then check with curl -I that a made-up slug returns 404, not 200.
7. One h1 per page
Each page gets exactly one <h1>, matching the topic of its title tag. Shared components such as headers, cards and footers must not render h1s. In blog content, the page renders the post title as the h1, so the article body starts at ##. A quick check:
document.querySelectorAll("h1").length // should be 1
8. Core Web Vitals basics
You don't need heroics, just the defaults applied consistently:
- LCP: server-render above-the-fold content, use
next/imagewithpriorityon the hero image, and serve correctly sized images. - CLS: give images and embeds explicit dimensions, reserve space for anything that loads late, and avoid injecting banners above content.
- INP: keep client components small, defer non-critical third-party scripts with
next/script, and avoid heavy work in click handlers.
Measure with field data where you have it, and with Lighthouse on throttled mobile settings where you don't.
9. Fonts via next/font
Load fonts with next/font so they are self-hosted, preloaded and served with font-display: swap, with no request to a third-party font CDN:
const sans = Instrument_Sans({
subsets: ["latin"],
weight: ["400", "500", "600"],
display: "swap",
variable: "--font-sans",
});
Load only the weights you use. Every extra weight is another file on the critical path.
Running the checklist
Before launch, and after any significant change, we walk through it in order: view-source on a sample of pages to check canonical, OG and JSON-LD; validate structured data with Google's Rich Results Test; fetch /sitemap.xml and /robots.txt; request a made-up URL and confirm the 404; and run Lighthouse on the key templates.
This is the baseline of our website and SEO work, and part of how we work on every build (see our process). If your Next.js site has drifted from any of this, get in touch and we'll start with an audit.