GameCraftGameCraft

Performance Optimization

Optimize your ProductReady app for speed - Core Web Vitals, caching, code splitting, and database optimization

Performance Optimization

Make your ProductReady app blazingly fast with Next.js 15+ optimization techniques. Learn how to improve Core Web Vitals, database performance, and user experience.

Performance = SEO + UX: Fast sites rank higher in Google and convert better. Every 100ms delay costs 1% conversion.


Quick Overview

Key metrics (Core Web Vitals):

  • LCP (Largest Contentful Paint) - Loading speed < 2.5s
  • FID/INP (First Input Delay / Interaction to Next Paint) - Interactivity < 200ms
  • CLS (Cumulative Layout Shift) - Visual stability < 0.1

Optimization areas:

  • ✅ Image & font optimization
  • ✅ Code splitting & lazy loading
  • ✅ Caching strategies
  • ✅ Database query optimization
  • ✅ API response time
  • ✅ Bundle size reduction

Measuring Performance

Tools

  1. Lighthouse (Chrome DevTools)

    • Press F12 → Lighthouse tab → Generate report
    • Provides actionable recommendations
  2. PageSpeed Insights

  3. Vercel Analytics (if deployed to Vercel)

    • Real user monitoring (RUM)
    • Tracks Core Web Vitals automatically
  4. Chrome DevTools Performance

    • Record page load
    • Identify bottlenecks

Web Vitals in Code

// src/app/layout.tsx
import { Analytics } from '@vercel/analytics/react';
import { SpeedInsights } from '@vercel/speed-insights/next';

export default function RootLayout({ children }: Props) {
  return (
    <html>
      <body>
        {children}
        <Analytics />
        <SpeedInsights />
      </body>
    </html>
  );
}

Image Optimization

Next.js Image Component

Always use <Image> instead of <img>:

import Image from 'next/image';

// ✅ Good: Optimized automatically
<Image
  src="/hero.png"
  alt="Hero image"
  width={1200}
  height={630}
  priority // For above-the-fold images
  placeholder="blur"
  blurDataURL="data:image/png;base64,..." // Low-quality placeholder
/>

// ❌ Bad: No optimization
<img src="/hero.png" alt="Hero" />

Priority Loading

Mark critical images as priority:

// Hero image (above the fold)
<Image src="/hero.png" priority alt="Hero" width={1200} height={630} />

// Below the fold images (lazy load by default)
<Image src="/feature.png" alt="Feature" width={800} height={600} />

Responsive Images

<Image
  src="/hero.png"
  alt="Hero"
  width={1200}
  height={630}
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>

Remote Images

// next.config.mjs
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'images.unsplash.com',
      },
      {
        protocol: 'https',
        hostname: 'avatars.githubusercontent.com',
      },
    ],
  },
};

Font Optimization

Next.js Font Loading

// src/app/layout.tsx
import { Inter, Roboto_Mono } from 'next/font/google';

const inter = Inter({
  subsets: ['latin'],
  display: 'swap', // Prevent invisible text
  variable: '--font-inter',
});

const robotoMono = Roboto_Mono({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-roboto-mono',
});

export default function RootLayout({ children }: Props) {
  return (
    <html className={`${inter.variable} ${robotoMono.variable}`}>
      <body className="font-sans">{children}</body>
    </html>
  );
}

Local Fonts

import localFont from 'next/font/local';

const myFont = localFont({
  src: './my-font.woff2',
  display: 'swap',
  variable: '--font-custom',
});

Preload Fonts

// src/app/layout.tsx
export default function RootLayout({ children }: Props) {
  return (
    <html>
      <head>
        <link
          rel="preload"
          href="/fonts/inter-var.woff2"
          as="font"
          type="font/woff2"
          crossOrigin="anonymous"
        />
      </head>
      <body>{children}</body>
    </html>
  );
}

Code Splitting & Lazy Loading

Dynamic Imports

import dynamic from 'next/dynamic';

// Lazy load heavy components
const HeavyChart = dynamic(() => import('~/components/heavy-chart'), {
  loading: () => <div>Loading chart...</div>,
  ssr: false, // Skip SSR if not needed for SEO
});

export function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      <HeavyChart /> {/* Only loaded when component mounts */}
    </div>
  );
}

Route-Based Code Splitting

Next.js automatically splits code by route - no configuration needed!

src/app/
├── page.tsx          # Bundle 1: Home
├── dashboard/
│   └── page.tsx      # Bundle 2: Dashboard
└── settings/
    └── page.tsx      # Bundle 3: Settings

Conditional Imports

'use client';

import { useState } from 'react';

export function Editor() {
  const [editor, setEditor] = useState(null);

  const loadEditor = async () => {
    // Only import when user clicks "Edit"
    const { Editor } = await import('~/components/rich-editor');
    setEditor(<Editor />);
  };

  return (
    <div>
      <button onClick={loadEditor}>Edit</button>
      {editor}
    </div>
  );
}

Caching Strategies

Static Pages (ISR)

Rebuild pages periodically:

// src/app/posts/[slug]/page.tsx
export const revalidate = 3600; // Revalidate every hour

export default async function PostPage({ params }: Props) {
  const post = await fetchPost(params.slug);
  return <div>{post.title}</div>;
}

API Route Caching

// src/app/api/data/route.ts
export async function GET() {
  const data = await fetchData();

  return Response.json(data, {
    headers: {
      'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
    },
  });
}

tRPC Response Caching

// src/server/routers/posts.ts
export const postsRouter = createTRPCRouter({
  list: publicProcedure
    .query(async ({ ctx }) => {
      const posts = await ctx.db.query.posts.findMany();
      
      // Cache for 5 minutes
      ctx.resHeaders?.set(
        'Cache-Control',
        'public, s-maxage=300, stale-while-revalidate=600'
      );
      
      return posts;
    }),
});

React Query Caching

'use client';

import { trpc } from '~/lib/trpc/client';

export function Posts() {
  const { data } = trpc.posts.list.useQuery(undefined, {
    staleTime: 5 * 60 * 1000, // 5 minutes
    cacheTime: 10 * 60 * 1000, // 10 minutes
  });

  return <div>{/* Render posts */}</div>;
}

Database Optimization

Query Optimization

❌ Bad: N+1 queries

const posts = await db.query.posts.findMany();

for (const post of posts) {
  const author = await db.query.users.findFirst({
    where: eq(users.id, post.authorId),
  });
  post.author = author; // N queries!
}

✅ Good: Join in single query

const posts = await db.query.posts.findMany({
  with: {
    author: true, // Single query with JOIN
  },
});

Indexes

Add indexes for frequently queried fields:

// src/db/schema/posts.ts
import { pgTable, text, timestamp, index } from 'drizzle-orm/pg-core';

export const posts = pgTable('posts', {
  id: text('id').primaryKey(),
  authorId: text('author_id').notNull(),
  slug: text('slug').notNull(),
  createdAt: timestamp('created_at').defaultNow(),
}, (table) => ({
  // Index for faster lookups
  slugIdx: index('slug_idx').on(table.slug),
  authorIdx: index('author_idx').on(table.authorId),
  createdAtIdx: index('created_at_idx').on(table.createdAt),
}));

Connection Pooling

Use connection pooling for better performance:

# Neon (built-in pooling)
PG_DATABASE_URL=postgresql://user:pass@host.neon.tech:5432/db?sslmode=require

# Supabase (transaction mode for Drizzle)
PG_DATABASE_URL=postgresql://postgres.[ref]:[pass]@aws-0-[region].pooler.supabase.com:6543/postgres

# Or use PgBouncer
docker run -d -p 6432:6432 \
  -e DATABASE_URL=postgresql://user:pass@postgres:5432/db \
  edoburu/pgbouncer

Pagination

❌ Bad: Load all records

const posts = await db.query.posts.findMany(); // Could be millions!

✅ Good: Paginate

const posts = await db.query.posts.findMany({
  limit: 20,
  offset: page * 20,
  orderBy: [desc(posts.createdAt)],
});

Select Only Needed Fields

// ❌ Bad: Select all fields
const users = await db.select().from(users);

// ✅ Good: Select only needed fields
const users = await db.select({
  id: users.id,
  name: users.name,
  email: users.email,
}).from(users);

Bundle Size Optimization

Analyze Bundle

# Build with analysis
pnpm build

# Install analyzer
pnpm add -D @next/bundle-analyzer

# Configure
// next.config.mjs
import bundleAnalyzer from '@next/bundle-analyzer';

const withBundleAnalyzer = bundleAnalyzer({
  enabled: process.env.ANALYZE === 'true',
});

export default withBundleAnalyzer(nextConfig);

# Run analysis
ANALYZE=true pnpm build

