Awwwards Nominee Awwwards Nominee

Next.js SEO: A Technical Checklist for the App Router

by : deepak-chauhan Category : Nextjs Date :

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.

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 revalidate interval, pre-building the most important paths with generateStaticParams and 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. fetch requests are memoised within a render; wrap ORM or CMS SDK loaders in React’s cache() 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.png or generated opengraph-image.tsx in 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-Language on 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 width and height (or fill in a sized container) so space is reserved and nothing shifts.
  • Mark the likely LCP image, usually the hero, with priority so it is preloaded, not lazy-loaded. One or two per page at most.
  • Set sizes on responsive images so the browser does not download oversized files.
  • Allow your CMS or storage bucket in images.remotePatterns so 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 nearest Suspense boundary. 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 noindex or 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.

ItemWhy it mattersHow in Next.js
Rendering choice per routeCrawlers should get complete HTML quicklyServer Components by default; static, revalidate or dynamic per route
Unique title and descriptionDrives relevance and click-through from resultsmetadata export or generateMetadata
Self-referencing canonicalConsolidates duplicate URLsalternates.canonical plus metadataBase
robots.txtKeeps crawlers out of low-value areasapp/robots.ts, driven by environment
XML sitemapsHelps discovery of every indexable URLapp/sitemap.ts; generateSitemaps above 50,000 URLs
Structured dataClarifies page meaning, enables rich resultsJSON-LD <script> in a Server Component
One URL formatAvoids duplicate slash/no-slash versionstrailingSlash in next.config
Permanent redirectsPreserves rankings when URLs changeredirects() in next.config, or middleware at scale
hreflangServes the right language or region versionalternates.languages with an [lang] segment
Real 404sStops missing records being indexednotFound(), not-found.tsx, dynamicParams = false
No soft 404sThin pages weaken the whole sectionContent threshold: 404, noindex or index
Stable, fast imagesLCP and CLSnext/image with dimensions, sizes and priority
Crawlable internal linksDiscovery and link equitynext/link, breadcrumbs, hub pages, real pagination
Content in the server HTMLContent that needs JavaScript may be missed or delayedFetch 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

Deepak Chauhan About Deepak Chauhan I am a technology strategist at VOCSO with 20 years of experience in full-stack development. Specializing in Python, the MERN stack, Node.js, and Next.js, I architect scalable, high-performance applications and custom solutions. I excel at transforming ideas into innovative digital products that drive business success.


Leave a Reply

Your email address will not be published. Required fields are marked *

Further Reading...

We use cookies to give you the best online experience. By using our website you agree to use of cookies in accordance with VOCSO cookie policy. I Accept Cookies