WEIRDSOFT
Back to blog

Building Performant Next.js Apps

Performance is not a feature flag. It is a core constraint that must be engineered from the first line of code, not bolted on during a frantic pre-launch week. At WEIRDSOFT, we have shipped production Next.js applications handling millions of requests per day. This guide captures the optimisation patterns that consistently deliver the greatest impact.

Every millisecond of improvement in Time to Interactive correlates with measurable increases in conversion, retention, and revenue. Google's own research shows that a 0.1-second improvement in mobile site speed can increase conversion by over 8% for retail sites. For a $100,000 daily revenue stream, that is nearly $3 million annually.

React Server Components: The Paradigm Shift

The single most impactful performance decision you can make in a Next.js 13+ application is how aggressively you shift rendering to the server with React Server Components (RSC). The principle is simple: components that do not need client-side interactivity should never send JavaScript to the browser.

// app/products/page.tsx — This is a Server Component by default
import { ProductGrid } from '@/components/product-grid'
import { getProducts } from '@/lib/db'

export default async function ProductsPage() {
  const products = await getProducts()

  return (
    <div>
      <h1>Our Products</h1>
      <ProductGrid products={products} />
    </div>
  )
}

This component renders entirely on the server. The browser receives only HTML — zero JavaScript for data fetching, zero hydration overhead, zero client-side state. The getProducts call runs against your database directly, without an API route intermediary.

The Server Component Rule of Thumb

Is interactivity needed? Component type JS sent to client
No (static content, data fetching, layout) Server Component Zero
Yes (useState, useEffect, onClick) Client Component Yes (optimised)
Partial (mostly static, some interactive islands) Server Component wrapping client children Minimal

The Cost of "Just Make It a Client Component"

A common antipattern we see is slapping 'use client' at the top of a file because one small interactive element requires it — then the entire component tree becomes client-rendered. The fix is to isolate interactive islands:

// ❌ Bad: Entire page is a client component
'use client'
export default function Page() {
  const [likes, setLikes] = useState(0)
  // ... lots of static content that now hydrates unnecessarily
}

// ✅ Good: Only the interactive piece is a client component
import { LikeButton } from './like-button'

export default function Page() {
  return (
    <div>
      <h1>Article Title</h1>
      {/* ... static content... */}
      <LikeButton />
    </div>
  )
}

Streaming Server-Side Rendering

Next.js supports streaming HTML from the server using React's Suspense boundary. This allows the browser to render content progressively as it arrives, rather than waiting for the entire page to render.

import { Suspense } from 'react'
import { ProductGrid } from '@/components/product-grid'
import { ProductGridSkeleton } from '@/components/product-grid-skeleton'
import { Reviews } from '@/components/reviews'

export default function ProductPage({ params }: { params: { id: string } }) {
  return (
    <div>
      <h1>Product Details</h1>
      <Suspense fallback={<ProductGridSkeleton />}>
        <ProductGrid id={params.id} />
      </Suspense>
      <Suspense fallback={<div>Loading reviews...</div>}>
        <Reviews id={params.id} />
      </Suspense>
    </div>
  )
}

Streaming delivers measurable improvements in First Contentful Paint (FCP) and Largest Contentful Paint (LCP). In production benchmarks, we have observed:

  • 40–60% reduction in LCP for content-heavy pages
  • Immediate FCP for shell layouts while data fetches complete
  • Improved perceived performance even when total load time is unchanged

Streaming is especially impactful for pages with heterogeneous data sources — a product page with reviews, recommendations, and inventory data that have different response times.

Incremental Static Regeneration (ISR) Strategies

Not every page needs real-time data. ISR lets you serve statically generated pages with periodic revalidation, giving you the speed of static with the freshness of dynamic.

// app/blog/[slug]/page.tsx
export const revalidate = 3600 // Revalidate at most every hour

export async function generateStaticParams() {
  const posts = await getPublishedPosts()
  return posts.map((post) => ({ slug: post.slug }))
}

export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug)
  return <Article post={post} />
}

Choosing the Right Revalidation Strategy

