All posts
Stack / how-to·Updated Aug 8, 2026·13 min read

SEO for Next.js SaaS: an App Router-friendly checklist (as of July 2026)

Carlos, founder of CiteClip

TL;DR

SEO for Next.js SaaS on App Router differs from Pages Router, metadata API, dynamic OG images, and streaming all change how you signal to crawlers. Here's the 9-item checklist we run on citeclip.com in production as of July 2026.

SEO for Next.js SaaS on the App Router (13+, currently 16.x as of July 2026) works differently from the Pages Router. The metadata API replaces the manual <Head> component. File-system conventions (sitemap.ts, robots.ts, opengraph-image.tsx) replace hand-rolled routes. Streaming and Suspense change what crawlers see mid-render. Route segment config controls caching in ways that either help or hurt SEO.

Most "Next.js SEO checklist" posts you'll find still target Pages Router. That advice is stale. This is the 9-item checklist we actually run on citeclip.com in production. Every item references a file that exists in the repo. If you're a solo founder or vibe coder on Next.js 15+, this maps 1:1 to your app/ directory.

Companion posts: the solo-SaaS SEO five-page framework covers what to publish; this covers how to ship it correctly on App Router. For the AI-search layer, our how-to-appear-in-ChatGPT-cited-sources post covers what to put inside each of those pages. If you're on Astro instead, see the Astro SEO rank plan.

1. Metadata API in app/layout.tsx + generateMetadata

Root layout sets the site-wide defaults. Every dynamic route overrides them via generateMetadata, or Google sees 3,000 URLs sharing one title.

The metadata API is exported from any layout or page file. In your root app/layout.tsx, export a metadata object with title, description, openGraph, twitter, alternates.canonical, and metadataBase. Next.js injects the equivalent HTML into <head> at render time. No manual <Head> component required.

ts
import type { Metadata } from 'next';
export const metadata: Metadata = {
metadataBase: new URL('https://citeclip.com'),
title: {
default: 'CiteClip · AI SEO articles that get cited by ChatGPT',
template: '%s · CiteClip',
},
description: 'The GEO engine for solo founders...',
alternates: { canonical: 'https://citeclip.com' },
openGraph: {
type: 'website',
siteName: 'CiteClip',
},
};
Root app/layout.tsx (abridged). Every field here becomes the site-wide default. `metadataBase` lets relative URLs elsewhere resolve correctly.

For dynamic routes (blog post pages, product pages), export generateMetadata instead. It's an async function that receives the route params and returns a Metadata object. This is where you pull the post title from the database, produce a route-specific canonical URL, and set an OG description that matches the article excerpt.

tsx
export async function generateMetadata(
{ params }: { params: Promise<{ slug: string }> }
): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
const canonical = `https://citeclip.com/blog/${slug}`;
return {
title: post.title,
description: post.excerpt,
alternates: { canonical },
openGraph: {
title: post.title,
description: post.excerpt,
url: canonical,
type: 'article',
publishedTime: post.date,
},
};
}
app/blog/[slug]/page.tsx. Per-route metadata overrides root defaults. Without this, every URL inherits the root title.

2. Dynamic OG images via opengraph-image.tsx

One file per route generates the OG image. Ship a branded default at the root and one per-slug generator per dynamic content type.

File-system convention. Create app/opengraph-image.tsx (or app/[dynamic-route]/opengraph-image.tsx) and export a default function that returns an ImageResponse from next/og. Next.js registers the file as a runtime route and generates a 1200×630 image on demand.

tsx
import { ImageResponse } from 'next/og';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
export async function generateStaticParams() {
return posts.map((p) => ({ slug: p.slug }));
}
export default async function OG({
params,
}: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = getPost(slug);
return new ImageResponse(
(
<div style={{ display: 'flex', /* ... */ }}>
<h1>{post.title}</h1>
</div>
),
{ ...size }
);
}
app/blog/[slug]/opengraph-image.tsx. `generateStaticParams` pre-renders one image per post at build time. Add caching headers so Vercel's edge holds them.

As of July 2026 citeclip.com uses one root opengraph-image.tsx (branded default) plus one blog-post-scoped file that renders the post title into the same visual frame. Total code: ~80 lines across both files.

3. sitemap.ts + robots.ts (the file-system routes)

