Technical SEO in Next.js: What I Actually Wired Into This Site
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
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.
Metadata: 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.
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: { "@id": personId }, // ← the link, not a copy
url: `${site.url}/blog/${post.slug}`,
image: `${site.url}/blog/${post.slug}/opengraph-image`,
};
}author: { "@id": personId } is the line that matters. Not a duplicated inline author object — a reference. 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.
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.
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:
- 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, watch coverage and the sitemap report. It's the slow feedback loop, but it's the one that reflects reality.
Gotchas worth remembering
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
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
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.
Multi-Language Sites in Next.js: Locale Routing, Typed Dictionaries, and the RTL U-Turn
A code-first guide to internationalization in the Next.js App Router — a [lang] route segment, locale detection in proxy.ts, type-safe dictionaries with one source of truth, RTL support, and hreflang metadata. No i18n library required.
Multi-Step Forms with Zod and React Hook Form in TypeScript
A practical guide to building type-safe multi-step forms in React with Zod and React Hook Form — per-step validation, cross-field conditional rules, and one schema shared with the server.