Next.js TTFB Optimization: Sub-200ms with PPR, Edge & Sanity

By Nayan Kyada · · 7 min read

Part of The Sanity + Next.js Guide

Why TTFB still tanks on supposedly cached pages

I see this pattern constantly: team builds a Next.js site with ISR, points it at Sanity, hits 400–800ms TTFB on "cached" pages. The culprit is usually revalidation blocking or route handlers hitting the Sanity CDN synchronously during render. Even with revalidate: 3600, Next.js has to check the cache, possibly re-fetch, and stream. If you're querying Sanity from a serverless function in us-east-1 while your CDN edge is in Sydney, you've lost 150ms to geography alone.

Take a typical target: sub-200ms TTFB globally for a marketing site with dozens of localized pages, each pulling hero images, nav, footer, and a testimonial carousel from Sanity. Here's the pattern that gets you there.

Partial Prerendering for the shell, defer the dynamic bits

Partial Prerendering (PPR) was an experimental, canary-only flag in Next.js 15; in Next.js 16 you enable it with cacheComponents: true in next.config — I've covered how PPR behaves in production separately. The idea: prerender the static shell at build time, stream dynamic holes on request. For Sanity-backed pages, I prerender the layout, hero, and any above-fold content that doesn't change per user. The testimonial carousel and a "latest posts" sidebar remain dynamic—fetched at request time but outside the initial HTML payload.

In app/[locale]/page.tsx:

// app/[locale]/page.tsx
// Requires `cacheComponents: true` in next.config.ts (Next.js 16)
import { Suspense } from 'react';
import { getHeroData } from '@/sanity/queries/hero';
import { TestimonialCarousel } from '@/components/TestimonialCarousel';
import { LatestPosts } from '@/components/LatestPosts';
 
export function generateStaticParams() {
  return [{ locale: 'en' }, { locale: 'de' }];
}
 
interface PageProps {
  params: Promise<{ locale: string }>;
}
 
export default async function Home({ params }: PageProps) {
  const { locale } = await params;
  // getHeroData is a 'use cache' function, so the hero is part of the prerendered shell
  const hero = await getHeroData(locale);
 
  return (
    <main>
      <section className="hero">
        <h1>{hero.title}</h1>
        <p>{hero.subtitle}</p>
      </section>
 
      <Suspense fallback={<div className="skeleton h-64" />}>
        <TestimonialCarousel locale={locale} />
      </Suspense>
 
      <Suspense fallback={<div className="skeleton h-96" />}>
        <LatestPosts locale={locale} />
      </Suspense>
    </main>
  );
}

The hero is prerendered. The two Suspense boundaries stream after the shell. TTFB reflects only the prerendered HTML served from the CDN, which is why it can drop well under 200ms.

Edge runtime vs. caching for dynamic fragments

The older version of this pattern put the page on the edge runtime to cut Sanity query latency. That doesn't work in Next.js 16: the edge runtime is not supported with Cache Components, so a PPR route has to run on the Node.js runtime (the default). Two things to know:

  • runtime is a route-segment config, not a component-level one. Next.js only reads it from page.tsx, layout.tsx, or route.ts — exporting it from a plain component file like TestimonialCarousel.tsx does nothing.
  • With Cache Components on, remove export const runtime = 'edge' from PPR routes. If you need edge behaviour for something like geo or redirects, use Proxy instead.

What actually keeps the fragments fast is caching, so most requests never touch Sanity at all:

// components/TestimonialCarousel.tsx
import { cacheLife } from 'next/cache';
import { client } from '@/sanity/client';
 
interface Props {
  locale: string;
}
 
export async function TestimonialCarousel({ locale }: Props) {
  'use cache';
  cacheLife({ revalidate: 1800 }); // revalidate every 30 minutes
 
  const testimonials = await client.fetch(
    `*[_type == "testimonial" && language == $locale] | order(_createdAt desc)[0..2] {
      _id,
      quote,
      author,
      "avatar": avatar.asset->url
    }`,
    { locale }
  );
 
  return (
    <div className="carousel">
      {testimonials.map((t) => (
        <blockquote key={t._id}>
          <p>{t.quote}</p>
          <cite>{t.author}</cite>
        </blockquote>
      ))}
    </div>
  );
}

Key: 'use cache' plus cacheLife({ revalidate: 1800 }). The result is cached and revalidated in the background every 30 minutes, so subsequent requests are served from cache — no Sanity roundtrip. (Caches with very short lifetimes — zero revalidate or expire under 5 minutes — are excluded from the prerender and become dynamic holes, which is when the Suspense fallback matters.) Note the order() comes before the [0..2] slice; slicing first would sort an arbitrary three documents.

