Table of Contents
Next.js with the App Router gives you everything search engines need, but none of it is automatic. To rank, each indexable page must be rendered on the server, carry its own title, description and canonical, appear in a sitemap, return the right status code and load fast. The checklist below walks through each of those, with the App Router code that implements it.
It is written for marketing leads who need to know what to ask for and developers who have to build it. If you are still choosing a framework, see our comparison of Next.js, Gatsby and Nuxt for SEO first.
Table of Contents
1. Choose a rendering strategy per page type
Components are Server Components by default, so the real decision is when HTML is produced: at build time, on a schedule (ISR) or per request. We compare the trade-offs in SSG vs SSR in Next.js for SEO; the practical mapping is:
- Marketing pages and blog posts: static.
- Large catalogues and programmatic pages (listings, locations, product or institution profiles): static with a
revalidateinterval, pre-building the most important paths withgenerateStaticParamsand generating the rest on first request. - Search results, dashboards, anything personalised: dynamic, and usually not meant to be indexed at all.
Write the choice down per route. A common problem is a route that quietly became dynamic (because it read cookies or headers) or client-rendered, unnoticed until traffic dropped.
2. Titles, descriptions and canonicals with the Metadata API
Export a static metadata object for fixed pages, or a generateMetadata function when the title depends on data. The tags are rendered into the server HTML, so crawlers see them without running JavaScript.
Set defaults once in the root layout
In app/layout.tsx, set metadataBase (for example new URL('https://www.example.com')) so relative canonical and Open Graph URLs resolve to your production domain, and a title.template such as '%s | Brand' for a consistent suffix.
Generate per-page metadata from your data
// app/colleges/[slug]/page.tsx
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const college = await getCollege(slug); // same cached fetch the page uses
if (!college) notFound();
return {
title: `${college.name}: courses, fees and admissions`,
description: college.summary.slice(0, 155),
alternates: { canonical: `/colleges/${slug}` },
openGraph: {
title: college.name,
url: `/colleges/${slug}`,
images: [{ url: college.imageUrl, width: 1200, height: 630 }],
},
};
}
A few rules that save rework later:
- Every indexable page gets a self-referencing canonical via
alternates.canonical, the simplest defence against duplicates from tracking parameters, filters and sort orders. - Titles and descriptions must be unique. On programmatic pages, build them from the fields that make each page different, not one template sentence.
- Deduplicate data calls.
fetchrequests are memoised within a render; wrap ORM or CMS SDK loaders in React’scache()so metadata and page share one query. - Use
robots: { index: false }for pages meant for users but not search, such as internal search results. - Open Graph images: an
opengraph-image.pngor generatedopengraph-image.tsxin a route folder is picked up automatically.
3. robots.ts and sitemap.ts
Both live in app/ and are served as /robots.txt and /sitemap.xml, generated in step with your routes and CMS.
// app/robots.ts
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: [{ userAgent: '*', allow: '/', disallow: ['/api/', '/search'] }],
sitemap: 'https://www.example.com/sitemap.xml',
};
}
A classic launch-day mistake is shipping the staging rule (disallow: '/') to production. Drive the rules from an environment variable.
Large and programmatic sitemaps
A single sitemap file is limited to 50,000 URLs. For bigger sites, generateSitemaps splits the output into several files, each at its own URL (below, /colleges/sitemap/0.xml, /colleges/sitemap/1.xml and so on).
// app/colleges/sitemap.ts
import type { MetadataRoute } from 'next';
const PER_FILE = 45000;
export async function generateSitemaps() {
const total = await countColleges();
return Array.from({ length: Math.ceil(total / PER_FILE) }, (_, id) => ({ id }));
}
export default async function sitemap({ id }: { id: number }): Promise<MetadataRoute.Sitemap> {
const rows = await getCollegeSlugs({ offset: id * PER_FILE, limit: PER_FILE });
return rows.map((r) => ({
url: `https://www.example.com/colleges/${r.slug}`,
lastModified: r.updatedAt,
}));
}
List each generated sitemap in robots.ts or a sitemap index and submit them in Search Console. Include only URLs that return 200, are self-canonical and are not noindex. Use a real lastModified from your data, not the build time, or every URL looks freshly changed on every deploy.
4. Structured data as JSON-LD in a Server Component
Structured data helps search engines understand a page and can make it eligible for rich results. Render it as a <script type="application/ld+json"> tag in the page’s Server Component: it ships in the initial HTML and adds nothing to the client bundle.
// app/colleges/[slug]/page.tsx (inside the page component)
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollegeOrUniversity',
name: college.name,
url: `https://www.example.com/colleges/${college.slug}`,
address: { '@type': 'PostalAddress', addressLocality: college.city },
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd).replace(/</g, '\\u003c'),
}}
/>
{/* page content */}
</>
);
The .replace() stops CMS content breaking out of the script tag. Structured data must describe content visible on the page. Useful types include Organization (in the root layout), BreadcrumbList, Article and Product. Validate with Google’s Rich Results Test.
5. Trailing slashes and redirects in next.config
By default Next.js redirects /about/ to /about; trailingSlash: true reverses that. Either is fine, as long as only one version returns 200 and your canonicals, sitemap and links all match it.
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
trailingSlash: false,
async redirects() {
return [
{ source: '/blog/old-post', destination: '/blog/new-post', permanent: true },
{ source: '/courses/:slug', destination: '/programmes/:slug', permanent: true },
];
},
};
export default nextConfig;
permanent: true returns a 308, which search engines treat as a permanent move. When migrating a React site to Next.js or re-platforming, map every old URL with traffic or backlinks before launch. For thousands of redirects, apply them in middleware from a data store instead of the config file.
6. Internationalisation and hreflang
The App Router does not use the Pages Router i18n config. Put the locale in the URL with a segment such as app/[lang]/, use middleware to route first-time visitors, and declare hreflang in generateMetadata with alternates.languages, for example { 'en-GB': '/en-gb/pricing', 'x-default': '/en-us/pricing' }.
- Each language version canonicalises to itself, not to the default language.
- hreflang must be reciprocal: every version lists every other version, including itself.
- Do not redirect by IP or
Accept-Languageon every request; every localised URL must be reachable directly.
7. 404s, notFound() and soft 404s on programmatic pages
For a record that does not exist, call notFound() from next/navigation: Next.js renders the nearest not-found.tsx with a 404 status. Call it early, before any Suspense boundary; once streaming has started the status is already sent, and Next.js can only add a noindex tag.
If you pre-build every valid path, export const dynamicParams = false turns any other value into a 404.
The harder problem is the soft 404: a 200 page with nothing useful on it, such as “schools in [city]” for a city with no schools. These dilute the quality of the whole section. Set a threshold in code:
- No records at all:
notFound(). - Too few to be useful: render for users, set
robots: { index: false }and leave it out of the sitemap. - Enough unique content: index it.
8. next/image and next/font for Core Web Vitals
Core Web Vitals (LCP, INP and CLS) are part of how Google assesses page experience. Two built-in components do most of the work.
next/image
- Give
widthandheight(orfillin a sized container) so space is reserved and nothing shifts. - Mark the likely LCP image, usually the hero, with
priorityso it is preloaded, not lazy-loaded. One or two per page at most. - Set
sizeson responsive images so the browser does not download oversized files. - Allow your CMS or storage bucket in
images.remotePatternsso remote images are optimised too.
next/font
next/font self-hosts fonts from your own domain and generates a size-adjusted fallback so text does not jump when the web font loads. Load fonts once in the root layout.
For INP, ship less client JavaScript: keep 'use client' at the leaves of the tree, not on whole pages, and defer third-party scripts with next/script.
9. Internal linking with next/link
<Link> renders a real <a href> in the server HTML, which crawlers follow, and prefetches the destination. Avoid onClick handlers calling router.push() for navigation: crawlers cannot follow them.
On large sites, internal links are how new pages get discovered. Build them into templates: breadcrumbs, hub pages linking to children, “related” modules between siblings, and pagination with real links rather than infinite scroll alone.
10. Do not render indexable content only on the client
A 'use client' component is still rendered to HTML on the server, so the directive is not the problem. The problem is content that only appears after the browser runs JavaScript. Watch for:
- Data fetched in
useEffect: the server HTML contains a spinner. Fetch in a Server Component and pass props down. dynamic(() => import(...), { ssr: false })wrapped around anything a searcher should find.- Tabs and accordions whose content is not in the DOM until clicked. Render it and hide it with CSS.
useSearchParams()on a static page, which client-renders everything up to the nearestSuspenseboundary. Keep that boundary tight.
Quick check: fetch the page with curl and confirm headings, copy, links and JSON-LD are in the raw HTML. Search Console’s URL Inspection shows what Google rendered.
11. What we learned building programmatic SEO portals
Two of our Next.js projects were built around programmatic SEO. Colleges18 is a college discovery portal with a server-rendered Next.js front end, a headless CMS setup of Strapi and a Node.js back end, dynamic search and filtering, AWS S3 for assets and OpenAI for content generation. Schools18 is a school search portal covering more than 19,000 schools on Strapi and Next.js, built for programmatic SEO and fast performance with a large data set.
Lessons that carry over:
- Model the data for the page. Unique titles and introductions need structured CMS fields; with only a name and address, every page reads the same. See our post on Strapi as a headless CMS.
- Decide which filter pages to index. Filters create near-infinite URL combinations. Give the ones people search for clean paths and their own metadata; keep the rest
noindexor canonicalised to the parent. - Generated content needs editorial rules. AI-assisted copy helps at scale only when grounded in the record’s real data and reviewed; otherwise you produce thin pages faster.
- Pre-build the head, generate the tail. Pre-render high-value paths, generate the rest on request with
revalidate, and split the sitemap. - Performance is a template problem. A slow component on a detail template repeats on every page, so measure templates, not just the home page.
The Next.js SEO checklist
Use this as a pre-launch or audit checklist.
| Item | Why it matters | How in Next.js |
| Rendering choice per route | Crawlers should get complete HTML quickly | Server Components by default; static, revalidate or dynamic per route |
| Unique title and description | Drives relevance and click-through from results | metadata export or generateMetadata |
| Self-referencing canonical | Consolidates duplicate URLs | alternates.canonical plus metadataBase |
| robots.txt | Keeps crawlers out of low-value areas | app/robots.ts, driven by environment |
| XML sitemaps | Helps discovery of every indexable URL | app/sitemap.ts; generateSitemaps above 50,000 URLs |
| Structured data | Clarifies page meaning, enables rich results | JSON-LD <script> in a Server Component |
| One URL format | Avoids duplicate slash/no-slash versions | trailingSlash in next.config |
| Permanent redirects | Preserves rankings when URLs change | redirects() in next.config, or middleware at scale |
| hreflang | Serves the right language or region version | alternates.languages with an [lang] segment |
| Real 404s | Stops missing records being indexed | notFound(), not-found.tsx, dynamicParams = false |
| No soft 404s | Thin pages weaken the whole section | Content threshold: 404, noindex or index |
| Stable, fast images | LCP and CLS | next/image with dimensions, sizes and priority |
| Crawlable internal links | Discovery and link equity | next/link, breadcrumbs, hub pages, real pagination |
| Content in the server HTML | Content that needs JavaScript may be missed or delayed | Fetch in Server Components; avoid ssr: false for key content |
How VOCSO can help
VOCSO has built software since 2009 across 600+ projects. Our Next.js development services cover new builds, migrations and SEO audits, including large programmatic portals. We are ISO 27001 certified and sign an NDA from day one. For a second pair of eyes before launch, get in touch with our team.
Frequently asked questions
Is Next.js good for SEO?
Yes, when configured properly. Server Components render HTML on the server by default, and built-in APIs cover most technical SEO needs. Problems usually come from browser-only content or missing metadata on dynamic routes.
Do I still need next/head in the App Router?
No. next/head is a Pages Router API. In app/, the metadata export and generateMetadata handle titles, descriptions, canonicals, Open Graph, robots directives and hreflang.
Does using ‘use client’ hurt SEO?
Not by itself. Client Components are still server-rendered on first load. SEO suffers when their content is fetched only in the browser, for example in useEffect.
How do I handle sitemaps for more than 50,000 pages?
Use generateSitemaps in sitemap.ts to split URLs across files, list each in robots.ts or a sitemap index, and submit them in Search Console.
Should filter and search pages be indexed?
Internal search results should not be. Filter pages can be, if people search for that combination and the page has enough unique content.
Related reading
- Static Site Generation (SSG) vs Server-Side Rendering (SSR) in NextJS: Which is Best for SEO?
- NextJS vs ReactJS: When to Use Each and Why
- Migrating to NextJs: Seamless Transition from ReactJS to NextJs













Leave a Reply