Next.js App Router has excellent built-in SEO support, but getting it right requires understanding how the Metadata API works, which pages should be statically rendered, and how to handle the details that actually move rankings: structured data, canonical URLs, and Open Graph tags.
This is the complete setup for a production SaaS — not just the basics.
Static vs dynamic metadata
Next.js App Router supports two ways to set metadata: the static export const metadata object for pages with fixed titles, and the async generateMetadata function for pages where the title depends on dynamic data (like a blog post slug).
// Static: app/(marketing)/about/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "About | GetLaunchpad",
description: "GetLaunchpad is a production-ready Next.js SaaS boilerplate.",
alternates: {
canonical: "https://getlaunchpad.net/about",
},
};
// Dynamic: app/blog/[slug]/page.tsx
import type { Metadata } from "next";
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = posts.find((p) => p.slug === slug);
if (!post) return {};
return {
title: `${post.title} | GetLaunchpad Blog`,
description: post.excerpt,
alternates: {
canonical: `https://getlaunchpad.net/blog/${post.slug}`,
},
openGraph: {
title: post.title,
description: post.excerpt,
type: "article",
publishedTime: post.date,
},
};
}
Root layout default metadata
Set sitewide defaults in your root layout. Child pages can override individual fields.
// app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: {
default: "GetLaunchpad — Next.js SaaS Boilerplate",
template: "%s | GetLaunchpad",
},
description: "Production-ready Next.js 16 SaaS starter with Clerk, Stripe, and Supabase.",
metadataBase: new URL("https://getlaunchpad.net"),
openGraph: {
siteName: "GetLaunchpad",
locale: "en_US",
type: "website",
},
robots: {
index: true,
follow: true,
},
};
The metadataBaseis required for Next.js to resolve relative URLs in Open Graph and Twitter card tags correctly. Without it, image URLs will be relative paths that social crawlers can't fetch.
Canonical URLs
Every public page should have a canonical URL. Duplicate content across http:// and https://, with and without www, or with query parameters can split your ranking signals.
alternates: {
canonical: "https://getlaunchpad.net/blog/nextjs-seo-guide",
}
Next.js renders this as <link rel="canonical" href="..." /> in the page head. Make sure the canonical always uses your production domain with HTTPS.
Open Graph and Twitter cards
When your page is shared on LinkedIn, Slack, X, or iMessage, the preview is built from Open Graph tags. Without them, you get a generic link with no image or description.
openGraph: {
title: "How to add dark mode to Next.js App Router",
description: "Without the flash: the correct setup with next-themes and Tailwind v4.",
url: "https://getlaunchpad.net/blog/nextjs-dark-mode",
siteName: "GetLaunchpad",
type: "article",
publishedTime: "2026-04-11",
images: [
{
url: "https://getlaunchpad.net/og-blog.png",
width: 1200,
height: 630,
alt: "GetLaunchpad Blog",
},
],
},
twitter: {
card: "summary_large_image",
title: "How to add dark mode to Next.js App Router",
description: "Without the flash.",
},
opengraph-image.tsx for auto-generated OG images
Next.js can generate Open Graph images at build time using the opengraph-image.tsx file convention:
// app/opengraph-image.tsx
import { ImageResponse } from "next/og";
export const runtime = "edge";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default function Image() {
return new ImageResponse(
(
<div
style={{
background: "#18181b",
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
color: "white",
fontFamily: "sans-serif",
}}
>
<div style={{ fontSize: 72, fontWeight: 700 }}>GetLaunchpad</div>
<div style={{ fontSize: 28, color: "#a1a1aa", marginTop: 16 }}>
Next.js SaaS Boilerplate
</div>
</div>
),
{ width: 1200, height: 630 }
);
}
Sitemap
A sitemap tells search engines which pages to crawl and index. Create it as a route handler using Next.js's sitemap.ts file convention:
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { posts } from "./(marketing)/blog/posts";
export default function sitemap(): MetadataRoute.Sitemap {
const blogUrls = posts.map((p) => ({
url: `https://getlaunchpad.net/blog/${p.slug}`,
lastModified: new Date(p.date),
changeFrequency: "monthly" as const,
priority: 0.7,
}));
return [
{
url: "https://getlaunchpad.net",
lastModified: new Date(),
changeFrequency: "weekly",
priority: 1,
},
{
url: "https://getlaunchpad.net/blog",
lastModified: new Date(),
changeFrequency: "daily",
priority: 0.9,
},
...blogUrls,
];
}
Submit this URL (https://yourapp.com/sitemap.xml) to Google Search Console after deploying.
robots.txt
// app/robots.ts
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/dashboard/", "/api/"],
},
sitemap: "https://getlaunchpad.net/sitemap.xml",
};
}
Structured data (JSON-LD)
JSON-LD gives search engines structured context about your page. For a SaaS landing page, include SoftwareApplication and FAQPage schemas:
// In your page component
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "GetLaunchpad",
"applicationCategory": "DeveloperApplication",
"offers": {
"@type": "Offer",
"price": "29",
"priceCurrency": "USD"
}
})
}}
/>
For blog posts, use BlogPosting schema with the published date, author, and article URL. Google may use this to display rich results in search.
Performance and Core Web Vitals
Google uses Core Web Vitals (LCP, FID, CLS) as ranking signals. Next.js App Router helps by default — server components reduce JavaScript bundle size, and the Image component prevents layout shift. The biggest things to fix:
- Use next/image for all images. It prevents CLS, lazy-loads by default, and serves WebP automatically.
- Use next/font. Self-hosting fonts eliminates the external font request that often causes LCP delays.
- Minimize client components.Every "use client" boundary adds JavaScript to the bundle. Keep them at the leaves of your component tree.