czay.dev
Writing

SEO in Next.js: Metadata, Sitemap, Robots, and Structured Data

The mechanics of SEO in the App Router — generateMetadata, canonical tags, correctly setting lastModified in your sitemap, structuring JSON-LD with @graph, and which rich results are still actually alive.

Furkan ÖzaySeptember 2, 2026 · 7 min read

SEO in Next.js isn't about installing plugins — it's just a few files and a few functions. But figuring out what does what can get confusing, and most guides are floating around with outdated info.

This post breaks down the SEO surfaces in the App Router one by one. All the examples are from the actual code running on this very site.

1. Metadata: Static and Generated

There are two formats. For static pages, the metadata object:

TypeScript
// app/studio/page.tsx
export const metadata: Metadata = {
	title: "Czay Studio",
	description: "Work I've delivered for companies and brands…",
	alternates: { canonical: "https://czay.dev/studio" },
	openGraph: {
		title: "Czay Studio | Furkan Özay",
		description: "…",
		url: "https://czay.dev/studio",
	},
};

For dynamic pages, generateMetadata:

TypeScript
// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
	const { slug } = await params;
	const post = getPost(slug);
	if (!post) return {};
 
	return {
		title: post.title,
		description: post.description,
		alternates: { canonical: `https://czay.dev/blog/${slug}` },
		openGraph: {
			title: `${post.title} | Furkan Özay`,
			description: post.description,
			url: `https://czay.dev/blog/${slug}`,
			type: "article",
			publishedTime: post.date,
			modifiedTime: post.updated ?? post.date,
			authors: ["Furkan Özay"],
			tags: post.tags,
		},
	};
}

Warning: Don't forget modifiedTime

I added this line while writing this post. The site had publishedTime but was missing modifiedTime: the posts I had updated still looked like their initial versions in shares and browsers. If you make a habit of updating your posts, this field is the cheapest signal to communicate freshness.

Root template

If you define a title template in layout.tsx, you don't have to keep rewriting your brand name on every single page:

TypeScript
export const metadata: Metadata = {
	metadataBase: new URL("https://czay.dev"),
	title: {
		default: "Furkan Özay — Fullstack Developer & Teacher",
		template: "%s | Furkan Özay",
	},
};

metadataBase is especially important: without it, relative Open Graph image URLs aren't resolved to absolute URLs, and your social sharing cards will show up blank.

2. Canonical: The most overlooked field

Every page should declare its own canonical URL. When you don't define this, different paths leading to the same content (tracking parameters, trailing slashes, the www variant) are treated as separate pages, splitting your SEO signals.

TypeScript
alternates: { canonical: "https://czay.dev/studio/mobile" }

This is even more critical if you keep your blog's filter state in the address bar: a list loaded with ?topic=nextjs isn't a separate page, it's just a view of the same page.

3. sitemap.ts and robots.ts

Both are simply files; you don't write raw XML anymore.

TypeScript
// app/sitemap.ts
export default function sitemap(): MetadataRoute.Sitemap {
	const posts = getAllPosts().map((post) => ({
		url: `https://czay.dev/blog/${post.slug}`,
		lastModified: new Date(post.updated ?? post.date),
		changeFrequency: "monthly" as const,
		priority: 0.7,
	}));
 
	return [...staticPages, ...posts];
}
TypeScript
// app/robots.ts
export default function robots(): MetadataRoute.Robots {
	return {
		rules: { userAgent: "*", allow: "/" },
		sitemap: "https://czay.dev/sitemap.xml",
	};
}

Caution: Don't populate lastModified with the publication date

This was another mistake I caught and fixed while writing this post: the sitemap was using new Date(post.date), which completely ignored the updated field. I had updated six posts, and the sitemap was still serving them with their old dates — wasting the recrawl signal.

The correct way: new Date(post.updated ?? post.date)

Also, pay attention to which pages actually go into your sitemap. On this site, service detail pages exist to be found individually; leaving them out of the sitemap would defeat that purpose:

TypeScript
const servicePages = studioServices
	.filter((service) => service.detail)
	.map((service) => ({
		url: `https://czay.dev/studio/${service.id}`,
		changeFrequency: "monthly" as const,
		priority: 0.8,
	}));

4. Structured Data (JSON-LD)

Next.js doesn't have a built-in API for this; you render the script tag yourself.

TSX
const jsonLd = {
	"@context": "https://schema.org",
	"@type": "BlogPosting",
	headline: post.title,
	datePublished: post.date,
	dateModified: post.updated ?? post.date,
	author: { "@type": "Person", name: "Furkan Özay" },
};
 
export default function Page() {
	return (
		<>
			<script
				type="application/ld+json"
				dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
			/>
			{/* … */}
		</>
	);
}

Using JSON.stringify is crucial: if you try to stitch the text together manually, quotes and < characters in your content will break the schema.

Set up multiple schemas using @graph

If you need to define multiple entities on a single page, instead of using separate script tags, use a single @graph — this allows entities to reference each other using @id:

TypeScript
{
	"@context": "https://schema.org",
	"@graph": [
		{ "@type": "Service", "@id": `${url}#service`, name: "Mobile app", /* … */ },
		{ "@type": "BreadcrumbList", "@id": `${url}#breadcrumb`, /* … */ },
		{ "@type": "FAQPage", "@id": `${url}#faq`, /* … */ },
	],
}

Important: Which rich results are actually still alive?

There's a lot of outdated advice floating around here. Here is the current landscape:

  • BreadcrumbList — Still works; displays the "site › section › page" hierarchy in search results. Easy implementation, clear payoff.
  • Article / BlogPosting — Doesn't yield a rich snippet box, but exposes dates, author, and modification info to the crawler.
  • FAQPage — Google restricted FAQ rich results to authoritative sites in August 2023 and fully retired them on May 7, 2026. The markup is still valid and read by non-Google consumers, but expecting a snippet box on Google is now a mistake.

So, the advice of "let's add an FAQ schema to grab rich results" is obsolete. Use structured data to describe your page, not to hunt for snippets.

Don't put off-screen content in the schema

Structured data describes the actual content on the page. Adding an FAQ to your schema that doesn't exist on the rendered page violates search engine policies and can get you penalized.

5. Static Generation and generateStaticParams

The invisible half of SEO is speed. If you don't write this for dynamic routes, every page gets rebuilt on every single request:

TypeScript
export async function generateStaticParams() {
	return getAllPosts().map((post) => ({ slug: post.slug }));
}

The same applies to service detail pages — if you have a finite and known list, all of them should be pre-rendered at build time.

6. Open Graph Images

Next.js can generate images dynamically using code; you don't need to manually design an image for every single post:

plaintext
app/blog/[slug]/opengraph-image.tsx

This file returns a React component, and Next.js renders it into a 1200×630 image. You can inject the title, tags, and reading time directly into it.

7. RSS is Still Valuable

Generating a feed with app/rss.xml/route.ts takes just a few lines of code. Even if reader counts seem low, RSS is the easiest way to push your content to aggregators and newsletters — and those inbound links are real.

Checklist

Summary: Before going live

  • Is there a metadataBase and title template in the root layout.tsx?
  • Is alternates.canonical filled out for every page?
  • Does generateMetadata provide publishedTime and modifiedTime on dynamic pages?
  • Is lastModified in sitemap.ts actually the date of the last update?
  • Are the pages you want to show up separately in search results included in the sitemap?
  • Did you write generateStaticParams for dynamic routes?
  • Is JSON-LD rendered using JSON.stringify?
  • Does everything declared in the schema actually exist on the page?
  • Does robots.ts declare the sitemap?

I fixed two items on this list on my own site while writing this post. The hard part of SEO isn't not knowing; it's neglecting to look back at it once it's set up.

Related post: MDX with App Router: the right way to build a blog