Next.js SEO: Sitemap, Canonical URLs and JSON-LD

•Updated •Pate Bryant

Next.js renders HTML on the server, so search engines can read your pages without running JavaScript. That covers the hard part of SEO. What the framework does not do is tell Google which URL is the real one, which pages exist, or what each page is about. You have to add that yourself.

I learned this on this site. Search Console listed 46 of my URLs as "Discovered – currently not indexed", and the cause was a one-line mistake in the sitemap config that this post used to recommend. Here is the checklist I ended up with, using the code that runs on this site (Next.js 13, Pages Router).

The Next.js SEO checklist

  1. A sitemap on your canonical domain. Every URL in it should return 200, not redirect.
  2. A canonical tag on every page, pointing at the one URL you want indexed.
  3. A title and description per page, plus Open Graph and Twitter tags for link previews.
  4. JSON-LD structured data that says what the page is: an article, a person, a tool.
  5. noindex on pages that should not rank, such as demos, admin pages and thin pages.
  6. Check the result in Search Console instead of assuming it worked.
next-sitemap logo

1. Generate a sitemap with next-sitemap

next-sitemap builds sitemap.xml and robots.txt from your built pages. Install it:

npm install next-sitemap

Run it after every build by adding a postbuild script to package.json. npm runs postbuild automatically when build finishes, so this also runs on Vercel. (So does Yarn 1. pnpm and Yarn 2+ skip post scripts, so with those, append && next-sitemap to your build script instead.)

{
  "scripts": {
    "postbuild": "next-sitemap"
  }
}

Then create next-sitemap.config.js in the project root. Here is the version this site uses now:

const fs = require('fs')
const path = require('path')

