Abdelrahman

Technical SEO in Next.js: What I Actually Wired Into This Site

nextjsseotypescriptstructured-datametadata

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.ts and robots.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-image file 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:

tsx
// 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:

  • params is a Promise in current Next.js — you await it. If your generateMetadata destructures params synchronously, you're reading an older tutorial.
  • The canonical is relative. That only works because of the next snippet.
  • openGraph.type: "article" unlocks publishedTime and modifiedTime — real dates that show up in link previews and that crawlers cross-check against your sitemap. Ship updated ?? date so 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:

tsx
// 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:

ts
// 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:

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:

ts
// 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:

ts
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:

tsx
// 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:

tsx
// 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:

txt
app/
  about/
    page.tsx
    opengraph-image.png   <- that's it, Next.js wires the meta tags

Any 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:

json
{
  "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.

tsx
// 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×630 is the de facto Open Graph standard; most platforms crop or letterbox anything else.
  • contentType — the Content-Type header, so Slack/X/LinkedIn know they're fetching a PNG and not, say, JSON.
  • alt — becomes og: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 BlogPosting or FAQPage don'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 @id references actually connect.
  • next build — then hit /sitemap.xml, /robots.txt, and /blog/your-post/opengraph-image locally. 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:image and 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

  • disallow is not auth. robots.txt is public; listing a secret path there publishes it. Disallow noise, authenticate secrets.
  • Set metadataBase once, keep canonicals relative. Hardcoded absolute canonicals go stale and let preview deployments claim to be the real site.
  • lastModified from 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 referenced Person node makes the whole site one coherent graph.
  • Escape < in JSON-LD. JSON.stringify alone leaves a </script> breakout open. One .replace(/</g, "\\u003c") closes it.
  • params is a Promise now. In generateMetadata, pages, and opengraph-image.tsx alike — await it.
  • 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.