SEO in a Next.js SaaS is mechanical once you know which file controls what. This post covers everything the App Router needs -- root metadata, per-page titles, dynamic OG images, sitemap, robots, and JSON-LD -- so you ship with solid search visibility from day one.
Set metadataBase here so all relative OG image URLs resolve correctly. The title.template pattern appends your site name to every page title automatically.
// app/layout.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!),
title: {
default: 'MySaaS',
template: '%s | MySaaS',
},
description: 'The fastest way to ship a full-stack SaaS.',
openGraph: {
type: 'website',
locale: 'en_US',
},
twitter: {
card: 'summary_large_image',
},
};
Every child page that exports title: 'Dashboard' will automatically render as Dashboard | MySaaS in the browser tab and OG card.
Export a metadata const from each page.tsx. Always include a unique title, description, and alternates.canonical.
// app/(main)/page.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Home',
description: 'Build and ship your SaaS faster with a pre-configured Next.js boilerplate.',
alternates: { canonical: '/' },
};
The canonical URL prevents duplicate-content penalties when the same page is reachable at multiple paths (e.g. with and without trailing slashes).
For pages where the title depends on fetched data -- blog posts, product pages -- use generateMetadata. It runs on the server and supports the same full OG block as static metadata.
// app/(main)/blog/[slug]/page.tsx
import type { Metadata } from 'next';
import { postService } from '@/modules/post';
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await postService.getBySlug(slug);
return {
title: post.title,
description: post.description,
alternates: { canonical: `/blog/${slug}` },
openGraph: {
title: post.title,
description: post.description,
type: 'article',
publishedTime: post.createdAt.toISOString(),
},
};
}
Fetch once inside generateMetadata and once inside the page component -- Next.js deduplicates identical fetch() calls automatically, so there is no double round-trip.
A static root OG image at app/opengraph-image.tsx covers every page that does not provide its own. Use the edge runtime and inline styles only -- no Tailwind inside ImageResponse.
// app/opengraph-image.tsx
import { ImageResponse } from 'next/og';
export const runtime = 'edge';
export const alt = 'MySaaS';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
export default function OgImage() {
return new ImageResponse(
(
<div
style={{
background: '#0f172a',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
fontFamily: 'sans-serif',
color: '#f8fafc',
fontSize: 64,
fontWeight: 700,
}}
>
MySaaS
</div>
),
{ ...size }
);
}
For dynamic per-post OG images that need a DB query, create app/api/og/[slug]/route.tsx with runtime = 'nodejs' instead -- the edge runtime cannot connect to Neon.
app/sitemap.ts returns a MetadataRoute.Sitemap array. Fetch dynamic slugs from the DB and spread them alongside your static routes.
// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { postService } from '@/modules/post';
const BASE = process.env.NEXT_PUBLIC_BASE_URL!;
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await postService.getAllPublished();
const postEntries = posts.map((post) => ({
url: `${BASE}/blog/${post.slug}`,
lastModified: post.updatedAt,
changeFrequency: 'weekly' as const,
priority: 0.7,
}));
return [
{ url: BASE, changeFrequency: 'daily', priority: 1.0 },
{ url: `${BASE}/blog`, changeFrequency: 'daily', priority: 0.8 },
...postEntries,
];
}
Next.js serves this at /sitemap.xml automatically. Submit the URL to Google Search Console once and it re-fetches on each deploy.
Disallow crawling your API and auth routes. Always include the sitemap field.
// app/robots.ts
import type { MetadataRoute } from 'next';
const BASE = process.env.NEXT_PUBLIC_BASE_URL!;
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/', '/dashboard/', '/settings/'],
},
],
sitemap: `${BASE}/sitemap.xml`,
};
}
Inject structured data for blog posts using a <script> tag in the server component. Search engines use this for rich results (article date, author, breadcrumbs).
// app/(main)/blog/[slug]/page.tsx (inside the default export)
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
description: post.description,
datePublished: post.createdAt.toISOString(),
dateModified: post.updatedAt.toISOString(),
author: { '@type': 'Organization', name: 'MySaaS' },
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
{/* page content */}
</>
);
Start with metadataBase and title.template in app/layout.tsx -- everything else builds on top of that. Add app/sitemap.ts next and submit it to Google Search Console. Once those two are in place, OG images and JSON-LD layer on cleanly without touching your existing pages.