{"id":34153,"date":"2026-09-24T10:30:30","date_gmt":"2026-09-24T05:00:30","guid":{"rendered":"https:\/\/www.vocso.com\/blog\/?p=34153"},"modified":"2026-09-24T10:40:46","modified_gmt":"2026-09-24T05:10:46","slug":"nextjs-seo-checklist","status":"publish","type":"post","link":"https:\/\/www.vocso.com\/blog\/nextjs-seo-checklist\/","title":{"rendered":"Next.js SEO: A Technical Checklist for the App Router"},"content":{"rendered":"<div style=\"margin-top: 0px; margin-bottom: 0px;\" class=\"sharethis-inline-share-buttons\" ><\/div>\n<p>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.<\/p>\n\n\n\n<p>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 <a href=\"https:\/\/www.vocso.com\/blog\/nextjs-vs-gatsby-vs-nuxtjs-which-one-is-best-for-seo\/\">Next.js, Gatsby and Nuxt for SEO<\/a> first.<\/p>\n\n\n\n<div id=\"ez-toc-container\" class=\"ez-toc-v2_0_81 counter-hierarchy ez-toc-counter ez-toc-grey ez-toc-container-direction\">\n<div class=\"ez-toc-title-container\">\n<p class=\"ez-toc-title ez-toc-toggle\" style=\"cursor:pointer\">Table of Contents<\/p>\n<span class=\"ez-toc-title-toggle\"><\/span><\/div>\n<nav><ul class='ez-toc-list ez-toc-list-level-1 ' ><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-1\" href=\"https:\/\/www.vocso.com\/blog\/nextjs-seo-checklist\/#1-choose-a-rendering-strategy-per-page-type\" >1. Choose a rendering strategy per page type<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-2\" href=\"https:\/\/www.vocso.com\/blog\/nextjs-seo-checklist\/#2-titles-descriptions-and-canonicals-with-the-metadata-api\" >2. Titles, descriptions and canonicals with the Metadata API<\/a><ul class='ez-toc-list-level-3' ><li class='ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-3\" href=\"https:\/\/www.vocso.com\/blog\/nextjs-seo-checklist\/#set-defaults-once-in-the-root-layout\" >Set defaults once in the root layout<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-4\" href=\"https:\/\/www.vocso.com\/blog\/nextjs-seo-checklist\/#generate-per-page-metadata-from-your-data\" >Generate per-page metadata from your data<\/a><\/li><\/ul><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-5\" href=\"https:\/\/www.vocso.com\/blog\/nextjs-seo-checklist\/#3-robotsts-and-sitemapts\" >3. robots.ts and sitemap.ts<\/a><ul class='ez-toc-list-level-3' ><li class='ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-6\" href=\"https:\/\/www.vocso.com\/blog\/nextjs-seo-checklist\/#large-and-programmatic-sitemaps\" >Large and programmatic sitemaps<\/a><\/li><\/ul><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-7\" href=\"https:\/\/www.vocso.com\/blog\/nextjs-seo-checklist\/#4-structured-data-as-json-ld-in-a-server-component\" >4. Structured data as JSON-LD in a Server Component<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-8\" href=\"https:\/\/www.vocso.com\/blog\/nextjs-seo-checklist\/#5-trailing-slashes-and-redirects-in-nextconfig\" >5. Trailing slashes and redirects in next.config<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-9\" href=\"https:\/\/www.vocso.com\/blog\/nextjs-seo-checklist\/#6-internationalisation-and-hreflang\" >6. Internationalisation and hreflang<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-10\" href=\"https:\/\/www.vocso.com\/blog\/nextjs-seo-checklist\/#7-404s-notfound-and-soft-404s-on-programmatic-pages\" >7. 404s, notFound() and soft 404s on programmatic pages<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-11\" href=\"https:\/\/www.vocso.com\/blog\/nextjs-seo-checklist\/#8-nextimage-and-nextfont-for-core-web-vitals\" >8. next\/image and next\/font for Core Web Vitals<\/a><ul class='ez-toc-list-level-3' ><li class='ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-12\" href=\"https:\/\/www.vocso.com\/blog\/nextjs-seo-checklist\/#nextimage\" >next\/image<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-13\" href=\"https:\/\/www.vocso.com\/blog\/nextjs-seo-checklist\/#nextfont\" >next\/font<\/a><\/li><\/ul><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-14\" href=\"https:\/\/www.vocso.com\/blog\/nextjs-seo-checklist\/#9-internal-linking-with-nextlink\" >9. Internal linking with next\/link<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-15\" href=\"https:\/\/www.vocso.com\/blog\/nextjs-seo-checklist\/#10-do-not-render-indexable-content-only-on-the-client\" >10. Do not render indexable content only on the client<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-16\" href=\"https:\/\/www.vocso.com\/blog\/nextjs-seo-checklist\/#11-what-we-learned-building-programmatic-seo-portals\" >11. What we learned building programmatic SEO portals<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-17\" href=\"https:\/\/www.vocso.com\/blog\/nextjs-seo-checklist\/#the-nextjs-seo-checklist\" >The Next.js SEO checklist<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-18\" href=\"https:\/\/www.vocso.com\/blog\/nextjs-seo-checklist\/#how-vocso-can-help\" >How VOCSO can help<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-19\" href=\"https:\/\/www.vocso.com\/blog\/nextjs-seo-checklist\/#frequently-asked-questions\" >Frequently asked questions<\/a><ul class='ez-toc-list-level-3' ><li class='ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-20\" href=\"https:\/\/www.vocso.com\/blog\/nextjs-seo-checklist\/#is-nextjs-good-for-seo\" >Is Next.js good for SEO?<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-21\" href=\"https:\/\/www.vocso.com\/blog\/nextjs-seo-checklist\/#do-i-still-need-nexthead-in-the-app-router\" >Do I still need next\/head in the App Router?<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-22\" href=\"https:\/\/www.vocso.com\/blog\/nextjs-seo-checklist\/#does-using-%e2%80%98use-client-hurt-seo\" >Does using &#8216;use client&#8217; hurt SEO?<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-23\" href=\"https:\/\/www.vocso.com\/blog\/nextjs-seo-checklist\/#how-do-i-handle-sitemaps-for-more-than-50000-pages\" >How do I handle sitemaps for more than 50,000 pages?<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-24\" href=\"https:\/\/www.vocso.com\/blog\/nextjs-seo-checklist\/#should-filter-and-search-pages-be-indexed\" >Should filter and search pages be indexed?<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-25\" href=\"https:\/\/www.vocso.com\/blog\/nextjs-seo-checklist\/#related-reading\" >Related reading<\/a><\/li><\/ul><\/li><\/ul><\/nav><\/div>\n<h2 class=\"wp-block-heading\"><span class=\"ez-toc-section\" id=\"1-choose-a-rendering-strategy-per-page-type\"><\/span>1. Choose a rendering strategy per page type<span class=\"ez-toc-section-end\"><\/span><\/h2>\n\n\n\n<p>Components are Server Components by default, so the real decision is <em>when<\/em> HTML is produced: at build time, on a schedule (ISR) or per request. We compare the trade-offs in <a href=\"https:\/\/www.vocso.com\/blog\/static-site-generation-ssg-vs-server-side-rendering-ssr-in-nextjs-which-is-best-for-seo\/\">SSG vs SSR in Next.js for SEO<\/a>; the practical mapping is:<\/p>\n\n\n\n<ul class=\"wp-block-list\"><li><strong>Marketing pages and blog posts:<\/strong> static.<\/li><li><strong>Large catalogues and programmatic pages<\/strong> (listings, locations, product or institution profiles): static with a <code>revalidate<\/code> interval, pre-building the most important paths with <code>generateStaticParams<\/code> and generating the rest on first request.<\/li><li><strong>Search results, dashboards, anything personalised:<\/strong> dynamic, and usually not meant to be indexed at all.<\/li><\/ul>\n\n\n\n<p>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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><span class=\"ez-toc-section\" id=\"2-titles-descriptions-and-canonicals-with-the-metadata-api\"><\/span>2. Titles, descriptions and canonicals with the Metadata API<span class=\"ez-toc-section-end\"><\/span><\/h2>\n\n\n\n<p>Export a static <code>metadata<\/code> object for fixed pages, or a <code>generateMetadata<\/code> function when the title depends on data. The tags are rendered into the server HTML, so crawlers see them without running JavaScript.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><span class=\"ez-toc-section\" id=\"set-defaults-once-in-the-root-layout\"><\/span>Set defaults once in the root layout<span class=\"ez-toc-section-end\"><\/span><\/h3>\n\n\n\n<p>In <code>app\/layout.tsx<\/code>, set <code>metadataBase<\/code> (for example <code>new URL('https:\/\/www.example.com')<\/code>) so relative canonical and Open Graph URLs resolve to your production domain, and a <code>title.template<\/code> such as <code>'%s | Brand'<\/code> for a consistent suffix.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><span class=\"ez-toc-section\" id=\"generate-per-page-metadata-from-your-data\"><\/span>Generate per-page metadata from your data<span class=\"ez-toc-section-end\"><\/span><\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ app\/colleges\/[slug]\/page.tsx\nimport type { Metadata } from 'next';\nimport { notFound } from 'next\/navigation';\n\ntype Props = { params: Promise&lt;{ slug: string }&gt; };\n\nexport async function generateMetadata({ params }: Props): Promise&lt;Metadata&gt; {\n  const { slug } = await params;\n  const college = await getCollege(slug); \/\/ same cached fetch the page uses\n  if (!college) notFound();\n\n  return {\n    title: `${college.name}: courses, fees and admissions`,\n    description: college.summary.slice(0, 155),\n    alternates: { canonical: `\/colleges\/${slug}` },\n    openGraph: {\n      title: college.name,\n      url: `\/colleges\/${slug}`,\n      images: [{ url: college.imageUrl, width: 1200, height: 630 }],\n    },\n  };\n}<\/code><\/pre>\n\n\n\n<p>A few rules that save rework later:<\/p>\n\n\n\n<ul class=\"wp-block-list\"><li><strong>Every indexable page gets a self-referencing canonical<\/strong> via <code>alternates.canonical<\/code>, the simplest defence against duplicates from tracking parameters, filters and sort orders.<\/li><li><strong>Titles and descriptions must be unique.<\/strong> On programmatic pages, build them from the fields that make each page different, not one template sentence.<\/li><li><strong>Deduplicate data calls.<\/strong> <code>fetch<\/code> requests are memoised within a render; wrap ORM or CMS SDK loaders in React&#8217;s <code>cache()<\/code> so metadata and page share one query.<\/li><li><strong>Use <code>robots: { index: false }<\/code><\/strong> for pages meant for users but not search, such as internal search results.<\/li><li><strong>Open Graph images:<\/strong> an <code>opengraph-image.png<\/code> or generated <code>opengraph-image.tsx<\/code> in a route folder is picked up automatically.<\/li><\/ul>\n\n\n\n<h2 class=\"wp-block-heading\"><span class=\"ez-toc-section\" id=\"3-robotsts-and-sitemapts\"><\/span>3. robots.ts and sitemap.ts<span class=\"ez-toc-section-end\"><\/span><\/h2>\n\n\n\n<p>Both live in <code>app\/<\/code> and are served as <code>\/robots.txt<\/code> and <code>\/sitemap.xml<\/code>, generated in step with your routes and CMS.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ app\/robots.ts\nimport type { MetadataRoute } from 'next';\n\nexport default function robots(): MetadataRoute.Robots {\n  return {\n    rules: [{ userAgent: '*', allow: '\/', disallow: ['\/api\/', '\/search'] }],\n    sitemap: 'https:\/\/www.example.com\/sitemap.xml',\n  };\n}<\/code><\/pre>\n\n\n\n<p>A classic launch-day mistake is shipping the staging rule (<code>disallow: '\/'<\/code>) to production. Drive the rules from an environment variable.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><span class=\"ez-toc-section\" id=\"large-and-programmatic-sitemaps\"><\/span>Large and programmatic sitemaps<span class=\"ez-toc-section-end\"><\/span><\/h3>\n\n\n\n<p>A single sitemap file is limited to 50,000 URLs. For bigger sites, <code>generateSitemaps<\/code> splits the output into several files, each at its own URL (below, <code>\/colleges\/sitemap\/0.xml<\/code>, <code>\/colleges\/sitemap\/1.xml<\/code> and so on).<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ app\/colleges\/sitemap.ts\nimport type { MetadataRoute } from 'next';\n\nconst PER_FILE = 45000;\n\nexport async function generateSitemaps() {\n  const total = await countColleges();\n  return Array.from({ length: Math.ceil(total \/ PER_FILE) }, (_, id) =&gt; ({ id }));\n}\n\nexport default async function sitemap({ id }: { id: number }): Promise&lt;MetadataRoute.Sitemap&gt; {\n  const rows = await getCollegeSlugs({ offset: id * PER_FILE, limit: PER_FILE });\n  return rows.map((r) =&gt; ({\n    url: `https:\/\/www.example.com\/colleges\/${r.slug}`,\n    lastModified: r.updatedAt,\n  }));\n}<\/code><\/pre>\n\n\n\n<p>List each generated sitemap in <code>robots.ts<\/code> or a sitemap index and submit them in Search Console. Include only URLs that return 200, are self-canonical and are not <code>noindex<\/code>. Use a real <code>lastModified<\/code> from your data, not the build time, or every URL looks freshly changed on every deploy.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><span class=\"ez-toc-section\" id=\"4-structured-data-as-json-ld-in-a-server-component\"><\/span>4. Structured data as JSON-LD in a Server Component<span class=\"ez-toc-section-end\"><\/span><\/h2>\n\n\n\n<p>Structured data helps search engines understand a page and can make it eligible for rich results. Render it as a <code>&lt;script type=\"application\/ld+json\"&gt;<\/code> tag in the page&#8217;s Server Component: it ships in the initial HTML and adds nothing to the client bundle.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ app\/colleges\/[slug]\/page.tsx (inside the page component)\nconst jsonLd = {\n  '@context': 'https:\/\/schema.org',\n  '@type': 'CollegeOrUniversity',\n  name: college.name,\n  url: `https:\/\/www.example.com\/colleges\/${college.slug}`,\n  address: { '@type': 'PostalAddress', addressLocality: college.city },\n};\n\nreturn (\n  &lt;&gt;\n    &lt;script\n      type=\"application\/ld+json\"\n      dangerouslySetInnerHTML={{\n        __html: JSON.stringify(jsonLd).replace(\/&lt;\/g, '\\\\u003c'),\n      }}\n    \/&gt;\n    {\/* page content *\/}\n  &lt;\/&gt;\n);<\/code><\/pre>\n\n\n\n<p>The <code>.replace()<\/code> stops CMS content breaking out of the script tag. Structured data must describe content visible on the page. Useful types include <code>Organization<\/code> (in the root layout), <code>BreadcrumbList<\/code>, <code>Article<\/code> and <code>Product<\/code>. Validate with Google&#8217;s Rich Results Test.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><span class=\"ez-toc-section\" id=\"5-trailing-slashes-and-redirects-in-nextconfig\"><\/span>5. Trailing slashes and redirects in next.config<span class=\"ez-toc-section-end\"><\/span><\/h2>\n\n\n\n<p>By default Next.js redirects <code>\/about\/<\/code> to <code>\/about<\/code>; <code>trailingSlash: true<\/code> reverses that. Either is fine, as long as only one version returns 200 and your canonicals, sitemap and links all match it.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ next.config.ts\nimport type { NextConfig } from 'next';\n\nconst nextConfig: NextConfig = {\n  trailingSlash: false,\n  async redirects() {\n    return [\n      { source: '\/blog\/old-post', destination: '\/blog\/new-post', permanent: true },\n      { source: '\/courses\/:slug', destination: '\/programmes\/:slug', permanent: true },\n    ];\n  },\n};\n\nexport default nextConfig;<\/code><\/pre>\n\n\n\n<p><code>permanent: true<\/code> returns a 308, which search engines treat as a permanent move. When <a href=\"https:\/\/www.vocso.com\/blog\/migrating-to-next-js-from-reactjs\/\">migrating a React site to Next.js<\/a> 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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><span class=\"ez-toc-section\" id=\"6-internationalisation-and-hreflang\"><\/span>6. Internationalisation and hreflang<span class=\"ez-toc-section-end\"><\/span><\/h2>\n\n\n\n<p>The App Router does not use the Pages Router <code>i18n<\/code> config. Put the locale in the URL with a segment such as <code>app\/[lang]\/<\/code>, use middleware to route first-time visitors, and declare hreflang in <code>generateMetadata<\/code> with <code>alternates.languages<\/code>, for example <code>{ 'en-GB': '\/en-gb\/pricing', 'x-default': '\/en-us\/pricing' }<\/code>.<\/p>\n\n\n\n<ul class=\"wp-block-list\"><li>Each language version canonicalises to itself, not to the default language.<\/li><li>hreflang must be reciprocal: every version lists every other version, including itself.<\/li><li>Do not redirect by IP or <code>Accept-Language<\/code> on every request; every localised URL must be reachable directly.<\/li><\/ul>\n\n\n\n<h2 class=\"wp-block-heading\"><span class=\"ez-toc-section\" id=\"7-404s-notfound-and-soft-404s-on-programmatic-pages\"><\/span>7. 404s, notFound() and soft 404s on programmatic pages<span class=\"ez-toc-section-end\"><\/span><\/h2>\n\n\n\n<p>For a record that does not exist, call <code>notFound()<\/code> from <code>next\/navigation<\/code>: Next.js renders the nearest <code>not-found.tsx<\/code> with a 404 status. Call it early, before any <code>Suspense<\/code> boundary; once streaming has started the status is already sent, and Next.js can only add a <code>noindex<\/code> tag.<\/p>\n\n\n\n<p>If you pre-build every valid path, <code>export const dynamicParams = false<\/code> turns any other value into a 404.<\/p>\n\n\n\n<p>The harder problem is the <strong>soft 404<\/strong>: a 200 page with nothing useful on it, such as &#8220;schools in [city]&#8221; for a city with no schools. These dilute the quality of the whole section. Set a threshold in code:<\/p>\n\n\n\n<ul class=\"wp-block-list\"><li>No records at all: <code>notFound()<\/code>.<\/li><li>Too few to be useful: render for users, set <code>robots: { index: false }<\/code> and leave it out of the sitemap.<\/li><li>Enough unique content: index it.<\/li><\/ul>\n\n\n\n<h2 class=\"wp-block-heading\"><span class=\"ez-toc-section\" id=\"8-nextimage-and-nextfont-for-core-web-vitals\"><\/span>8. next\/image and next\/font for Core Web Vitals<span class=\"ez-toc-section-end\"><\/span><\/h2>\n\n\n\n<p>Core Web Vitals (LCP, INP and CLS) are part of how Google assesses page experience. Two built-in components do most of the work.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><span class=\"ez-toc-section\" id=\"nextimage\"><\/span>next\/image<span class=\"ez-toc-section-end\"><\/span><\/h3>\n\n\n\n<ul class=\"wp-block-list\"><li>Give <code>width<\/code> and <code>height<\/code> (or <code>fill<\/code> in a sized container) so space is reserved and nothing shifts.<\/li><li>Mark the likely LCP image, usually the hero, with <code>priority<\/code> so it is preloaded, not lazy-loaded. One or two per page at most.<\/li><li>Set <code>sizes<\/code> on responsive images so the browser does not download oversized files.<\/li><li>Allow your CMS or storage bucket in <code>images.remotePatterns<\/code> so remote images are optimised too.<\/li><\/ul>\n\n\n\n<h3 class=\"wp-block-heading\"><span class=\"ez-toc-section\" id=\"nextfont\"><\/span>next\/font<span class=\"ez-toc-section-end\"><\/span><\/h3>\n\n\n\n<p><code>next\/font<\/code> 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.<\/p>\n\n\n\n<p>For INP, ship less client JavaScript: keep <code>'use client'<\/code> at the leaves of the tree, not on whole pages, and defer third-party scripts with <code>next\/script<\/code>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><span class=\"ez-toc-section\" id=\"9-internal-linking-with-nextlink\"><\/span>9. Internal linking with next\/link<span class=\"ez-toc-section-end\"><\/span><\/h2>\n\n\n\n<p><code>&lt;Link&gt;<\/code> renders a real <code>&lt;a href&gt;<\/code> in the server HTML, which crawlers follow, and prefetches the destination. Avoid <code>onClick<\/code> handlers calling <code>router.push()<\/code> for navigation: crawlers cannot follow them.<\/p>\n\n\n\n<p>On large sites, internal links are how new pages get discovered. Build them into templates: breadcrumbs, hub pages linking to children, &#8220;related&#8221; modules between siblings, and pagination with real links rather than infinite scroll alone.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><span class=\"ez-toc-section\" id=\"10-do-not-render-indexable-content-only-on-the-client\"><\/span>10. Do not render indexable content only on the client<span class=\"ez-toc-section-end\"><\/span><\/h2>\n\n\n\n<p>A <code>'use client'<\/code> component is still rendered to HTML on the server, so the directive is not the problem. The problem is <em>content that only appears after the browser runs JavaScript<\/em>. Watch for:<\/p>\n\n\n\n<ul class=\"wp-block-list\"><li>Data fetched in <code>useEffect<\/code>: the server HTML contains a spinner. Fetch in a Server Component and pass props down.<\/li><li><code>dynamic(() =&gt; import(...), { ssr: false })<\/code> wrapped around anything a searcher should find.<\/li><li>Tabs and accordions whose content is not in the DOM until clicked. Render it and hide it with CSS.<\/li><li><code>useSearchParams()<\/code> on a static page, which client-renders everything up to the nearest <code>Suspense<\/code> boundary. Keep that boundary tight.<\/li><\/ul>\n\n\n\n<p>Quick check: fetch the page with <code>curl<\/code> and confirm headings, copy, links and JSON-LD are in the raw HTML. Search Console&#8217;s URL Inspection shows what Google rendered.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><span class=\"ez-toc-section\" id=\"11-what-we-learned-building-programmatic-seo-portals\"><\/span>11. What we learned building programmatic SEO portals<span class=\"ez-toc-section-end\"><\/span><\/h2>\n\n\n\n<p>Two of our Next.js projects were built around programmatic SEO. <a href=\"https:\/\/www.vocso.com\/case-study\/colleges18-college-discovery-web-application-development-with-strapi-nextjs\">Colleges18<\/a> is a college discovery portal with a server-rendered Next.js front end, a <a href=\"https:\/\/www.vocso.com\/headless-cms-development-services\">headless CMS<\/a> setup of Strapi and a Node.js back end, dynamic search and filtering, AWS S3 for assets and OpenAI for content generation. <a href=\"https:\/\/www.vocso.com\/case-study\/schools18-schools-search-website-application-development\">Schools18<\/a> 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.<\/p>\n\n\n\n<p>Lessons that carry over:<\/p>\n\n\n\n<ul class=\"wp-block-list\"><li><strong>Model the data for the page.<\/strong> Unique titles and introductions need structured CMS fields; with only a name and address, every page reads the same. See <a href=\"https:\/\/www.vocso.com\/blog\/why-strapi-is-the-headless-cms-of-choice-for-building-modern-web-applications\/\">our post on Strapi as a headless CMS<\/a>.<\/li><li><strong>Decide which filter pages to index.<\/strong> Filters create near-infinite URL combinations. Give the ones people search for clean paths and their own metadata; keep the rest <code>noindex<\/code> or canonicalised to the parent.<\/li><li><strong>Generated content needs editorial rules.<\/strong> AI-assisted copy helps at scale only when grounded in the record&#8217;s real data and reviewed; otherwise you produce thin pages faster.<\/li><li><strong>Pre-build the head, generate the tail.<\/strong> Pre-render high-value paths, generate the rest on request with <code>revalidate<\/code>, and split the sitemap.<\/li><li><strong>Performance is a template problem.<\/strong> A slow component on a detail template repeats on every page, so measure templates, not just the home page.<\/li><\/ul>\n\n\n\n<h2 class=\"wp-block-heading\"><span class=\"ez-toc-section\" id=\"the-nextjs-seo-checklist\"><\/span>The Next.js SEO checklist<span class=\"ez-toc-section-end\"><\/span><\/h2>\n\n\n\n<p>Use this as a pre-launch or audit checklist.<\/p>\n\n\n\n<figure class=\"wp-block-table table table-bordered\"><table><tbody><tr><td><strong>Item<\/strong><\/td><td><strong>Why it matters<\/strong><\/td><td><strong>How in Next.js<\/strong><\/td><\/tr><tr><td>Rendering choice per route<\/td><td>Crawlers should get complete HTML quickly<\/td><td>Server Components by default; static, <code>revalidate<\/code> or dynamic per route<\/td><\/tr><tr><td>Unique title and description<\/td><td>Drives relevance and click-through from results<\/td><td><code>metadata<\/code> export or <code>generateMetadata<\/code><\/td><\/tr><tr><td>Self-referencing canonical<\/td><td>Consolidates duplicate URLs<\/td><td><code>alternates.canonical<\/code> plus <code>metadataBase<\/code><\/td><\/tr><tr><td>robots.txt<\/td><td>Keeps crawlers out of low-value areas<\/td><td><code>app\/robots.ts<\/code>, driven by environment<\/td><\/tr><tr><td>XML sitemaps<\/td><td>Helps discovery of every indexable URL<\/td><td><code>app\/sitemap.ts<\/code>; <code>generateSitemaps<\/code> above 50,000 URLs<\/td><\/tr><tr><td>Structured data<\/td><td>Clarifies page meaning, enables rich results<\/td><td>JSON-LD <code>&lt;script&gt;<\/code> in a Server Component<\/td><\/tr><tr><td>One URL format<\/td><td>Avoids duplicate slash\/no-slash versions<\/td><td><code>trailingSlash<\/code> in <code>next.config<\/code><\/td><\/tr><tr><td>Permanent redirects<\/td><td>Preserves rankings when URLs change<\/td><td><code>redirects()<\/code> in <code>next.config<\/code>, or middleware at scale<\/td><\/tr><tr><td>hreflang<\/td><td>Serves the right language or region version<\/td><td><code>alternates.languages<\/code> with an <code>[lang]<\/code> segment<\/td><\/tr><tr><td>Real 404s<\/td><td>Stops missing records being indexed<\/td><td><code>notFound()<\/code>, <code>not-found.tsx<\/code>, <code>dynamicParams = false<\/code><\/td><\/tr><tr><td>No soft 404s<\/td><td>Thin pages weaken the whole section<\/td><td>Content threshold: 404, <code>noindex<\/code> or index<\/td><\/tr><tr><td>Stable, fast images<\/td><td>LCP and CLS<\/td><td><code>next\/image<\/code> with dimensions, <code>sizes<\/code> and <code>priority<\/code><\/td><\/tr><tr><td>Crawlable internal links<\/td><td>Discovery and link equity<\/td><td><code>next\/link<\/code>, breadcrumbs, hub pages, real pagination<\/td><\/tr><tr><td>Content in the server HTML<\/td><td>Content that needs JavaScript may be missed or delayed<\/td><td>Fetch in Server Components; avoid <code>ssr: false<\/code> for key content<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\"><span class=\"ez-toc-section\" id=\"how-vocso-can-help\"><\/span>How VOCSO can help<span class=\"ez-toc-section-end\"><\/span><\/h2>\n\n\n\n<p>VOCSO has built software since 2009 across 600+ projects. Our <a href=\"https:\/\/www.vocso.com\/nextjs-development-services\">Next.js development services<\/a> 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, <a href=\"https:\/\/www.vocso.com\/contact-us\">get in touch with our team<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><span class=\"ez-toc-section\" id=\"frequently-asked-questions\"><\/span>Frequently asked questions<span class=\"ez-toc-section-end\"><\/span><\/h2>\n\n\n\n<h3 class=\"wp-block-heading\"><span class=\"ez-toc-section\" id=\"is-nextjs-good-for-seo\"><\/span>Is Next.js good for SEO?<span class=\"ez-toc-section-end\"><\/span><\/h3>\n\n\n\n<p>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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><span class=\"ez-toc-section\" id=\"do-i-still-need-nexthead-in-the-app-router\"><\/span>Do I still need next\/head in the App Router?<span class=\"ez-toc-section-end\"><\/span><\/h3>\n\n\n\n<p>No. <code>next\/head<\/code> is a Pages Router API. In <code>app\/<\/code>, the <code>metadata<\/code> export and <code>generateMetadata<\/code> handle titles, descriptions, canonicals, Open Graph, robots directives and hreflang.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><span class=\"ez-toc-section\" id=\"does-using-%e2%80%98use-client-hurt-seo\"><\/span>Does using &#8216;use client&#8217; hurt SEO?<span class=\"ez-toc-section-end\"><\/span><\/h3>\n\n\n\n<p>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 <code>useEffect<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><span class=\"ez-toc-section\" id=\"how-do-i-handle-sitemaps-for-more-than-50000-pages\"><\/span>How do I handle sitemaps for more than 50,000 pages?<span class=\"ez-toc-section-end\"><\/span><\/h3>\n\n\n\n<p>Use <code>generateSitemaps<\/code> in <code>sitemap.ts<\/code> to split URLs across files, list each in <code>robots.ts<\/code> or a sitemap index, and submit them in Search Console.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><span class=\"ez-toc-section\" id=\"should-filter-and-search-pages-be-indexed\"><\/span>Should filter and search pages be indexed?<span class=\"ez-toc-section-end\"><\/span><\/h3>\n\n\n\n<p>Internal search results should not be. Filter pages can be, if people search for that combination and the page has enough unique content.<\/p>\n\n\n<!-- vocso-related -->\n\n<h3 class=\"wp-block-heading\"><span class=\"ez-toc-section\" id=\"related-reading\"><\/span>Related reading<span class=\"ez-toc-section-end\"><\/span><\/h3>\n\n\n\n<ul class=\"wp-block-list\"><li><a href=\"https:\/\/www.vocso.com\/blog\/static-site-generation-ssg-vs-server-side-rendering-ssr-in-nextjs-which-is-best-for-seo\/\">Static Site Generation (SSG) vs Server-Side Rendering (SSR) in NextJS: Which is Best for SEO?<\/a><\/li><li><a href=\"https:\/\/www.vocso.com\/blog\/nextjs-vs-reactjs-when-to-use-each-and-why\/\">NextJS vs ReactJS: When to Use Each and Why<\/a><\/li><li><a href=\"https:\/\/www.vocso.com\/blog\/migrating-to-next-js-from-reactjs\/\">Migrating to NextJs: Seamless Transition from ReactJS to NextJs<\/a><\/li><\/ul>\n\n<!-- \/vocso-related -->","protected":false},"excerpt":{"rendered":"<p>A practical Next.js SEO checklist for the App Router, covering metadata, sitemaps, structured data, redirects, hreflang, 404 handling and Core Web Vitals, with code for each step.<\/p>\n","protected":false},"author":23,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[1415],"tags":[],"class_list":["post-34153","post","type-post","status-publish","format-standard","hentry","category-nextjs"],"aioseo_notices":[],"_links":{"self":[{"href":"https:\/\/www.vocso.com\/blog\/wp-json\/wp\/v2\/posts\/34153","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.vocso.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.vocso.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.vocso.com\/blog\/wp-json\/wp\/v2\/users\/23"}],"replies":[{"embeddable":true,"href":"https:\/\/www.vocso.com\/blog\/wp-json\/wp\/v2\/comments?post=34153"}],"version-history":[{"count":2,"href":"https:\/\/www.vocso.com\/blog\/wp-json\/wp\/v2\/posts\/34153\/revisions"}],"predecessor-version":[{"id":34311,"href":"https:\/\/www.vocso.com\/blog\/wp-json\/wp\/v2\/posts\/34153\/revisions\/34311"}],"wp:attachment":[{"href":"https:\/\/www.vocso.com\/blog\/wp-json\/wp\/v2\/media?parent=34153"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.vocso.com\/blog\/wp-json\/wp\/v2\/categories?post=34153"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.vocso.com\/blog\/wp-json\/wp\/v2\/tags?post=34153"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}