Why this exists
The App Router has been GA for two years. Most teams still get the basics wrong — and the failure modes are silent. A Client Component bleeds into your SEO surface, an effect-driven metadata call ships zero <title> to bots, a server action becomes a public API by accident. We see all of these every month auditing inherited codebases. This post is the production checklist we use internally before any Next.js 15 build hits a Bluewich 15-day ship deadline.
If you want context on why we treat the SEO surface as sacred, our WordPress to Next.js migration playbook covers what happens when you treat it casually.
Pattern 1: SEO surface = 100% RSC
Marketing pages, product pages, blog posts, location pages — anything a bot will index — must render entirely on the server with no client boundary above the <main>. Bots see the rendered HTML; partial hydration only helps the browser. If a Client Component is high in your tree, every descendant ships JS your SEO surface doesn't need.
// app/(marketing)/[slug]/page.tsx — RSC by default
import { getPost } from '@/lib/cms'
import { ArticleBody } from './article-body' // RSC
import { LikeButton } from './like-button.client' // Client island
export default async function Page({ params }) {
const { slug } = await params // Next 15: params is async
const post = await getPost(slug)
return (
<article>
<ArticleBody post={post} />
<LikeButton id={post.id} /> {/* leaf-level interactivity */}
</article>
)
}
The audit question
Open DevTools, disable JS, refresh. Is the page visible and complete? If the answer is "no, the hero text is missing," your SEO surface is broken. Bots run JS unevenly; assume they don't.
Pattern 2: 'use client' at the leaf, not the root
The most common mistake: someone slaps 'use client' on a layout because they need a single dropdown. Every descendant becomes a Client Component by inheritance. We see hydration payloads 5-12x larger than necessary because of this one mistake.
// BAD — pollutes everything below
'use client'
export default function Layout({ children }) {
const [open, setOpen] = useState(false)
return <> ... {children} ... </>
}
// GOOD — interactivity isolated
// layout.tsx (RSC)
export default function Layout({ children }) {
return (
<>
<HeaderShell><MobileMenu /></HeaderShell> {/* MobileMenu is the only client island */}
{children}
</>
)
}
Pattern 3: Compose RSC into Client via children
You can't import an RSC into a Client Component, but you can pass one as children. This is how you keep server-rendered content inside an interactive shell — modals, tabs, accordions.
// tabs.client.tsx
'use client'
export function Tabs({ overview, specs, reviews }) {
const [tab, setTab] = useState('overview')
return <div>{tab === 'overview' && overview}{tab === 'specs' && specs}{tab === 'reviews' && reviews}</div>
}
// page.tsx (RSC)
<Tabs
overview={<Overview product={p} />} // RSC content
specs={<Specs product={p} />} // RSC content
reviews={<Reviews productId={p.id} />} // RSC content (async fetch)
/>
Pattern 4: generateMetadata for everything dynamic
Per-route metadata must be server-generated. useEffect-based meta is invisible to bots and to LLM crawlers (covered in SitPlay's AEO research). The contract:
export async function generateMetadata({ params }): Promise<Metadata> {
const { slug } = await params
const post = await getPost(slug)
if (!post) return { title: 'Not Found' }
return {
title: `${post.title} | Bluewich`,
description: post.excerpt,
alternates: { canonical: `https://bluewich.com/insights/${slug}/` },
openGraph: {
title: post.title,
description: post.excerpt,
type: 'article',
publishedTime: post.publishedAt,
authors: ['Yunmin Shin'],
},
twitter: { card: 'summary_large_image', title: post.title },
}
}
Pattern 5: JSON-LD as a server-rendered <script>
Don't use third-party schema libs. Just inline the JSON-LD inside an RSC. It renders into the initial HTML, which is what every parser actually consumes.
function ArticleSchema({ post }) {
const json = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
datePublished: post.publishedAt,
author: { '@type': 'Person', name: 'Yunmin Shin' },
publisher: { '@type': 'Organization', '@id': 'https://bluewich.com/#org' },
}
return <script type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(json) }} />
}
Our SaaS schema deep-dive covers the more interesting types (SoftwareApplication, Offer, Review, FAQPage) and the silent-failure pitfalls.
Pattern 6: Server Actions over API routes for forms
Stop building app/api/contact/route.ts for every form. Server Actions are simpler, faster (no extra HTTP round-trip), and they integrate with React's useActionState. Validate at the boundary with zod and you have a typed pipeline.
// app/contact/actions.ts
'use server'
import { z } from 'zod'
const Schema = z.object({
email: z.string().email(),
message: z.string().min(10).max(2000),
})
export async function submitContact(_prev: unknown, formData: FormData) {
const parsed = Schema.safeParse(Object.fromEntries(formData))
if (!parsed.success) return { ok: false, errors: parsed.error.flatten() }
await sendEmail(parsed.data)
return { ok: true }
}
// form.client.tsx
'use client'
import { useActionState } from 'react'
import { submitContact } from './actions'
export function ContactForm() {
const [state, action, pending] = useActionState(submitContact, { ok: false })
return <form action={action}>...</form>
}
Pattern 7: Always rate-limit server actions
Server actions are public endpoints disguised as functions. Anyone who reads your bundle can invoke them. We've audited codebases where the contact form would happily accept 5,000 requests/sec from one IP. Use @upstash/ratelimit or check headers in middleware.
'use server'
import { headers } from 'next/headers'
import { ratelimit } from '@/lib/ratelimit'
export async function submit(_p, fd: FormData) {
const ip = (await headers()).get('x-forwarded-for') ?? 'anon'
const { success } = await ratelimit.limit(ip)
if (!success) return { ok: false, error: 'rate_limited' }
// ...
}
Pattern 8: Streaming with Suspense for above-the-fold + slow data
RSC supports streaming. Wrap slow async children in <Suspense> so the shell ships immediately and slow sections fill in. Crawlers get the shell instantly; users get a fast LCP.
export default async function Page() {
return (
<>
<Hero /> {/* fast, server-rendered */}
<Suspense fallback={<Skeleton />}>
<ReviewsFromAlgolia /> {/* slow, streamed */}
</Suspense>
</>
)
}
Pattern 9: Cache the right thing — fetch, not the whole page
Next 15 changed defaults: fetch is no longer cached by default. Be explicit. We use { next: { revalidate, tags } } at the data-source level, not export const revalidate at the page level. Page-level revalidation is a blunt instrument.
const res = await fetch(`${CMS}/posts/${slug}`, {
next: { revalidate: 3600, tags: [`post:${slug}`] },
})
// elsewhere, after a CMS webhook
import { revalidateTag } from 'next/cache'
revalidateTag(`post:${slug}`)
Pattern 10: unstable_cache for non-fetch data sources
If your data comes from a database client (Postgres, Mongo, Prisma), fetch's cache won't help. Wrap the call in unstable_cache with explicit tags.
import { unstable_cache } from 'next/cache'
export const getPostBySlug = unstable_cache(
async (slug: string) => db.post.findUnique({ where: { slug } }),
['post-by-slug'],
{ revalidate: 3600, tags: (slug) => [`post:${slug}`] },
)
Pattern 11: Image optimization that actually ships
Next's <Image> is good if you set the right props. The defaults aren't enough. Always set sizes for responsive images; always use priority on the LCP image. We see p75 LCP improvements of 600-1100 ms just from adding these two props correctly. Hosting choice matters too — see our Hostinger LiteSpeed performance notes.
<Image
src={hero}
alt={post.title}
width={1200} height={630}
sizes="(max-width: 768px) 100vw, 800px"
priority // for LCP image only
/>
Pattern 12: generateStaticParams for build-time SEO
Dynamic routes that have a finite, knowable set of values should generate at build time. Cheaper, faster, more cacheable, easier to debug.
export async function generateStaticParams() {
const posts = await getAllSlugs()
return posts.map((slug) => ({ slug }))
}
// Optional: gate which extras can be ISR'd at runtime
export const dynamicParams = true
Pattern 13: Middleware for hreflang + locale, not auth
Middleware runs on every request. Keep it cheap. Locale negotiation, redirects, and x-pathname header injection are fine; database calls are not. For auth, prefer per-route checks in a Server Component layout — middleware Edge runtime can't see your full session store.
// middleware.ts
export function middleware(req: NextRequest) {
const url = req.nextUrl
const accept = req.headers.get('accept-language') ?? ''
if (url.pathname === '/' && accept.startsWith('th')) {
return NextResponse.redirect(new URL('/th', req.url))
}
const res = NextResponse.next()
res.headers.set('x-pathname', url.pathname) // for layouts to read
return res
}
Pattern 14: Know when to skip Next entirely
Next.js is overkill for some products. A 40-page brochure site? Astro or plain HTML wins on TTFB. A heavily interactive dashboard with no SEO surface? Vite + React with a thin Hono backend is faster to develop and easier to debug. We choose Next because of the SEO + ISR + RSC combo. If two of those three don't apply, pick a smaller tool.
| Product type | Our default | Why |
|---|---|---|
| Marketing site, content-heavy | Next.js 15 (App Router) | RSC + ISR + metadata |
| SaaS dashboard (auth-walled) | Vite + React + Hono | No SEO surface; faster DX |
| Brochure ≤30 pages, static | Astro | Smaller, faster builds |
| Mobile-first product | Expo + Next admin | See RN vs Flutter |
The pre-deploy checklist we actually run
- View source on every indexable route.
<title>,<meta name="description">, canonical, JSON-LD all present. - Disable JS in DevTools. Hero text, nav links, primary CTAs all visible.
- Run Lighthouse 3x. p75 LCP ≤ 2.0 s on 4G mobile, CLS ≤ 0.05, INP ≤ 150 ms.
- Check
/_next/staticbundle sizes per route. Anything over 200 KB on a marketing route, audit the client tree. - Server Action endpoints rate-limited and logged.
- 404 + 500 pages are RSC and have correct metadata. Crawlers will hit them.
- hreflang and sitemap.xml accurate. We covered the bilingual side at SitPlay.
Closing
Next.js 15 is the best tool we have for SEO-driven products in 2026 — but only if you use the server primitives properly. The 14 patterns above are the ones we reach for in every build. They're also the ones missing from 80% of the inherited codebases we audit. If you'd like a 30-minute review of your current Next.js architecture against this list, email us — we do this free for serious teams. For the bigger picture on how we ship in 15 days, see our services page and case studies.
nextjs rsc seo-architecture server-actions app-router