function articleDate(loc) {
  const slug = loc.match(/^\/articles\/([^/]+)$/)?.[1]
  const file =
    slug && path.join(process.cwd(), 'src/pages/articles', slug, 'index.mdx')
  if (!file || !fs.existsSync(file)) return undefined
  const source = fs.readFileSync(file, 'utf8')
  const date =
    source.match(/^\s*updated:\s*['"](\d{4}-\d{2}-\d{2})['"]/m) ??
    source.match(/^\s*date:\s*['"](\d{4}-\d{2}-\d{2})['"]/m)
  return date ? new Date(date[1]).toISOString() : undefined
}

/** @type {import('next-sitemap').IConfig} */
module.exports = {
  siteUrl: 'https://www.patebryant.com',
  generateRobotsTxt: true,
  exclude: ['/projects/*', '/admin'],
  robotsTxtOptions: {
    policies: [{ userAgent: '*', allow: '/', disallow: ['/admin'] }],
  },
  transform: async (config, loc) => ({ loc, lastmod: articleDate(loc) }),
}

Three parts of this config matter for SEO.

siteUrl must be the domain you actually serve. The first version of this post used:

siteUrl: process.env.NEXT_PUBLIC_SITE_URL || 'https://patebryant.com',

The site is served from www.patebryant.com, and the apex domain answers with a 308 redirect to www. My environment variable was set to the apex, so every URL in the sitemap pointed at a redirect. A sitemap full of redirecting URLs tells Google those are not the canonical pages, and Google left all 46 of them "Discovered – currently not indexed" rather than crawling them. Hard-coding the www origin is the fix. Check yours with curl. A sitemap URL should come back 200, not 301, 308 or 404:

curl -sI https://patebryant.com/articles/nextjs-sitemap-example | head -1
# HTTP/2 308  <- this one redirects, so it does not belong in the sitemap

lastmod should be a real date. By default next-sitemap stamps every URL with the build time, which says every page changed on every deploy. Google ignores lastmod values it learns not to trust. The transform above reads each article's updated date from its MDX file, falling back to its publish date, so lastmod only moves when a post actually changes. The same updated date feeds dateModified in the JSON-LD below. I also dropped changefreq and priority, which Google says it ignores.

exclude keeps pages out of the sitemap. That does not keep them out of Google, though. Section 4 covers that.

The sitemap files are generated on every build, so keep them out of git:

public/robots.txt
public/sitemap*.xml

2. Add canonical, title and Open Graph tags

In the Pages Router, page metadata goes in next/head. This site has one Seo component that every page renders. Here is the core of it:

import Head from 'next/head'
import { useRouter } from 'next/router'

import { SITE_URL } from '@/lib/site'

export function Seo({ title, description }) {
  const path = useRouter().asPath.split(/[?#]/)[0]
  const url = path === '/' ? SITE_URL : `${SITE_URL}${path}`

  return (
    <Head>
      <title>{title}</title>
      <meta key="description" name="description" content={description} />
      <link key="canonical" rel="canonical" href={url} />
      <meta key="og:title" property="og:title" content={title} />
      <meta key="og:url" property="og:url" content={url} />
      <meta key="twitter:card" name="twitter:card" content="summary_large_image" />
    </Head>
  )
}

A few details are easy to get wrong:

  • Build the canonical URL from a fixed origin, never from the request host. Otherwise a visit to a preview deployment or the apex domain produces a canonical pointing at the wrong domain.
  • Strip the query string and hash from asPath, so ?utm_source=x does not become a separate canonical URL.
  • Give each tag a key. next/head dedupes <title> and name= meta tags on its own, but not property= tags (all of Open Graph) or <link rel="canonical">. Without a key, a page that overrides one of those gets duplicate tags instead of a replacement.
  • Use a PNG or JPEG for og:image. Not every platform renders a GIF preview, so this site falls back to a default 1200×630 PNG when an article's image is anything else, such as a GIF or SVG.

3. Add JSON-LD structured data

JSON-LD tells search engines what kind of thing a page is. Every article on this site renders a BlogPosting:

export function blogPostingJsonLd(post) {
  return {
    '@context': 'https://schema.org',
    '@type': 'BlogPosting',
    headline: post.title,
    description: post.description,
    datePublished: post.date,
    dateModified: post.updated ?? post.date,
    image: [post.image],
    author,
    publisher: author,
    mainEntityOfPage: { '@type': 'WebPage', '@id': post.url },
  }
}

The home page renders a Person, and the kill port tool renders WebApplication, FAQPage and BreadcrumbList. Render the object in a script tag inside Head:

<script
  key="jsonld"
  type="application/ld+json"
  dangerouslySetInnerHTML={{
    __html: JSON.stringify(jsonLd).replace(/</g, '\\u003c'),
  }}
/>

The .replace(/</g, '\\u003c') matters. If a title or description contains </script>, the unescaped JSON would close the tag early and inject the rest into the page. Escaping < keeps the JSON valid and the HTML safe. Check the output with Google's Rich Results Test.

4. Keep pages out of Google with noindex

Leaving a page out of the sitemap does not stop Google from indexing it if something links to it. To keep a page out, send noindex. The /projects/* demos on this site are static HTML in public/, where there is no React component to render a meta tag, so they get an X-Robots-Tag header from next.config.mjs:

async headers() {
  return [
    {
      source: '/projects/:name/:path*',
      headers: [{ key: 'X-Robots-Tag', value: 'noindex, follow' }],
    },
    {
      source: '/projects/:name',
      headers: [{ key: 'X-Robots-Tag', value: 'noindex, follow' }],
    },
  ]
},

For a React page, a <meta name="robots" content="noindex, follow" /> in Head does the same job. Do not also block the page in robots.txt. If Google cannot crawl the page, it never sees the noindex, and a linked URL can still show up as "Indexed, though blocked by robots.txt". (This site's config above does disallow /admin, so Google never sees its noindex either. It stays out of the index only because nothing links to it.)

5. If you use the App Router

The same checklist applies, but the App Router has built-in APIs for it. Use export const metadata (or generateMetadata) with alternates.canonical instead of next/head, and app/sitemap.ts and app/robots.ts instead of next-sitemap. The siteUrl mistake from section 1 can happen there too: the sitemap URLs are whatever origin you write into sitemap.ts.

6. Check that it worked

Deploying the fix does not change anything in Google until it recrawls the pages. In Google Search Console:

  1. Submit the sitemap under Sitemaps, and check that its URL count matches your page count.
  2. Inspect a URL with URL Inspection. "URL is on Google" means it is indexed. "Discovered – currently not indexed" means Google knows about it but has not crawled it yet.
  3. Click "Request indexing" for important pages that are still not indexed. Google limits these requests per day, so start with the pages that matter most.
  4. Watch the Pages report over the next week or two. The indexed count is a more reliable signal than any single inspection, which can change within the same hour.