Data type Strategy Revalidation interval
Blog posts, documentation ISR with time-based revalidation 3600s (1 hour)
Product catalog (low churn) ISR with on-demand revalidation Webhook-triggered
User dashboard Dynamic rendering On every request
Marketing pages Static (no ISR) Never
News or time-sensitive content ISR with short revalidation 300s (5 minutes)
Leaderboard / analytics Dynamic with caching headers Custom

On-demand revalidation is particularly powerful for e-commerce. When inventory changes or price updates occur, trigger revalidation from your CMS webhook:

// app/api/revalidate/route.ts
export async function POST(request: Request) {
  const { secret, path } = await request.json()

  if (secret !== process.env.REVALIDATION_SECRET) {
    return Response.json({ message: 'Invalid secret' }, { status: 401 })
  }

  await revalidatePath(path)
  return Response.json({ revalidated: true })
}

Bundle Analysis and Code Splitting

Next.js automatically splits code by route boundaries, but meaningful optimisation requires understanding what is actually in your bundles.

Setting Up Bundle Analysis

npm install @next/bundle-analyzer

# next.config.ts
import withBundleAnalyzer from '@next/bundle-analyzer'

const config = {
  // your next config
}

export default withBundleAnalyzer({
  enabled: process.env.ANALYZE === 'true',
})(config)

Dynamic Imports Beyond Route Boundaries

Route-level splitting is table stakes. True optimisation requires splitting within pages:

import dynamic from 'next/dynamic'

const HeavyChart = dynamic(() => import('@/components/chart'), {
  loading: () => <ChartSkeleton />,
  ssr: false, // Skip SSR for components that only render client-side
})

// Lazy-load a heavy library only when the user interacts
const Map = dynamic(() => import('@/components/map'), {
  loading: () => <div className="map-placeholder" />,
})

export default function DashboardPage() {
  const [showMap, setShowMap] = useState(false)

  return (
    <div>
      <HeavyChart />
      <button onClick={() => setShowMap(true)}>View Map</button>
      {showMap && <Map />}
    </div>
  )
}

Common Bundle Bloat Culprits

  • Moment.js (use date-fns or Temporal instead — saves 200KB+)
  • Lodash full import (import specific functions: import debounce from 'lodash/debounce')
  • UI framework tree-shaking failures (verify your bundler is actually tree-shaking unused components)
  • React Quill, Monaco Editor, and similar (always dynamic import with ssr: false)

Image Optimisation Pipeline

The Next.js Image component handles responsive images, lazy loading, and format negotiation out of the box — but only when configured correctly.

Production Configuration

import Image from 'next/image'

export function ProductImage({ src, alt, priority = false }: Props) {
  return (
    <Image
      src={src}
      alt={alt}
      width={800}
      height={600}
      sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 800px"
      quality={85}
      priority={priority}
      placeholder="blur"
      blurDataURL="data:image/webp;base64,..."
    />
  )
}

Key Optimisation Settings

Setting Recommendation Impact
sizes Match your CSS breakpoints 30–50% bandwidth reduction
quality 75–85 for photos, 85–90 for product shots 20–40% smaller than 100
formats ['avif', 'webp'] 25–35% smaller than JPEG/PNG
priority Only above-the-fold images Avoids bandwidth contention
placeholder "blur" with blurDataURL Eliminates layout shift

Image CDN Considerations

If you are using a custom image CDN (Cloudinary, imgix, Cloudflare Images), configure the remote patterns in next.config.ts:

const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'cdn.weirdsoft.com',
        pathname: '/images/**',
      },
    ],
  },
}

This allows Next.js to optimise images served from your CDN using its built-in image optimisation API.

Multi-Layer Caching Strategy

Performance at scale requires caching at every layer of the stack. A single cache miss at the database should not mean a single cache miss at the edge.

Cache Layer Architecture

Edge Cache (CDN)
    ↓ (cache hit serves instantly)
Next.js Data Cache
    ↓ (cache hit skips rendering)
In-Memory Cache (Redis/Upstash)
    ↓ (cache hit skips DB query)
Database

Implementing the Cache Layers

// lib/cache.ts
import { redis } from '@/lib/redis'
import { unstable_cache } from 'next/cache'

export async function getCachedProducts() {
  return unstable_cache(
    async () => {
      // Check Redis first
      const cached = await redis.get('products:featured')
      if (cached) return cached

      const products = await db.query.products.findMany({
        where: eq(products.featured, true),
      })

      // Set cache with TTL
      await redis.set('products:featured', products, { ex: 300 })
      return products
    },
    ['products-featured'],
    { revalidate: 300, tags: ['products'] }
  )()
}