A 30-URL curated sitemap beats a 3,000-URL thin-content sitemap. Both files should exist before you publish post #1.

Two file-system routes. app/sitemap.ts exports a default async function returning MetadataRoute.Sitemap. Next.js compiles it to /sitemap.xml at build time (or per-request if marked dynamic). No sitemap-generation library needed.

ts
import type { MetadataRoute } from 'next';
import { getPublishedPosts } from '@/lib/blog';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getPublishedPosts();
const staticRoutes = [
{ url: 'https://citeclip.com', priority: 1.0 },
{ url: 'https://citeclip.com/pricing', priority: 0.8 },
{ url: 'https://citeclip.com/blog', priority: 0.9 },
];
const blogRoutes = posts.map((p) => ({
url: `https://citeclip.com/blog/${p.slug}`,
lastModified: new Date(p.date),
priority: 0.7,
}));
return [...staticRoutes, ...blogRoutes];
}
app/sitemap.ts. Combine static routes with database-driven dynamic routes in the same array. Filter drafts + legacy content.

app/robots.ts exports a default function returning MetadataRoute.Robots. Compiles to /robots.txt. Point Sitemap: to your sitemap URL, list any Disallow paths (typically /api/, /dashboard/, /sign-in).

4. JSON-LD injection in <head>

Site-wide schema in root layout. Per-page schema (Article, FAQPage) in the page. Validate every one before shipping, broken schema hides you entirely.

Two ways to inject JSON-LD on App Router. The first: render a <script type="application/ld+json"> tag inside a Server Component whose HTML ends up in the page's head. The second (recommended for site-wide schema): render <head> inside your root layout with the script tag inside it.

tsx
const articleJsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
datePublished: post.date,
author: { '@type': 'Person', name: 'Carlos' },
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleJsonLd) }}
/>
{/* page content */}
</>
);
Inside a Server Component. dangerouslySetInnerHTML serializes the object once at server render, never re-executes on the client.

On citeclip.com we use the layout-level approach for site-wide Organization + WebSite + SoftwareApplication schema. Per-page schema (Article, FAQPage, HowTo, Product) goes inline in the page component.

5. Route-level canonical URLs

Set metadataBase once in root layout. Override alternates.canonical in every dynamic route. Pick a trailing-slash policy and enforce it.

Canonical URLs tell crawlers which URL is the primary version when multiple URLs serve the same content. On App Router, set canonical inside metadata via alternates: { canonical: '...' }.

metadataBase matters here. Set it once in root layout: metadataBase: new URL('https://citeclip.com'). That makes relative canonical URLs resolve correctly and prevents Vercel preview URLs from getting canonicalized to their preview domain by mistake.

Cross-domain canonicals for republishing: if you syndicate a blog post to Medium or Dev.to, set the canonical on the Medium/Dev version back to your citeclip.com URL. As of July 2026 both platforms honor cross-domain canonicals correctly if set via their canonical field.

6. Streaming, Suspense, and what crawlers see

Anything inside a Suspense boundary can be invisible to Perplexity + Claude. Main content renders synchronously. Stream only below-the-fold.

App Router supports streaming via Suspense boundaries. <Suspense fallback={<Skeleton />}> lets you render fast static content immediately and stream slower content in progressively. Good for user LCP. Complicated for SEO.

Google's crawler executes JavaScript and waits for the fully-streamed response before indexing. In practice, this works. The failure mode is when the initial HTML shell contains only skeleton placeholders and the actual content streams in via Suspense with a 3+ second delay. Some crawlers time out. AI engines almost always do.

On citeclip.com the blog post pages render the full article body server-side (no Suspense). Only the "you might also like" related-posts widget streams. The tradeoff: marginally slower TTFB, faster indexation. The right tradeoff for a content-first SaaS.

7. noindex for auth/dashboard + force-static vs force-dynamic

Noindex the dashboard subtree at the layout level. Force-static public content so Vercel caches it at the edge.

