SEO for Next.js SaaS: an App Router-friendly checklist (as of July 2026)
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 15/16 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: 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.
1. Metadata API in app/layout.tsx + generateMetadata
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. Root defaults matter because every route below inherits them unless overridden. On citeclip.com the root layout sets title 'CiteClip — AI SEO articles that rank on Google and get cited by ChatGPT', a ~350-character description, matching OG and Twitter cards, and canonical https://citeclip.com. Every child route can override any of those. 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. The trap: generateMetadata runs on the server per request unless you cache the underlying data fetch. If your metadata function makes an uncached DB call, you'll pay that latency on every crawl. Use unstable_cache or React's cache() wrapper on the fetch. As of July 2026 (Next.js 16) cache() works reliably for this pattern. Skip generateMetadata on dynamic routes and every URL inherits the root title. Google will see 3,000 URLs with the same title. That's a duplicate-content signal and you'll rank for none of them.
2. Dynamic OG images via opengraph-image.tsx
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. For blog posts, use app/blog/[slug]/opengraph-image.tsx. Fetch the post data (same source your page uses), render title + author + date inside an ImageResponse, and Next.js serves a per-post OG image at /blog/[slug]/opengraph-image.png. X, LinkedIn, and Facebook all crawl this URL when the article is shared. ImageResponse takes JSX — but a subset. No Tailwind full spec, no CSS files. Inline styles only, plus a tw='...' prop with a Tailwind-like syntax. Keep the JSX simple: a background color, a title in a large font, one supporting line, one logo mark. 90% of SaaS OG images are over-designed and unreadable in the ~200-pixel-tall preview cards X uses. Cache aggressively. Also export const alt, const size = { width: 1200, height: 630 }, and const contentType = 'image/png'. Next.js caches per URL, but Vercel's edge CDN caches it globally if Cache-Control is set correctly (see item 9). 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. Every shared link on X or LinkedIn now shows a legible, on-brand card.
3. sitemap.ts + robots.ts (the file-system routes)
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. The signature: default async function sitemap() returns an array of { url, lastModified, priority, changeFrequency } objects. Combine static routes (homepage, pricing, blog index) with dynamic routes (blog posts pulled from the database) in the same array. Filter out drafts, private routes, and legacy pre-pivot pages. On citeclip.com we filter out any blog post with category === 'legacy' and scope the sitemap to ~10 static routes + all current blog posts. Total sitemap size: 30 URLs. This is not a mistake — a small, curated sitemap beats a 3,000-URL sitemap of thin content every time. See our seo-for-indie-hackers-51-to-1000-impressions post for why deindexing 181 pre-pivot pages was the highest-leverage fix on this codebase. 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). Do not use robots.txt to hide content — use robots: { index: false } in the route's metadata instead. robots.txt Disallow only blocks crawling, not indexing; a disallowed page can still be indexed via external links. Both files are 20-40 lines. Both should exist in your repo before you publish your first blog post.
4. JSON-LD injection in <head>
Two ways to inject JSON-LD into `<head>` 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. On citeclip.com we use the layout-level approach for the site-wide Organization + WebSite + SoftwareApplication schema. Root app/layout.tsx renders `<script type='application/ld+json' dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />` inside `<head>`. That schema loads on every page and satisfies the site-wide identity signals. Per-page schema (Article, FAQPage, HowTo, Product) goes inline in the page component. A blog post page renders its own JSON-LD script with Article schema populated from the post — title, author, datePublished, image, articleBody excerpt. FAQPage schema goes on posts that have an FAQ block (see our generative-engine-optimization-checklist for why FAQPage schema is signal #2 in the citation-rate correlation). The trap: dangerouslySetInnerHTML needs the JSON stringified once at server render time, not re-executed on the client. Server Components handle this correctly by default. Accidentally putting JSON-LD inside a Client Component means you re-render it on hydration and pay the cost. Validate every schema in Google's Rich Results Test tool before shipping. Broken schema is worse than no schema.
5. Route-level canonical URLs
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: '...' }. Root layout sets the default: alternates: { canonical: 'https://citeclip.com' }. Every child route inherits — which is wrong. Every blog post URL would report https://citeclip.com as canonical. Override in each dynamic route's generateMetadata to return the route-specific URL: alternates.canonical = the current page's fully-qualified URL. 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. Trailing slash policy: pick one, be consistent. Next.js does not add trailing slashes by default. Match your Vercel domain config to the metadata policy. Mixed trailing-slash canonicals fragment ranking signal across two URL variants. 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 Medium and Dev.to both honor cross-domain canonicals correctly if set via their canonical field.
6. Streaming, Suspense, and what crawlers see
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. The rule: any content you want indexed must be inside the initial HTML response. Above-the-fold content — H1, first paragraph, primary CTA — should render synchronously (server-fetched, no Suspense). Below-the-fold content (comments, related posts, sidebar analytics) can stream inside Suspense boundaries. Perplexity and Claude's web tools are more aggressive than Googlebot. As of July 2026 they generally do not wait for streamed content — they scrape the initial HTML and move on. Anything inside a Suspense boundary is often invisible to AI engines. If your article's main body streams in, you will not get cited. 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 is: marginally slower TTFB, faster indexation. The right tradeoff for a content-first SaaS.
7. noindex for auth/dashboard + force-static vs force-dynamic
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. Export metadata: { robots: { index: false, follow: true } } from the layout of the noindex tree. On citeclip.com that's app/business/layout.tsx — sets noindex on the entire dashboard subtree in one file. follow: true means crawlers still traverse the internal links out from those pages, so any anchor pointing to a public marketing page still transfers link signal. 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. Default is 'auto' — Next.js decides based on what the route does. Blog posts, product pages, marketing pages should be force-static (or ISR'd with revalidate). Dashboards, personalized pages should be force-dynamic (and noindex'd, per above). If you use cookies(), headers(), or searchParams inside a page, Next.js implicitly forces dynamic. For public content, avoid those APIs so the route can stay static. As of July 2026 the ISR pattern (export const revalidate = 3600) is our default for blog posts.
8. Vercel Edge caching, Cache-Control, and what to ship this week
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 static routes, Vercel handles this automatically — force-static routes get cached at the edge with immutable Cache-Control. For dynamic routes serving public content, set Cache-Control manually in the response headers via middleware or a route handler. The pattern: 'Cache-Control: s-maxage=3600, stale-while-revalidate=86400'. Serves fresh for an hour, stale for a day while re-fetching. Works for blog listings, category pages, sitemap.xml, and RSS feeds. For OG images: 'Cache-Control: public, max-age=31536000, immutable' — cache forever, invalidate by URL versioning if the content changes. Do not set no-cache or no-store on public content. Every page you no-cache gets a slow TTFB on every crawler hit. Googlebot alone hits large sites tens of thousands of times a month — no-cache multiplies origin costs and hurts rankings. Test with curl -I on your blog URLs; if Cache-Control is missing or no-cache, fix it before shipping more posts. This week: ship the checklist. Root app/layout.tsx has the metadata + JSON-LD. app/sitemap.ts and app/robots.ts exist. Every dynamic route implements generateMetadata with a route-specific canonical. Every noindex subtree has robots: { index: false } at its layout level. Public content is force-static or ISR'd. Streaming is limited to below-the-fold content. Cache-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.