Cache Invalidation Strategy

Stale data is worse than slow data. Use tag-based invalidation to surgically purge cached content:

// After updating a product in your admin panel
import { revalidateTag } from 'next/cache'

export async function updateProduct(id: string, data: ProductData) {
  await db.update(products).set(data).where(eq(products.id, id))
  revalidateTag('products')
}

Database Query Optimisation

The database is the deepest bottleneck in most Next.js applications. No amount of edge caching compensates for N+1 queries in your page components.

The N+1 Problem

// ❌ Bad: N+1 queries
export default async function AuthorPage() {
  const authors = await db.query.authors.findMany()
  // For each author, another query:
  const authorsWithPosts = await Promise.all(
    authors.map((author) =>
      db.query.posts.findMany({ where: eq(posts.authorId, author.id) })
    )
  )
}

// ✅ Good: Single query with join
const authorsWithPosts = await db.query.authors.findMany({
  with: {
    posts: true,
  },
})

Database-Specific Optimisations

Database Key optimisation Typical gain
PostgreSQL Index covering, connection pooling (PgBouncer) 10–100x query speed
Prisma include vs select, batch operations 5–10x fewer queries
Drizzle ORM Prepared statements, using db.execute for hot paths 2–5x faster ORM
PlanetScale Branch-based schema, connection limit management Avoids connection exhaustion

Connection Pooling

Serverless functions create a new database connection on each invocation. Without pooling, you will hit connection limits under load:

// lib/db.ts
import { Pool } from '@neondatabase/serverless'
import { PrismaNeon } from '@prisma/adapter-neon'
import { PrismaClient } from '@prisma/client'

const pool = new Pool({ connectionString: process.env.DATABASE_URL })
const adapter = new PrismaNeon(pool)
export const db = new PrismaClient({ adapter })

Real-World Performance Metrics

Theory is useful. Numbers are better. Here are benchmark results from a production Next.js e-commerce application we shipped in Q1 2026:

Metric Before optimisation After optimisation Improvement
LCP 3.8s 1.2s 68%
FCP 2.1s 0.6s 71%
TBT (Total Blocking Time) 320ms 45ms 86%
CLS 0.25 0.02 92%
Time to Interactive 4.2s 1.4s 67%
JavaScript per page 185KB 42KB 77%
Lighthouse Performance 62 98 36 points

These improvements were achieved through the combination of: Server Components (eliminated 80% of client JS), streaming SSR (improved perceived LCP), aggressive ISR (reduced server load by 90%), image optimisation (cut image payload by 60%), and database query consolidation (eliminated 90% of N+1 queries).

Measuring What Matters in Production

Lab data (Lighthouse, PageSpeed Insights) is useful for regression detection. Real User Monitoring (RUM) tells you what actual users experience on actual devices on actual networks.

Setting Up RUM

// app/layout.tsx
export function reportWebVitals(metric: NextWebVitalsMetric) {
  const body = {
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    url: window.location.pathname,
    userAgent: navigator.userAgent,
  }

  // Send to analytics
  fetch('/api/analytics/vitals', {
    method: 'POST',
    body: JSON.stringify(body),
    keepalive: true,
  })
}

What to Watch

  • P75 and P95 LCP, not just the median. The median user is not your performance bottleneck — the slowest quartile is.
  • INP (Interaction to Next Paint) — Google's replacement for FID. Target under 200ms.
  • LCP sub-parts — Is LCP slow because of Time to First Byte (TTFB), image load, or render delay? Each requires a different fix.
  • Cache hit ratios — If your CDN or ISR cache hit rate is below 80%, your caching strategy needs work.

Performance as a Competitive Advantage

In a market where every competitor uses the same frameworks and similar infrastructure, performance is one of the few genuine differentiators. A fast site is a better user experience, a better SEO signal, and a better business outcome.

At WEIRDSOFT, we treat performance the same way we treat security — it is not optional, it is not bolt-on, and it is every developer's responsibility. The patterns above are the foundation of every Next.js project we ship. Apply them systematically, measure relentlessly, and your users will feel the difference.