Bluewich Get Quote
// NEXT.JS · 2026-04-15 · 19 min read

Next.js 15 Server Components for SEO: A Pattern Library

Across 30+ production Next.js 15 deploys we've converged on 14 patterns that we now reach for by default. This is the unfluffed playbook — when to use RSC, when to drop to Client, when 'use server' earns its keep, and when to skip Next entirely.

By Yunmin Shin · Published 2026-04-15 · Updated 2026-04-22

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 typeOur defaultWhy
Marketing site, content-heavyNext.js 15 (App Router)RSC + ISR + metadata
SaaS dashboard (auth-walled)Vite + React + HonoNo SEO surface; faster DX
Brochure ≤30 pages, staticAstroSmaller, faster builds
Mobile-first productExpo + Next adminSee RN vs Flutter

The pre-deploy checklist we actually run

  1. View source on every indexable route. <title>, <meta name="description">, canonical, JSON-LD all present.
  2. Disable JS in DevTools. Hero text, nav links, primary CTAs all visible.
  3. Run Lighthouse 3x. p75 LCP ≤ 2.0 s on 4G mobile, CLS ≤ 0.05, INP ≤ 150 ms.
  4. Check /_next/static bundle sizes per route. Anything over 200 KB on a marketing route, audit the client tree.
  5. Server Action endpoints rate-limited and logged.
  6. 404 + 500 pages are RSC and have correct metadata. Crawlers will hit them.
  7. 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.

Tags: nextjs rsc seo-architecture server-actions app-router
// RELATED INSIGHTS
// MOBILE · 2026-03-12

React Native vs Flutter for Thai Market in 2026

Hiring pool, libraries, ASO, Thai tooling. Winners by use case.

// HOSTING · 2026-02-08

Hosting on Hostinger LiteSpeed: Performance Notes

Real p75 CWV from 17 deployed sites. When to upgrade VPS.

// MIGRATION · 2026-01-18

WordPress to Next.js Migration Without Ranking Loss

7-step lossless migration playbook with cutover scripting.

// SCHEMA · 2025-12-22

Schema.org for SaaS Products: Beyond the Basics

SoftwareApplication, Offer, Review, FAQPage. Real JSON-LD.

Ship a real Next.js 15 product in 15 days.

RSC-first architecture, server actions, schema graph, hreflang — built right from commit one. No "phase 2 SEO."

Get Quote · umma@xx.gg +66 61 093 4014
💬 LINE

Yunmin Agency Network

Bluewich · SitPlay Media · SEO Agency Bangkok · Bangkok Digital

// WEEKLY THAI MARKET INSIGHTS

Get the data we scraped this week.

Rising keywords. SERP shifts. AI citation changes. Bangkok-market specific. No fluff, no sales — one email Tuesday morning.

No spam · Unsubscribe in one click

📱 WhatsApp · 💬 LINE · 📞 +66 61 093 4014

© 2026 · Operated by Yunmin Co., Ltd. · Thai Co. Reg. (pending) · 3rd Floor, 272 Than Thip 3 Alley, Phlabphla, Wang Thonglang, Bangkok 10310

Privacy · Terms · Atelier · umma@xx.gg