Asia/Karachi
BlogOctober 6, 2026

Technical SEO for Next.js: Metadata, Sitemap, Canonical

What the App Router gives you and how to check it worked
Fawaz Israr
Technical SEO for Next.js: Metadata, Sitemap, Canonical
Technical SEO in the Next.js App Router comes down to a few exports and one habit. Export generateMetadata for titles, descriptions and canonical URLs, add app/sitemap.ts and app/robots.ts, mark thin pages as noindex, and make sure the real content is already in the HTML the server sends. Then check all of it with view-source and curl instead of trusting the browser. I am a final-year Computer Science student who does SEO work, and this portfolio runs on Next.js, so the examples below are simplified versions of the setup on this site. If you want the content side first, my on-page SEO checklist for a developer portfolio covers titles, headings and internal links in plain terms. Next.js reads metadata from your route files and writes the title, meta and link tags into the document head on the server. You can export a static metadata object from a layout or page, or export an async generateMetadata function when the values depend on the route. Both only work in Server Components. For a blog post, the slug decides everything, so generateMetadata is the right tool. In Next.js 16 the params value is a Promise, so you await it.
Tsx
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getPost } from "@/lib/posts";

type Props = { params: Promise<{ slug: string }> };

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) return {};

  return {
    title: post.title,
    description: post.summary,
    alternates: { canonical: `/blog/${slug}` },
    openGraph: {
      title: post.title,
      description: post.summary,
      type: "article",
      url: `/blog/${slug}`,
      images: [post.image],
      publishedTime: post.publishedAt,
    },
  };
}

export default async function Page({ params }: Props) {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      {/* post body */}
    </article>
  );
}
The relative URLs only resolve if you set metadataBase once in the root layout. Without it, Next.js has no domain to build absolute canonical and Open Graph URLs from.
Tsx
// app/layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  metadataBase: new URL("https://example.com"),
};
Two details trip people up. A child openGraph object replaces the parent's one instead of merging field by field, so repeat the title and description there. And getPost runs in both functions, so wrap a database call in React's cache (or use fetch, which is deduplicated within a render) to avoid loading the post twice. The official generateMetadata reference lists every field. Set alternates.canonical on every indexable page, as in the code above. The canonical tag tells search engines which URL is the main version when several URLs show the same content, for example a page reachable with and without tracking parameters or with a trailing slash. Google treats the canonical annotation as a hint, so it can still pick a different URL if your other signals disagree. Keep the canonical consistent with your sitemap entry, your internal links and your redirects. The consolidate duplicate URLs guide explains how these signals are weighed. A self-referencing canonical (the page pointing at its own clean URL) is normal and harmless. Do not point every page at the home page to be safe, because that asks search engines to ignore the other pages. Put sitemap.ts and robots.ts in the root of the app folder. Next.js turns them into /sitemap.xml and /robots.txt.
Ts
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { getAllPosts } from "@/lib/posts";

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

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getAllPosts();

  return [
    { url: baseUrl, lastModified: new Date("2026-10-01") },
    ...posts.map((post) => ({
      url: `${baseUrl}/blog/${post.slug}`,
      lastModified: new Date(post.updatedAt ?? post.publishedAt),
    })),
  ];
}
Ts
// app/robots.ts
import type { MetadataRoute } from "next";

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [{ userAgent: "*", allow: "/", disallow: ["/api/", "/admin/"] }],
    sitemap: "https://example.com/sitemap.xml",
  };
}
Sitemap URLs must be absolute. Only list pages you want indexed and that return a normal 200 response, and make lastModified a real date. If every entry says "today", the field tells a crawler nothing. The sitemap line in robots.ts points crawlers at the file, so you do not have to rely on submitting it by hand. Keep disallow for paths that have no search value, such as API routes. Do not use it to hide pages you want kept out of results, which the noindex section below explains. This is the check I would run before anything else. Search engines and link preview bots request a URL and receive HTML. Google can run JavaScript and render the page, but that is a second step, and some other crawlers only read the first response. If your text appears only after client code runs, you are depending on that second step. On this portfolio I ran a crawler and found that every page arrived with almost no text in the first HTML: the crawler reported 0 main words. A client component was running a route check inside useEffect and showing a loading state until it finished, so the server only ever sent the loading screen. The check needed no network call, so I replaced the effect with a plain function that runs during render. Next.js renders client components on the server too, so the first HTML now contains the page text: a few hundred words on the home page and over 1,200 on each blog post. The pattern to avoid looks like this:
Tsx
"use client";
import { useEffect, useState } from "react";