Not every route should be indexed. Auth pages (/sign-in, /sign-up), dashboard routes (/business/dashboard/*), settings pages, and internal-only tools should be noindex.

ts
import type { Metadata } from 'next';
export const metadata: Metadata = {
robots: { index: false, follow: true },
};
app/business/layout.tsx. One layout export noindexes the entire dashboard subtree. follow:true keeps internal-link signal flowing to public pages.

For public content routes, set the segment config. export const dynamic = 'force-static' marks the route as fully static (rendered at build time). export const dynamic = 'force-dynamic' marks it as per-request. Static routes are cached at the edge and serve in under 50ms, best for SEO. Dynamic routes hit your origin server every request, worse for SEO because slow TTFB hurts rankings.

8. Vercel Edge caching, Cache-Control, and what to ship this week

Static pages get edge-cached automatically. For dynamic public content, set Cache-Control manually or pay the origin-latency tax on every crawl.

Static pages served via Vercel's edge network hit under 100ms globally. That's a ranking factor, Google's Core Web Vitals (LCP, INP, CLS) all benefit from edge caching. As of July 2026 CWV is still a confirmed ranking signal for Google's main search results.

For force-static routes Vercel handles caching automatically. For dynamic routes serving public content, set Cache-Control manually in the response headers via middleware or a route handler.

ts
// Blog listings, category pages, sitemap.xml, RSS feeds:
'Cache-Control: s-maxage=3600, stale-while-revalidate=86400'
// OG images, versioned static assets:
'Cache-Control: public, max-age=31536000, immutable'
The two Cache-Control patterns you actually need. First for HTML that revalidates hourly; second for immutable assets like OG images.

This week: ship the checklist.

  1. 1Root app/layout.tsx has the metadata + JSON-LD.
  2. 2app/sitemap.ts and app/robots.ts exist.
  3. 3Every dynamic route implements generateMetadata with a route-specific canonical.
  4. 4Every noindex subtree has robots: { index: false } at its layout level.
  5. 5Public content is force-static or ISR'd.
  6. 6Streaming is limited to below-the-fold content.
  7. 7Cache-Control is set on every route handler that serves public content.

That's the mechanical layer. For the content layer, use the solo-SaaS SEO five-page framework. For the AI-search layer, our how-to-appear-in-ChatGPT-cited-sources post covers what to put inside each page. CiteClip drafts SEO + GEO-ready articles into WordPress and (as of July 2026) is rolling out a Next.js MDX integration for the same pipeline. Sign up at citeclip.com, the first 4 articles are free, no credit card required.

Frequently asked

How do I set metadata in Next.js App Router?
Export a metadata: Metadata object from your root app/layout.tsx for site-wide defaults, then override in each route by exporting either a metadata object or an async generateMetadata function. Include metadataBase in root so relative URLs resolve correctly. Every dynamic route should implement generateMetadata to override the inherited canonical or Google sees duplicate titles across every URL.
How do I generate a sitemap in Next.js App Router?
Create app/sitemap.ts with a default async function that returns MetadataRoute.Sitemap. Combine static routes with database-driven dynamic routes in one array. Next.js compiles it to /sitemap.xml at build time. No library needed. Prefer a small curated sitemap of 20-30 real URLs over a 3,000-URL sitemap of thin content.
Does Suspense break SEO in Next.js?
Suspense-streamed content is often invisible to AI-search engines (Perplexity, Claude) as of July 2026, they don't wait for the streamed response and scrape the initial HTML only. Googlebot handles streaming correctly but slow-streaming content risks timeouts. Rule: main article body renders synchronously (no Suspense); only below-the-fold widgets (related posts, comments) stream.
How do I add JSON-LD structured data in Next.js App Router?
Render a <script type="application/ld+json" dangerouslySetInnerHTML={{__html: JSON.stringify(jsonLd)}} /> inside a Server Component. Site-wide schema (Organization, WebSite) goes in the root layout, per-page schema (Article, FAQPage, HowTo) goes in the specific page. Server Components serialize once at render time; putting JSON-LD in a Client Component re-executes on hydration and pays the cost.
How do I noindex the dashboard subtree?
Export metadata: { robots: { index: false, follow: true } } from the parent layout of the noindex tree (e.g., app/business/layout.tsx). One layout noindexes the entire subtree; follow: true preserves internal-link signal flowing back to public marketing pages.

Keep reading


Get found in ChatGPT, Perplexity, Gemini. And Google.

We watch your competitors, find the topics you're missing, and draft the articles those engines recommend. One click sends them to WordPress.