Tree Shaking

Import only what you need:

// ❌ Bad: Imports entire library
import _ from 'lodash';
const result = _.map(array, fn);

// ✅ Good: Tree-shakeable import
import { map } from 'lodash-es';
const result = map(array, fn);

// ✅ Better: Use native methods
const result = array.map(fn);

Remove Unused Dependencies

# Find unused dependencies
npx depcheck

# Remove them
pnpm remove unused-package

React Performance

Memoization

import { memo, useMemo, useCallback } from 'react';

// Memoize expensive components
export const ExpensiveComponent = memo(function ExpensiveComponent({ data }: Props) {
  const processedData = useMemo(() => {
    return expensiveProcessing(data);
  }, [data]);

  return <div>{processedData}</div>;
});

// Memoize callbacks
export function ParentComponent() {
  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []); // Stable reference

  return <ExpensiveComponent onClick={handleClick} />;
}

Virtual Lists

For long lists, use virtualization:

pnpm add @tanstack/react-virtual
import { useVirtualizer } from '@tanstack/react-virtual';

export function VirtualList({ items }: Props) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 50,
  });

  return (
    <div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>
      <div style={{ height: `${virtualizer.getTotalSize()}px` }}>
        {virtualizer.getVirtualItems().map((virtualRow) => (
          <div
            key={virtualRow.index}
            style={{
              height: `${virtualRow.size}px`,
              transform: `translateY(${virtualRow.start}px)`,
            }}
          >
            {items[virtualRow.index]}
          </div>
        ))}
      </div>
    </div>
  );
}

Streaming & Suspense

Streaming Server Components

// src/app/dashboard/page.tsx
import { Suspense } from 'react';

async function SlowData() {
  const data = await fetchSlowData(); // 3 seconds
  return <div>{data}</div>;
}

export default function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<div>Loading...</div>}>
        <SlowData /> {/* Streams in when ready */}
      </Suspense>
    </div>
  );
}

Loading States

// src/app/dashboard/loading.tsx
export default function Loading() {
  return <div>Loading dashboard...</div>;
}

CDN & Edge

Edge Runtime

Run API routes on the edge for lower latency:

// src/app/api/hello/route.ts
export const runtime = 'edge';

export async function GET() {
  return Response.json({ hello: 'world' });
}

Static Asset CDN

Vercel automatically serves static assets from CDN. For self-hosted:

# nginx config
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
  expires 1y;
  add_header Cache-Control "public, immutable";
}

Monitoring

Real User Monitoring (RUM)

// src/app/layout.tsx
import { Analytics } from '@vercel/analytics/react';

export default function RootLayout({ children }: Props) {
  return (
    <html>
      <body>
        {children}
        <Analytics />
      </body>
    </html>
  );
}

Custom Metrics

export function reportWebVitals(metric: NextWebVitalsMetric) {
  // Send to analytics
  fetch('/api/analytics', {
    method: 'POST',
    body: JSON.stringify(metric),
  });
}

Checklist

Images

  • Use Next.js <Image> component
  • Add priority to above-the-fold images
  • Use appropriate sizes
  • Serve WebP format

Fonts

  • Use next/font for optimization
  • Set display: 'swap'
  • Preload critical fonts

Code

  • Lazy load heavy components
  • Split code by route
  • Remove unused dependencies
  • Tree-shake imports

Caching

  • Enable ISR for static pages
  • Cache API responses
  • Use React Query caching

Database

  • Add indexes to frequently queried fields
  • Use connection pooling
  • Paginate large result sets
  • Avoid N+1 queries

Bundle

  • Analyze bundle size
  • Remove unused code
  • Use tree-shakeable imports

Quick Wins

30 seconds:

  • Add <Analytics /> component

5 minutes:

  • Replace <img> with <Image>
  • Add priority to hero images
  • Use next/font for fonts

30 minutes:

  • Add database indexes
  • Enable ISR on static pages
  • Lazy load heavy components

2 hours:

  • Analyze and reduce bundle size
  • Optimize database queries
  • Add React Query caching

Next Steps

  • Measure first - Run Lighthouse to identify issues
  • Fix one at a time - Don't optimize prematurely
  • Monitor continuously - Track Core Web Vitals
  • Test on real devices - Mobile performance matters

Focus on Core Web Vitals first - they directly impact SEO and user experience. Start with images and fonts, they're the easiest wins.

On this page