DevelopmentNov 28, 20248 min read

Next.js App Router: The Performance Patterns We Use on Every Project

After shipping a dozen App Router projects, these are the optimizations that moved the needle on Core Web Vitals every single time.

When we migrated our internal stack to the App Router at Vekto Studio, the performance gains weren't automatic. The mental model shift is real — and getting Core Web Vitals into the green requires deliberate decisions, not just upgrading your Next.js version.

After shipping projects like Orbit (a Fintech SaaS landing page with heavy data fetching) and Pulse (an HR app with complex authenticated routes), these are the patterns that consistently moved the needle.

1. Server Components First, Always

The default instinct when building with React is to reach for useState and useEffect. In the App Router, that instinct will cost you. Every component is a Server Component by default — and the goal is to keep it that way for as long as possible.

The rule we follow: a component only becomes a Client Component when it genuinely needs interactivity or browser APIs. Not because it fetches data. Not because it's "cleaner." Only when it needs to react to user input or access window.

The performance difference on Orbit was measurable. Moving the pricing section from a client-side fetch pattern to a Server Component with async data fetching reduced Time to First Byte noticeably and eliminated a full loading spinner that was hurting perceived performance.

// Before — client component with loading state
"use client";
export function PricingSection() {
  const [plans, setPlans] = useState([]);
  useEffect(() => { fetchPlans().then(setPlans); }, []);
  if (!plans.length) return <Spinner />;
  return <PlanGrid plans={plans} />;
}

// After — server component, no loading state needed
export async function PricingSection() {
  const plans = await fetchPlans();
  return <PlanGrid plans={plans} />;
}

2. Granular Suspense Boundaries

Once you're fetching on the server, the question becomes: where do loading states live? The answer is not a top-level page skeleton — that's just recreating the old waterfall problem on the server.

The pattern we use is granular Suspense wrapping at the component level, with each data-dependent section streaming in independently. This means the static shell of the page — hero, navigation, footer — renders and becomes interactive immediately, while heavier sections like dynamic pricing or testimonials stream in as they resolve.

export default function Page() {
  return (
    <>
      <Hero />  {/* static, renders immediately */}
      <Suspense fallback={<PricingSkeleton />}>
        <PricingSection />
      </Suspense>
      <Suspense fallback={<TestimonialsSkeleton />}>
        <Testimonials />
      </Suspense>
    </>
  );
}

On the Madera ecommerce project, this pattern was the difference between a 4.2s LCP and a 1.8s LCP on the product listing page.

3. Route Segment Config for Cache Control

The App Router gives you fine-grained cache control at the route segment level. Most teams either ignore this entirely or set everything to no-store out of caution. Both are wrong.

Our baseline for marketing and content pages:

// app/blog/[slug]/page.tsx
export const revalidate = 3600; // revalidate every hour
export const dynamicParams = true;

For authenticated or highly dynamic routes, we switch to:

export const dynamic = "force-dynamic";

The key insight: cache decisions should be made per route segment, not globally. A blog post and a user dashboard have fundamentally different freshness requirements. Treating them the same always means over-fetching or serving stale data somewhere.

4. Image Optimization Beyond the Basics

next/image is table stakes. What moves Core Web Vitals is how you use it.

Three non-obvious things we do on every project:

  • priority on the LCP image, always. Not just the hero image — the actual element that will be the Largest Contentful Paint, which is sometimes a card thumbnail or a product image below the fold on mobile.
  • Explicit sizes props on responsive images. The default 100vw causes the browser to download images far larger than needed on desktop. Setting accurate sizes strings cut image payload by ~40% on Madera.
  • Blur placeholders for above-the-fold images. placeholder="blur" with a blurDataURL eliminates layout shift on images that load just after the initial render.

5. Parallel Data Fetching with Promise.all

This one still surprises developers coming from the Pages Router. Sequential await calls in Server Components create waterfalls just like they do on the client. Always parallelize independent fetches.

// Waterfall — don't do this
const user = await getUser(id);
const posts = await getPosts(id);
const analytics = await getAnalytics(id);

// Parallel — ship this
const [user, posts, analytics] = await Promise.all([
  getUser(id),
  getPosts(id),
  getAnalytics(id),
]);

On the Pulse project, a dashboard page had six independent data fetches running sequentially. Switching to Promise.all cut server response time by more than half.


The App Router is genuinely powerful — but it rewards teams that understand the rendering model, not just the API surface. These five patterns aren't tricks. They're the natural result of thinking carefully about where each piece of data lives, when it needs to be fresh, and how the browser will encounter it.

Sofia Reyes, our Lead Developer, puts it this way: the App Router doesn't make performance automatic — it makes good performance decisions possible. You still have to make them.

Ready to build something great?

Turn what you just read into action. Tell us about your project.

Start a project