export default function Page() {
  const [allowed, setAllowed] = useState(false);

  useEffect(() => {
    checkRoute().then(setAllowed);
  }, []);

  if (!allowed) return <p>Loading...</p>;
  return <Content />;
}
The version that fixed it runs the same check during render, so it also runs on the server before any HTML is sent:
Tsx
"use client";
import { usePathname } from "next/navigation";

// A plain function: no state, no effect, no waiting.
function isRouteEnabled(pathname: string) {
  return ["/", "/about", "/work", "/blog"].includes(pathname);
}

export function RouteGuard({ children }: { children: React.ReactNode }) {
  const pathname = usePathname();
  if (!isRouteEnabled(pathname)) return <p>Page not found</p>;
  return <>{children}</>;
}
Server Components are the default in the App Router, so when a check really needs data, make the page a Server Component and keep "use client" for small interactive pieces such as a menu or a form. A loading.tsx file or a Suspense fallback is fine for slow data when the page's main text still arrives in the same response. The problem is real content that only appears after hydration. Loading states that swap content in late also tend to cause layout shifts, which I cover in Core Web Vitals explained.
Hands typing on a laptop keyboard with a second monitor blurred in the background
This is also a practical reason to pick Next.js for public sites in the first place. Server rendering is built in, which I weighed against other options in MERN vs Next.js vs Spring Boot. Use noindex for pages that exist for users but have nothing to offer a search result: an empty gallery, a thank-you page, internal search results, a login screen, a draft preview. In the App Router it is one field:
Tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  robots: { index: false, follow: true },
};
Next.js writes this as a robots meta tag. One mistake is worth knowing about: if you also block the same URL in robots.txt, the crawler never fetches the page, so it never sees the noindex tag. Google's guide to blocking indexing says the page must be crawlable for the rule to work. Use robots.txt to save crawl effort on junk paths and the meta tag to keep a page out of results. Decide which routes are public early. When I plan a project I list them in the software design document, next to the page that each route renders. Start with view-source in the browser (right click, then View page source, or put view-source: before the URL). This shows the HTML the server sent, before JavaScript changed anything. Search it for a sentence from your page. If you cannot find it, a crawler that does not run JavaScript cannot find it either. The Elements panel in dev tools shows the page after JavaScript, so it can hide this problem. Curl gives you the same view from a terminal, and it is easy to repeat after every deploy. In Git Bash or any Unix shell:
Bash
curl -s https://example.com/ | grep -oE '<title>[^<]*</title>|<link rel="canonical"[^>]*>|<meta name="description"[^>]*>'
That prints the title, canonical and description from the first HTML. To confirm body text, count a phrase from the page, for example curl -s URL | grep -c "a sentence from the page". A count of 0 means the text is missing from the first response. Request the two files as well, because a wrong deploy can break them quietly: curl -s https://example.com/robots.txt and curl -s https://example.com/sitemap.xml. For a whole-site view, run a crawler that can report titles, canonicals, status codes and word counts for each URL. Look for duplicate titles, canonicals that point somewhere unexpected, redirected URLs inside the sitemap and pages with a very low word count. That last column is how I found the loading-state problem above, so keep an eye on it after structural changes. Once the site is live, add it to Google Search Console and submit the sitemap URL. The URL Inspection tool there shows Google's own view of a page, including the canonical it selected. Treat any mismatch with your alternates.canonical as the next thing to debug. Cover photo from Unsplash.
Share this post: