Claude Code Boilerplate
FeaturesPricingBlogDocs
Get started →

Product

  • Features
  • Pricing
  • Skills

Compare

  • vs ShipFast
  • vs MakerKit
  • vs supastarter

Resources

  • Docs
  • Blog
  • Discord

Legal

  • License
  • Privacy Policy
  • Terms of Service
Claude Code Boilerplate

© 2026 Claude Code Boilerplate. All rights reserved.

← All posts

SEO in Next.js App Router -- metadata, OG images, sitemap, and JSON-LD for a SaaS

June 4, 2026
nextjsseosaasmetadata

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.

Root metadata in app/layout.tsx

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.

Static page metadata

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).

Dynamic metadata

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.

OG image

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.

Sitemap

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.

Robots

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`,
  };
}

JSON-LD

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 */}
  </>
);

What to do next

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.