Technical SEO in Next.js: Crawlability, Sitemaps, Canonicals, and JSON-LD
Published · Last updated · by Abdelrahman Mostafa
Search for "SEO for developers" and you'll find nine hundred articles telling you to add a <title> tag and "write good content." Thanks. Incredible. This is not that article. This one is about the parts that live in code — the JSON-LD graph, the XSS hole hiding in your structured data, the sitemap that lies about lastModified, and the Open Graph image nobody wired up correctly.
It's also not hypothetical. Every pattern below is running on the page you are reading right now. Open DevTools, view source, and you can check my homework live. (Please do. The <script type="application/ld+json"> tags are right there. I have nothing to hide except one admin route, which we'll get to.)
Everything here targets the Next.js App Router with TypeScript.
What we're wiring
Technical SEO in an App Router site comes down to five deliverables:
- Metadata — titles, descriptions, canonicals, and Open Graph tags via
generateMetadata sitemap.tsandrobots.ts— as typed, computed code instead of hand-edited XML- JSON-LD structured data — a linked schema graph, not loose disconnected blobs
- Safe injection — structured data without opening an XSS hole
- Open Graph images — per page, served through the
opengraph-imagefile convention
All five assume one thing first: that a crawler can actually read your pages. So that's where we start.
None of these rank a bad article. All of them decide how machines read a good one — and machines are, increasingly, the audience that shows up first.
How to make a Next.js site crawlable
Before any metadata matters, the crawler has to get your content. A crawler requests a URL, reads the HTML that comes back, and follows the links in it. Everything in this section is about making that first response complete.
1. The content has to be in the initial HTML. This is the big one, and "server vs client components" is the wrong way to think about it. A "use client" component is still rendered on the server for the first load, so its markup is in the HTML. What isn't in the HTML:
- data fetched in a
useEffect(the HTML ships the loading spinner, not the article) - anything behind
dynamic(() => import(...), { ssr: false }) - anything gated on a "mounted" flag or
typeof window !== "undefined"
Google does eventually run JavaScript, in a second rendering pass that can lag the crawl. Most other crawlers, link-preview bots, and AI crawlers don't run it at all. Fetch content in Server Components, or with generateStaticParams for static pages, and keep client-side fetching for things nobody needs to index.
Static, ISR, or dynamic rendering doesn't matter here. All three send complete HTML. What matters is the server/browser split.
2. Links must be real links. <Link href="/blog/post"> renders an <a href>, which crawlers follow. A <div onClick={() => router.push("/blog/post")}> looks the same to a user and is a dead end for a crawler. The same goes for pagination, filters, and language switchers: if a page is only reachable through an onClick, it's an orphan.
3. Missing pages must return 404. Call notFound() when a slug doesn't exist, and set export const dynamicParams = false on routes whose generateStaticParams lists every valid slug. A "post not found" message on a 200 is a soft 404, and Google indexes it as a thin page.
4. No accidental noindex. The usual culprit is a robots: { index: false } added for preview deployments that leaks into production through a shared layout. Gate it on the environment, and check the production HTML for <meta name="robots".
5. Give crawlers a map and a door. sitemap.ts lists every URL you want indexed, with honest dates, and robots.ts points at it. Both are covered below. A URL that's in the sitemap but never linked from any page is still a weak orphan, so link your important pages from your nav or index pages too.
Check it like a crawler would
The quickest test needs no tooling. Fetch the page without JavaScript and look for a sentence from the body:
curl -s https://your-site.com/blog/your-post | grep -c "a sentence from the article"
# 0 means the content isn't in the HTMLThen check what Google actually got. In Search Console, URL Inspection → Test live URL → View tested page shows the rendered HTML and screenshot from Googlebot's side. "Request indexing" lives on the same screen.
To check a whole site at once, crawl it. next-seo-doctor is a CLI I built for this. It crawls a running Next.js site like a search engine would, follows the links, and reports broken canonicals, sitemap entries that 404, JSON-LD that won't parse, and robots.txt problems:
npx next-seo-doctor http://localhost:3000Metadata: generateMetadata and the canonical trap
Static pages can export a plain metadata object, but anything dynamic — a blog post, a project page — uses generateMetadata. You have the slug, so fetch exactly one post with it:
// app/blog/[slug]/page.tsx
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug); // one post, by slug — not the whole list
if (!post) return {};
return {
title: post.title,
description: post.description,
alternates: { canonical: `/blog/${post.slug}` },
openGraph: {
type: "article",
publishedTime: post.date,
modifiedTime: post.updated ?? post.date,
},
};
}getPost here is whatever your data layer provides — a fetch to your CMS's single-post endpoint, a database query, a file read. The shape that matters: slug in, one post out, null when it doesn't exist. Resist the tempting shortcut of loading the full post list and .find()-ing in it — it works, but it makes every page build pay for every post, and the habit follows you to places where the list is actually expensive.
Three details that separate this from copy-paste metadata:
paramsis a Promise in current Next.js — youawaitit. If yourgenerateMetadatadestructures params synchronously, you're reading an older tutorial.- The canonical is relative. That only works because of the next snippet.
openGraph.type: "article"unlockspublishedTimeandmodifiedTime— real dates that show up in link previews and that crawlers cross-check against your sitemap. Shipupdated ?? dateso the fallback is never a lie.
The canonical trap: hardcode absolute URLs and every preview deployment happily declares itself the canonical home of your content. Instead, set metadataBase once in the root layout:
// app/layout.tsx
export const metadata: Metadata = {
metadataBase: new URL(site.url),
// ...
};Now every relative canonical, OG URL, and image path resolves against one configurable base. One env-driven value, zero per-page URL surgery. The pages don't know what domain they live on, and that's exactly how they should like it.
The openGraph merge trap
Here's one I found on this very site while updating this post. Next.js merges metadata between layouts and pages shallowly. If a page sets openGraph at all, it replaces the layout's entire openGraph object. It doesn't merge field by field.
So the generateMetadata above, which sets openGraph.type and the dates, silently drops siteName, locale, and url from every blog post. Worse, my root layout set openGraph.url: "/". Every page that didn't override openGraph (about, contact, the blog index) inherited it and announced og:url as the home page. No error, no warning. I only found it by dumping the <head> of every route.
The fix is to stop writing openGraph objects by hand. Build them all from one helper:
// lib/seo/metadata.ts
export function pageOpenGraph(path: string, overrides?: OpenGraph): OpenGraph {
return {
type: "website",
siteName: site.name,
locale: "en_US",
url: path,
...overrides,
};
}
// app/blog/[slug]/page.tsx
openGraph: pageOpenGraph(`/blog/${post.slug}`, {
type: "article",
publishedTime: post.date,
modifiedTime: post.updated ?? post.date,
}),Notice what the helper doesn't set: images. My first version had a default image in there, and every blog post's own opengraph-image vanished from its previews. I'd assumed file-based metadata beats config. It doesn't: an explicit openGraph.images on the page wins over the opengraph-image file sitting next to it. So pages with their own image file leave images out, and the handful of pages without one pass images: [defaultOgImage] explicitly.
The same goes for twitter. An images array in the layout's twitter block overrode every page's own OG image in Twitter/X previews. Leave twitter.images unset and Next.js falls back to the page's og:image.
sitemap.ts: the sitemap is a build artifact, not a chore
The App Router lets you write the sitemap as code — a default export in app/sitemap.ts that returns a typed array. Which means the sitemap can be computed from the content, so it is never out of date:
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { site } from "@/lib/site";
import { getAllPosts } from "@/lib/content/blog";
import { getAllProjects } from "@/lib/content/projects";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts();
const projects = getAllProjects();
const staticRoutes: MetadataRoute.Sitemap = [
{ url: `${site.url}/`, changeFrequency: "monthly", priority: 1 },
{ url: `${site.url}/blog`, changeFrequency: "weekly", priority: 0.8 },
// ...about, contact, projects
];
const postRoutes: MetadataRoute.Sitemap = posts.map((post) => ({
url: `${site.url}/blog/${post.slug}`,
lastModified: post.updated ?? post.date,
changeFrequency: "yearly",
priority: 0.6,
}));
return [...staticRoutes, ...postRoutes];
}The part worth stealing is lastModified: post.updated ?? post.date. A lot of generated sitemaps stamp new Date() on every entry at build time — which tells crawlers "everything changed today," every day, forever. Crawlers learn to ignore you, the way you learned to ignore that one Slack channel. Real dates from frontmatter mean lastModified actually means something.
MetadataRoute.Sitemap is doing quiet work too: changeFrequency is a union type, priority is a number, and a typo'd "weakly" fails the build instead of shipping silently. Add a new post, the sitemap updates itself. That's the whole maintenance story.
robots.ts: and the footgun that publishes your secrets
Same pattern, app/robots.ts:
// app/robots.ts
import type { MetadataRoute } from "next";
import { site } from "@/lib/site";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/admin/", "/api/"],
},
sitemap: `${site.url}/sitemap.xml`,
};
}Now the footgun, because it's a classic: robots.txt is a public file, and disallow is not access control. If you have a secret unlinked path — a hidden admin panel, a staging route — and you "protect" it by adding it to disallow, you have just published the path in a plaintext file that every crawler, security scanner, and bored stranger reads first. It's a treasure map with "DO NOT DIG HERE" written on the X.
This site has exactly such a secret admin path. It is not in the robots file, it is not linked anywhere, and the public /admin route that is disallowed just 404s. disallow is for keeping polite crawlers out of noise like /api/ — anything actually sensitive needs auth, not a robots entry.
JSON-LD: build a graph, not confetti
This is the part most articles get wrong, when they cover it at all. The usual advice is "add structured data," so people sprinkle disconnected schema blobs on each page — a Person here, a BlogPosting there — and none of them reference each other. Google gets a bag of confetti and shrugs.
Schema.org data is supposed to be a graph. Entities link to each other with @id. Do that, and every blog post on the site points at one Person node, and a crawler can assemble: this post → written by this person → who owns this site → and knows these topics.
Give the person a stable @id and make everything reference it:
// lib/seo/schema.ts
import { site } from "@/lib/site";
const personId = `${site.url}/#person`;
export function personSchema() {
return {
"@context": "https://schema.org",
"@type": "Person",
"@id": personId,
name: site.name,
jobTitle: site.jobTitle,
url: site.url,
knowsAbout: ["React", "Next.js", "TypeScript" /* ... */],
sameAs: site.sameAs,
};
}
export function blogPostingSchema(post: Post) {
return {
"@context": "https://schema.org",
"@type": "BlogPosting",
"@id": `${site.url}/blog/${post.slug}/#post`,
headline: post.title,
description: post.description,
datePublished: post.date,
dateModified: post.updated ?? post.date,
author: authorRef, // ← the link, not a copy
mainEntityOfPage: {
"@type": "WebPage",
"@id": `${site.url}/blog/${post.slug}`,
},
url: `${site.url}/blog/${post.slug}`,
image: `${site.url}/blog/${post.slug}/opengraph-image`,
};
}
// just enough to identify the author on a page that
// doesn't carry the full Person node
const authorRef = {
"@type": "Person",
"@id": personId,
name: site.name,
url: site.url,
};"@id": personId is the line that matters. It's a reference, not a full duplicated author object. One person node, declared once on the homepage, pointed at from every post, every project, the about page. Update the job title in one file and the entire graph agrees.
An earlier version of this post used a bare { "@id": personId } and nothing else. It's valid schema.org, but Google doesn't resolve an @id declared on a different page. Run a blog post through the Rich Results Test and the author shows up with no name. So the reference carries its name and url too: enough to stand alone on the page, still pointing at the one canonical node. mainEntityOfPage tells Google this article is the main thing on that URL, not a teaser of something else.
Two more node types earn their keep on a blog:
export function breadcrumbSchema(items: { name: string; path: string }[]) {
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items.map((item, index) => ({
"@type": "ListItem",
position: index + 1,
name: item.name,
item: `${site.url}${item.path}`,
})),
};
}
export function faqPageSchema(faq: { question: string; answer: string }[]) {
return {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: faq.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: { "@type": "Answer", text: item.answer },
})),
};
}The FAQ one is why posts on this site carry a faq array in their frontmatter — the same questions render as the FAQ section at the bottom of the post and feed FAQPage structured data. Content and schema come from one source, so they can't drift. (Single source of truth. If you read the multi-step forms post, you knew this bit was coming.)
A page then stacks its nodes declaratively:
// app/blog/[slug]/page.tsx
<JsonLd data={blogPostingSchema(post)} />
<JsonLd data={breadcrumbSchema([
{ name: "Home", path: "/" },
{ name: "Blog", path: "/blog" },
{ name: post.title, path: `/blog/${post.slug}` },
])} />
{post.faq && post.faq.length > 0 && (
<JsonLd data={faqPageSchema(post.faq)} />
)}No library. Schema builders are plain functions returning plain objects, and TypeScript already knows how to check those.
The XSS line nobody mentions
So what is that JsonLd component? Ten lines, one of which is load-bearing:
// components/seo/json-ld.tsx
export function JsonLd({ data }: { data: object }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(data).replace(/</g, "\\u003c"),
}}
/>
);
}Here's the part that deserves a normal, serious paragraph: structured data is an injection sink. Your JSON-LD contains content — post titles, descriptions, FAQ answers. If any of that content ever contains the string </script>, plain JSON.stringify output will terminate the script tag early and whatever follows becomes live markup in your page. That's XSS, delivered by your own SEO.
The fix is one replace: escape every < as \u003c, which is legal inside JSON strings and meaningless to the HTML parser. JSON.parse treats \u003c and < identically, so crawlers see identical data — but the script tag can no longer be broken out of. If you copy one line from this article, copy that one.
Here's what that looks like in the served HTML. This is a trimmed copy of the BlogPosting block on this page. Paste it into the Rich Results Test and swap in your own values:
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"@id": "https://abdelrahmanmostafa.com/blog/technical-seo-in-nextjs/#post",
"headline": "Technical SEO in Next.js: Crawlability, Sitemaps, Canonicals, and JSON-LD",
"description": "Make a Next.js App Router site crawlable: ...",
"datePublished": "2026-07-11",
"dateModified": "2026-10-16",
"author": {
"@type": "Person",
"@id": "https://abdelrahmanmostafa.com/#person",
"name": "Abdelrahman Mostafa",
"url": "https://abdelrahmanmostafa.com"
},
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "https://abdelrahmanmostafa.com/blog/technical-seo-in-nextjs"
},
"image": "https://abdelrahmanmostafa.com/blog/technical-seo-in-nextjs/opengraph-image"
}
</script>If a title ever contained </script>, that block would contain \u003c/script> instead, and the tag would stay closed. (Someone found this post by searching for next.js json-ld guide replace < with \u003c. That's the right instinct. This is the line.)
Open Graph images: one route, real files
The og:image is the thumbnail your link shows in Slack, X, LinkedIn — realistically the most-seen pixel real estate your post has. The App Router convention: put an opengraph-image file next to the route, and Next.js auto-injects the <meta property="og:image"> tags for you. No manual <meta> wrangling, no forgetting to update the tag when the image changes.
For a normal static page, that's the entire implementation. No code. Drop a PNG in the route folder and you're done:
app/
about/
page.tsx
opengraph-image.png <- that's it, Next.js wires the meta tagsAny page — /about, /contact, the homepage — gets its share image by file placement alone. Name it opengraph-image.png (or .jpg), keep it 1200×630, ship it.
The interesting case is a dynamic route like [slug], where the post's content — and its OG image — come from an API. Say your CMS returns each post's metadata like this:
{
"slug": "technical-seo-in-nextjs",
"title": "Technical SEO in Next.js: ...",
"ogImage": "https://cdn.example.com/og/technical-seo.png"
}You can't drop one static PNG next to [slug]/page.tsx — every post would share it. Instead, opengraph-image.tsx becomes a tiny image-serving route: it fetches the post's metadata from the API, follows the ogImage URL in the response, and returns the image bytes.
// app/blog/[slug]/opengraph-image.tsx
export const alt = "Blog post preview";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export async function generateStaticParams() {
const posts = await fetch("https://cms.example.com/api/posts").then((res) =>
res.json(),
);
return posts.map((post: { slug: string }) => ({ slug: post.slug }));
}
export default async function Image({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
// 1. ask the API for this post's metadata
const post = await fetch(`https://cms.example.com/api/posts/${slug}`).then(
(res) => res.json(),
);
// 2. follow the ogImage URL from that metadata
const image = await fetch(post.ogImage);
// 3. hand the bytes back as the route's response
return new Response(await image.arrayBuffer(), {
headers: { "Content-Type": "image/png" },
});
}Despite the .tsx extension, there's no rendering happening — the default export is just a handler that returns a Response full of PNG bytes, exactly like a route handler would. The pieces around it:
size— the image dimensions Next.js advertises in the meta tags.1200×630is the de facto Open Graph standard; most platforms crop or letterbox anything else.contentType— theContent-Typeheader, so Slack/X/LinkedIn know they're fetching a PNG and not, say, JSON.alt— becomesog:image:alt, the accessible description of the preview.generateStaticParams— runs at build time, once per slug from the API, so every post's image route is pre-rendered as a static file. Visitors (and crawlers) never wait on your CMS.- The two fetches — metadata first, image second. The API response is the source of truth for which image; the route is just the courier.
Why route through opengraph-image.tsx at all instead of pointing openGraph.images metadata straight at the CDN URL from the API? Centralization. The public URL stays stable and conventional (/blog/<slug>/opengraph-image), the JSON-LD image field points at the same route, and when someone swaps the image in the CMS, no meta tag anywhere needs touching — the route re-resolves it at the next build. One file owns the whole "which image does this post show" question, and your CDN URLs never leak into your markup.
(This page's own image went through the same route — the only difference is my "API" is the filesystem, because this blog's CMS is a folder of MDX files and I'm not ashamed of that. Same route, same Response, different courier.)
Verify it, because crawlers won't file a bug report
The cruelest property of technical SEO: when it's broken, nothing visibly breaks. No error, no console warning — just quietly worse results, discovered months later. So verify like it's code, because it is:
curl+grep, for crawlability: if a sentence from the article isn't in the raw HTML, a crawler that doesn't run JavaScript never saw it (see how to make a Next.js site crawlable).- next-seo-doctor — crawls your running site and checks canonicals, sitemap honesty, JSON-LD validity and
@idintegrity, the<escaping above, robots.txt, and OG images in one pass. I built it after doing every check on this list by hand one too many times. - Rich Results Test — paste a URL, see exactly which schema types Google detected and any warnings. If
BlogPostingorFAQPagedon't show up here, they don't exist as far as rich results are concerned. - Schema.org validator — stricter, checks the raw graph. Good for confirming your
@idreferences actually connect. next build— then hit/sitemap.xml,/robots.txt, and/blog/your-post/opengraph-imagelocally. All three are just routes; all three can be eyeballed.- OpenGraph.xyz or a paste into Slack — the only honest preview of what your
og:imageand title actually look like when a human shares the link. - Search Console — after deploy, use URL Inspection to see the HTML Googlebot rendered, then watch coverage and the sitemap report. It's the slow feedback loop, but it's the one that reflects reality.
Gotchas worth remembering
- Content in the HTML, not in
useEffect. Crawlers read the first response. Data fetched client-side, or rendered withssr: false, isn't in it. openGraphdoesn't deep-merge. A page that setsopenGraphreplaces the layout's whole object. Build every page's from one helper, and never puturlin the layout's.disallowis not auth. robots.txt is public; listing a secret path there publishes it. Disallow noise, authenticate secrets.- Set
metadataBaseonce, keep canonicals relative. Hardcoded absolute canonicals go stale and let preview deployments claim to be the real site. lastModifiedfrom real content dates. Stamping build time on every sitemap entry teaches crawlers your dates are meaningless.- Link JSON-LD nodes with
@id. Disconnected schema blobs are confetti; a referencedPersonnode makes the whole site one coherent graph. - Escape
<in JSON-LD.JSON.stringifyalone leaves a</script>breakout open. One.replace(/</g, "\\u003c")closes it. paramsis a Promise now. IngenerateMetadata, pages, andopengraph-image.tsxalike —awaitit.- FAQ content and FAQ schema from one source. If the visible answers and the structured answers can drift apart, they eventually will — and mismatched FAQ markup is how you lose the rich result.
Wrapping up
Server-rendered content and real links so crawlers can read the site at all, then five files, no libraries: generateMetadata with metadataBase-resolved canonicals, a sitemap computed from content with honest lastModified, a robots file that disallows noise without leaking secrets, a JSON-LD graph whose nodes actually reference each other, one escaped < standing between your structured data and an XSS hole, and per-post OG images served through one conventional route, wherever the bytes live.
None of it is glamorous. All of it compounds. And the best part of doing technical SEO on your own blog: the article and the implementation are the same site, so if you caught a mismatch between what I wrote and what's in view-source — congratulations, you just did a code review. File it. I'll update dateModified. Honestly.
FAQ
How do I make a Next.js site crawlable?
Make sure the content is in the server-rendered HTML, not fetched in useEffect or hidden behind ssr: false. Link between pages with real <a href> links (next/link renders them), return real 404s with notFound(), publish a sitemap.ts and robots.ts, and don't ship an accidental noindex. Then check with curl and Search Console's URL Inspection tool.
Why are og:url and og:site_name missing on some Next.js pages?
Next.js merges metadata shallowly. When a page sets openGraph, it replaces the layout's entire openGraph object, so fields only set in the layout (siteName, locale, url, images) disappear on that page. Build every page's openGraph from a shared helper that spreads the defaults and sets the page's own url.
Do I need a library for JSON-LD structured data in Next.js?
No. Structured data is just a JSON object rendered inside a <script type="application/ld+json"> tag. Write small typed builder functions, link entities together with @id, and inject the serialized JSON — no dependency required.
How do I set canonical URLs with the Next.js Metadata API?
Return alternates: { canonical: "/path" } from generateMetadata and set metadataBase once in your root layout. Next.js resolves the relative path against metadataBase, so canonicals stay correct across preview deployments and production without touching every page.
Is rendering JSON-LD with dangerouslySetInnerHTML safe?
Only if you escape it. Serialize with JSON.stringify and replace every < with \u003c so content pulled into the schema (titles, FAQ answers) can never close the script tag and inject markup. Structured data is a real XSS sink.
How do I set a per-page Open Graph image in Next.js?
For a static page, drop a 1200×630 opengraph-image.png next to its page.tsx — Next.js wires the og:image meta tags automatically. For a dynamic route like [slug], add an opengraph-image.tsx that looks up the page's metadata (e.g. from your CMS API), fetches the image URL it points at, and returns the bytes as a Response with a Content-Type: image/png header.
Still here? Read more
no ads, no popups, just the void offering you more content.
Next.js Localization: i18n Routing, Translations, a Language Switcher, and the RTL U-Turn
Next.js localization in the App Router: [lang] i18n routing, typed translations, a language switcher, Arabic RTL, and hreflang — with or without next-intl.
React Hook Form + Zod: zodResolver, Conditional Validation, and Multi-Step Forms
Set up React Hook Form with Zod and zodResolver in TypeScript — install, z.infer types, conditional and async validation, and multi-step forms.