10 min read

Next.js Blog SEO Checklist for Markdown Blogs

A practical Next.js blog SEO checklist for Markdown blogs on the App Router: metadata, canonicals, sitemap.ts, JSON-LD, crawlable links and a check you can run after every deploy.

Blog on Next.js? See how Blogger Dog publishes into Next.js.

Why Next.js blogs underperform in search

A Next.js blog built from Markdown files in a GitHub repo can rank well. Most of them do not, and the reason is rarely the writing. It is usually a handful of small technical gaps: every post shares the homepage canonical, the sitemap forgets new posts, the only links to a page live inside a client-side menu, or the structured data says the article was published "today" on every build.

None of these are hard to fix. They are just easy to miss, because a Next.js site looks fine in the browser even when the HTML a crawler receives is thin. This checklist walks through what to verify, in the order it usually matters, with App Router code you can copy. It assumes posts live as .md files in a folder such as content/blog and are rendered at /blog/[slug].

We run this exact checklist on blogger-dog.com, which is a Next.js site with a Markdown blog in its own repo. Several items below come from bugs we found and fixed on our own site this month, so they are not hypothetical.

1. Give every post its own metadata

The App Router builds the <head> from a metadata object or a generateMetadata function. For a Markdown blog, read the frontmatter for the requested slug and return a title, description, canonical URL and Open Graph fields for that post.


// app/blog/[slug]/page.tsx

import type { Metadata } from "next";

import { getBlogPost } from "@/lib/blog-content";



const SITE = "https://example.com";



export function generateMetadata({ params }: { params: { slug: string } }): Metadata {

  const post = getBlogPost(params.slug);

  if (!post) return {};

  const url = `${SITE}/blog/${post.slug}`;

  return {

    title: post.title,

    description: post.description,

    alternates: { canonical: url },

    openGraph: {

      type: "article",

      url,

      title: post.title,

      description: post.description,

      publishedTime: post.date,

    },

  };

}

Three things go wrong here more often than anything else:

  • Duplicated title suffixes. If the root layout sets title: { template: "%s | Brand" } and the page also appends | Brand, you get "Post | Brand | Brand". Let the template add the suffix and return the bare title from the page.
  • Inherited canonicals. If the root layout sets alternates.canonical to /, every page that does not override it tells Google it is a copy of the homepage. Set the canonical per page, or not at all in the layout.
  • Inherited Open Graph. The openGraph object from a page replaces the parent one rather than merging deeply, so a post that sets openGraph without images ends up with no share image. Either include the image in the post metadata or generate one per post (see step 5).

We shipped the first two bugs on our own site and only caught them by reading the rendered HTML, which is why step 8 exists.

2. Use absolute canonical URLs

Google treats rel="canonical" as a strong signal and a sitemap entry as a weak one, and recommends absolute URLs over relative paths in canonical tags, according to its guide on consolidating duplicate URLs. Relative canonicals work, but they misfire when a preview deployment gets crawled and every preview URL claims to be canonical for itself.

Set metadataBase once in the root layout and build canonicals from a single constant for the production origin. Do not derive it from the request host, because Vercel preview URLs would then canonicalize to themselves.


// app/layout.tsx

export const metadata = {

  metadataBase: new URL("https://example.com"),

  title: { default: "Example", template: "%s | Example" },

};

If you retire or rename a post, use a permanent redirect rather than leaving the old file around. Redirects are the strongest canonical signal of all, and they pass the old URL's links to the new page. In next.config.mjs that is a redirects() entry with permanent: true, which returns a 308.

3. Generate the sitemap from the same files that render the posts

A hand-written sitemap.xml drifts the first time someone adds a post and forgets to update it. Next.js supports a sitemap.ts file that exports a function returning an array of URLs, documented in the sitemap file convention. Point it at the same loader your blog pages use, so a post cannot exist without a sitemap entry.


// app/sitemap.ts

import type { MetadataRoute } from "next";

import { getBlogPosts } from "@/lib/blog-content";



const SITE = "https://example.com";



export default function sitemap(): MetadataRoute.Sitemap {

  const posts = getBlogPosts().map((post) => ({

    url: `${SITE}/blog/${post.slug}`,

    lastModified: post.updated ?? post.date,

  }));

  return [

    { url: SITE, lastModified: new Date() },

    { url: `${SITE}/blog` },

    { url: `${SITE}/pricing` },

    ...posts,

  ];

}

Two details matter. First, use the post's real date for lastModified, not new Date(). A lastmod that changes on every deploy teaches crawlers to ignore it. Second, only list URLs that return 200 and are canonical to themselves. Redirected, noindexed or login-only routes do not belong in the sitemap. The Next.js docs note Google's limit of 50,000 URLs per sitemap; a blog will not hit that, but programmatic pages can, and generateSitemaps splits the file when they do.

Then reference the sitemap from app/robots.ts and submit it once in Google Search Console.

4. Add BlogPosting structured data with honest dates

Structured data does not make a page rank, but it removes ambiguity about what the page is, who wrote it and when. That matters for rich results and increasingly for AI answer engines that need to decide which source to cite.

Google's Article structured data documentation supports Article, NewsArticle and BlogPosting, lists no required properties, and recommends author, datePublished, dateModified, headline and image. It also asks for images that represent the content rather than a logo, in 16x9, 4x3 and 1x1 ratios.

The Next.js JSON-LD guide recommends rendering a plain <script type="application/ld+json"> in the page and replacing < with < so a stray string in your content cannot break out of the tag:


const jsonLd = {

  "@context": "https://schema.org",

  "@type": "BlogPosting",

  headline: post.title,

  description: post.description,

  datePublished: post.date,

  dateModified: post.updated ?? post.date,

  author: { "@type": "Organization", name: "Example", url: "https://example.com" },

  mainEntityOfPage: `https://example.com/blog/${post.slug}`,

  image: [`https://example.com/blog/${post.slug}/opengraph-image`],

};



<script

  type="application/ld+json"

  dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }}

/>

Keep datePublished fixed to the frontmatter date and only move dateModified when the content actually changes. Add a BreadcrumbList (Home, Blog, Post) next to it; it is cheap and it helps search engines understand the site hierarchy. Validate the output with the Rich Results Test before you ship.

5. Ship a real PNG share image per post

Many Markdown blogs set an SVG logo as the Open Graph image. Most social platforms do not render SVG previews, so shared links show up as a bare URL. Next.js can generate a PNG per post from an opengraph-image.tsx file in the post's route segment using next/og, at 1200x630, with the post title drawn on it. The same URL can then be reused in the BlogPosting image field.

If generating images feels like too much for now, a single static 1200x630 PNG for the whole blog is still far better than an SVG.

6. Make sure crawlers can reach every post through plain links

This is the item we underestimated. Google Search Console showed our pricing page with no referring page detected, even though every visitor could reach it from the header. The header was interactive, and the links a crawler saw in the server HTML did not include it.

The fix is boring and effective: a server-rendered footer on every public page with plain <a href> links to the blog index, the pricing page and the newest posts. In the App Router, a server component using next/link renders a normal anchor tag, so this costs nothing. Also:

  • Link from each post to two or three related posts inside the body text, not only in a "related posts" widget.
  • Keep the blog index paginated with real URLs (/blog?page=2 or /blog/page/2) rather than infinite scroll that only loads with JavaScript.
  • Do not link public pages to login-only routes that robots.txt blocks; crawlers waste budget following them.

A useful test: curl -s https://example.com/ | grep -o 'href="/blog/[^"]*"'. If that returns nothing, neither will Googlebot's first pass over the page.

7. Server-render the article, not just the shell

App Router pages are server components by default, so a Markdown post usually renders fully on the server. Problems creep in when a page imports sections with dynamic(() => import(...), { ssr: false }), or when the Markdown is fetched in a client useEffect. Both ship an empty container in the HTML and fill it in later.

Keep ssr: false for things that genuinely need the browser, such as canvas, WebGL or a code playground. The article body, headings, and any counters or statistics you want indexed should arrive in the initial HTML. A counter animation that renders "0" on the server and counts up in the browser is a small example: the crawler indexes the zero.

8. Check the built HTML after every deploy

Most of the bugs above are invisible in a browser and obvious in the raw HTML. Add a short check you run after each deploy, or as a test against next build output:


URL=https://example.com/blog/your-post

HTML=$(curl -s "$URL")

echo "$HTML" | grep -o '<title>[^<]*</title>'

echo "$HTML" | grep -o '<link rel="canonical" href="[^"]*"'

echo "$HTML" | grep -c '<h1'            # expect 1

echo "$HTML" | grep -c 'application/ld+json'

curl -s https://example.com/sitemap.xml | grep -c "$URL"

You are looking for one H1, a self-referencing absolute canonical, a title without a doubled suffix, at least one JSON-LD block, and the URL present in the sitemap. On our own site this check found two H1s per post (the frontmatter title plus a # heading at the top of the Markdown). The fix was to treat the frontmatter title as the only H1 and start the body at ##.

9. Publish consistently, then request indexing

Technical SEO sets the ceiling; publishing frequency decides how close you get to it. A blog that last posted eight months ago gets crawled less often, and a perfect sitemap does not change that. Pick a cadence you can keep, such as one post a week, and request indexing in Search Console for each new URL in the first few weeks while the site builds crawl history.

This is the part that most often stalls on developer-run blogs, which is why we built Blogger Dog. It researches and writes SEO and AEO posts and commits them straight into your repo, in the format your site already reads, then checks the public URL to confirm the post is actually live. If you want to see how that works for your stack, the Next.js integration page lists the folder and file shapes it supports. For the publishing side in more depth, see GitHub blog publishing for SaaS teams and why we treat a live URL as the definition of published.

Quick checklist

  1. generateMetadata returns a unique title, description, canonical and Open Graph object per post.
  2. Canonicals are absolute and built from one production origin; the layout does not set a site-wide canonical.
  3. sitemap.ts reads the same post loader as the pages and uses real dates.
  4. Each post renders BlogPosting and BreadcrumbList JSON-LD with escaped output and fixed publish dates.
  5. Each post has a PNG share image at 1200x630.
  6. Every public page has plain server-rendered links to the blog, pricing and recent posts.
  7. Article text, headings and stats are in the initial HTML.
  8. A curl-based check runs after each deploy.
  9. A publishing cadence you actually keep.

FAQ

Does Next.js handle SEO automatically?

It gives you the tools, not the decisions. The Metadata API, sitemap.ts, robots.ts and opengraph-image cover the mechanics, but you still have to supply per-page canonicals, real dates and crawlable links. The defaults will happily give every page the same title and canonical if the layout sets them.

Should a Markdown blog use MDX for SEO?

MDX does not change rankings. Choose it if you need components inside posts. For SEO, what matters is that the rendered HTML has one H1, a clean heading hierarchy and the full text on the server, which plain Markdown already gives you.

Do I still need a sitemap if every post is linked?

Yes, it is cheap and it helps discovery of new URLs. But treat it as a hint. Google calls sitemaps a weak canonical signal, so internal links and canonicals do the heavy lifting.

How long until a new post is indexed?

It varies with how often your site is crawled. New or rarely updated sites can wait days to weeks. Requesting indexing in Search Console and linking the new post from pages that are already indexed both shorten the wait.

Put the publishing part on autopilot

If your Next.js blog is technically sound but has gone quiet, the missing piece is usually output, not configuration. Blogger Dog writes and publishes posts into your GitHub or GitLab repo and verifies each one is live. You can start with 3 free posts and compare plans on the pricing page.

Put your blog on autopilot

Blogger Dog researches, writes and commits SEO/AEO posts like this one straight into your GitHub or GitLab repo on a schedule. Your first 3 posts are free, then plans start at $19/month.

Launch offer: 50% off any plan for 3 months with code EARLY3. First 3 customers, ends Sunday.

Claim 50% off

More from the archive