Selective projection in GROQ

Even with caching, a 200 kB Sanity response tanks your budget on every cache miss. I project only the fields I render. The above query omits _rev, _updatedAt, nested reference expansions I don't need. For portable text fields, I check if the component actually renders blocks; if not, I omit them:

*[_type == "post" && slug.current == $slug][0] {
  _id,
  title,
  "excerpt": pt::text(excerpt),  // Extract plain text instead of full blocks
  publishedAt,
  "image": mainImage.asset->url
}

On a post with a long Portable Text excerpt this can cut the response from well over 100 kB to around 10 kB. Multiply by 10 posts in a grid and the transfer saving runs into megabytes.

What to expect

These are illustrative numbers for the shape of the improvement, not a benchmark — measure your own with field data:

Before (blocking Sanity fetches in a Node.js serverless function, ISR every 3600s):

  • TTFB (p50): ~400ms
  • TTFB (p95): ~750ms+

After (PPR shell served from the CDN, cached fragments streamed):

  • TTFB (p50): well under 200ms (prerendered shell)
  • LCP improves too, because the hero image and above-fold content are in the initial HTML

Users see hero and layout immediately. Testimonials and posts pop in without layout shift because the skeletons reserve their space.

Trade-offs

PPR as described here requires Next.js 16 with cacheComponents enabled (in Next.js 15 it was a canary-only experiment that works differently). On older versions you can approximate this with loading.tsx and manual streaming, but it's clunkier. Cache Components also means giving up the edge runtime on those routes, as covered above.

The 30-minute revalidate window means content updates lag by up to 30 minutes. For a marketing site, that's fine. For a news site, drop it to 60–300 seconds or use on-demand revalidation via Sanity webhooks.

When to reach for this

If your Sanity-powered pages serve global traffic and you're hitting 300ms+ TTFB, this pattern pays off immediately. If your audience is regional and you're already sub-200ms, the complexity isn't worth it. It's most worth it on sites with Core Web Vitals targets — high TTFB eats into your LCP budget before the browser even starts parsing HTML.

Monitoring TTFB so the win survives contact with the roadmap

The dirty secret of TTFB optimisation is regression. Six weeks after shipping sub-200ms pages, someone adds an await to the layout — a feature-flag lookup, a geo check, an A/B assignment — and every page on the site quietly inherits a 300ms tax before the shell can stream. The layout is the most expensive place in the entire tree to block, and nothing in code review flags it.

Two cheap defences:

Alert on the p75, not the average. Averages hide regional pain — a US-East origin serving Sydney users can average 180ms globally while Australian p75 sits at 600ms. Vercel Speed Insights and most RUM tools segment TTFB by region; set the alert threshold per region or the alert is decorative.

Make blocking awaits visually loud in review. A comment convention costs nothing:

// layout.tsx — ⚠ BLOCKS TTFB FOR EVERY PAGE. Nothing async here
// unless it's cached at the edge or wrapped in Suspense.
export default function RootLayout({ children }) {

Low-tech, but it gives reviewers a chance to catch regressions that monitoring would only catch after deploy.

If you can't use PPR yet

PPR requires Next.js 16 with Cache Components turned on, and plenty of teams can't adopt that yet (it replaces route segment configs like revalidate and rules out the edge runtime). The same architecture works with boring, stable primitives: a fully static page via generateStaticParams plus ISR, with the dynamic fragments moved into client components that fetch after mount, or into Suspense boundaries around server components on a dynamic segment.

You lose the elegance of one streamed response — the dynamic bits arrive via a second round-trip instead of streaming into the shell — but the TTFB profile is essentially the same, because the initial HTML is still a CDN cache hit. PPR is the better developer experience; it is not a prerequisite for the performance. Don't let a framework flag hold the architecture hostage.

Frequently asked questions

Can I set the edge runtime on an individual React component in Next.js?

No — `runtime` is a route-segment config that Next.js only reads from `page.tsx`, `layout.tsx`, or `route.ts`. Exporting `export const runtime = 'edge'` from an arbitrary component file has no effect. Note also that in Next.js 16 the edge runtime is not supported with Cache Components (which is how PPR is enabled), so PPR routes run on the Node.js runtime.

What's a realistic TTFB target for a Sanity-powered marketing page?

Sub-200ms globally is achievable by prerendering the static shell with PPR, moving genuinely dynamic fragments (testimonials, latest-posts widgets) into cached Suspense boundaries that stream after the shell, and keeping GROQ projections tight. That's a meaningful jump from the 400-800ms many teams see when Sanity queries run synchronously in a Node serverless function far from both the user and Sanity's CDN.