Skip to main content

Performance Optimization for SaaS Boilerplates

·StarterPick Team
performancenextjsoptimizationcore-web-vitals2026

TL;DR

Most SaaS boilerplates ship with acceptable but not great performance. The three highest-impact optimizations are: (1) reducing JavaScript bundle size, (2) lazy loading non-critical components, and (3) eliminating N+1 database queries. This guide covers each with specific Next.js implementation patterns.


Measuring First

Before optimizing, measure:

# Lighthouse in Chrome DevTools (local)
# Or:
npx lighthouse https://yoursaas.com --view

# Bundle analysis
npx @next/bundle-analyzer

Add to next.config.js:

const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
});

module.exports = withBundleAnalyzer({
  // your config
});
ANALYZE=true npm run build
# Opens treemap of bundle in browser

Core Web Vitals Targets

MetricGoodPoorWhat It Measures
FCP< 1.8s> 3sFirst content appears
LCP< 2.5s> 4sLargest content appears
CLS< 0.1> 0.25Layout shift score
INP< 200ms> 500msInteraction delay

Fix 1: Eliminate JavaScript That Blocks FCP

The most common FCP killer is large JavaScript bundles that must be parsed before anything renders.

Dynamic Imports for Non-Critical Components

// Before: everything loads on initial render
import { RichTextEditor } from '@/components/RichTextEditor';
import { Chart } from '@/components/Chart';
import { DataTable } from '@/components/DataTable';

// After: load when needed
import dynamic from 'next/dynamic';

const RichTextEditor = dynamic(() => import('@/components/RichTextEditor'), {
  loading: () => <div className="h-40 bg-gray-100 rounded animate-pulse" />,
  ssr: false, // Editor doesn't need SSR
});

const Chart = dynamic(() => import('@/components/Chart'), {
  loading: () => <div className="h-64 bg-gray-100 rounded animate-pulse" />,
});

Analyze What's Making Your Bundle Large

Top offenders in boilerplates:

# Common heavy imports that should be dynamically loaded
@uiw/react-md-editor      # 500KB+
react-quill               # 300KB+
recharts / chart.js       # 200-400KB
@fullcalendar/*           # 400KB+
prism-react-renderer      # 200KB+ (use next/code highlight instead)
moment                    # 300KB (replace with date-fns or dayjs)

Fix 2: Optimize Images

// Bad: plain <img> tag
<img src="/hero.png" alt="Hero" />

// Good: Next.js Image component
import Image from 'next/image';

<Image
  src="/hero.png"
  alt="Hero"
  width={1200}
  height={630}
  priority // For above-the-fold images (improves LCP)
  placeholder="blur"
  blurDataURL="data:image/jpeg;base64,/9j/..." // Low-res placeholder
/>

For user avatars:

// Resize on upload, not on display
// lib/upload.ts
import sharp from 'sharp';

export async function processAvatar(buffer: Buffer) {
  return sharp(buffer)
    .resize(200, 200, { fit: 'cover' })
    .webp({ quality: 80 })
    .toBuffer();
}

Fix 3: Eliminate N+1 Database Queries

N+1 queries are the #1 SaaS dashboard performance killer.

// Bad: N+1 — 1 query for orgs + N queries for member counts
const organizations = await prisma.organization.findMany();
for (const org of organizations) {
  org.memberCount = await prisma.organizationMember.count({
    where: { organizationId: org.id },
  });
}

// Good: 1 query with aggregation
const organizations = await prisma.organization.findMany({
  include: {
    _count: {
      select: { members: true },
    },
  },
});
// Access as: org._count.members

Detect N+1 with query logging:

// lib/prisma.ts
export const prisma = new PrismaClient({
  log: process.env.NODE_ENV === 'development'
    ? ['query', 'warn', 'error']
    : ['error'],
});

Fix 4: Database Query Optimization

// Add indexes for common query patterns
// prisma/schema.prisma

model Project {
  id             String   @id @default(cuid())
  organizationId String
  userId         String
  status         String
  createdAt      DateTime @default(now())

  // Add these indexes:
  @@index([organizationId, status])    // Filter by org + status
  @@index([organizationId, createdAt]) // Sort by date within org
  @@index([userId])                    // Look up user's projects
}

Verify indexes are being used:

-- In PostgreSQL (run via Neon console or psql)
EXPLAIN ANALYZE
SELECT * FROM "Project"
WHERE "organizationId" = 'clx...' AND status = 'active'
ORDER BY "createdAt" DESC
LIMIT 20;

-- Look for "Index Scan" vs "Seq Scan"
-- Index Scan = good, Seq Scan on large tables = add an index

Fix 5: React Server Component Optimization

// Waterfall pattern (slow — each awaits sequentially):
export default async function Dashboard() {
  const user = await getUser();
  const stats = await getStats(user.orgId); // Waits for getUser
  const activity = await getActivity(user.orgId); // Waits for getStats
  return <DashboardView user={user} stats={stats} activity={activity} />;
}

// Parallel pattern (fast — all fetch simultaneously):
export default async function Dashboard() {
  const session = await getSessionWithOrg();
  const orgId = session.organization.id;

  const [stats, activity, notifications] = await Promise.all([
    getStats(orgId),
    getActivity(orgId),
    getNotifications(session.user.id),
  ]);

  return (
    <DashboardView
      stats={stats}
      activity={activity}
      notifications={notifications}
    />
  );
}

Fix 6: Caching Expensive Queries

// Next.js fetch caching (App Router)
async function getPublicStats() {
  const res = await fetch('https://api.example.com/stats', {
    next: { revalidate: 3600 }, // Cache for 1 hour
  });
  return res.json();
}

// React cache for per-request deduplication
import { cache } from 'react';

export const getUser = cache(async (userId: string) => {
  return prisma.user.findUnique({ where: { id: userId } });
});
// Multiple calls with same userId → single DB query per request

Performance Budget

Set performance budgets in next.config.js:

module.exports = {
  experimental: {
    webVitalsAttribution: ['FCP', 'LCP', 'CLS', 'INP'],
  },
};

Track in app/layout.tsx:

export function reportWebVitals(metric: NextWebVitalsMetric) {
  if (process.env.NODE_ENV === 'production') {
    // Send to PostHog, Datadog, or console
    console.log(metric.name, metric.value);
  }
}

Expected Improvements

OptimizationFCP ImpactEffort
Dynamic import heavy libs-0.3 to -1s1-2 hours
Next/Image for hero images-0.2 to -0.5s1 hour
Eliminate N+1 queriesDashboard TTI -50%Half day
Add DB indexesQuery time -80%1 hour
Parallel data fetching-0.3 to -0.8s1-2 hours

Compare boilerplate Lighthouse scores on StarterPick.

Check out this boilerplate

View ShipFast on StarterPick →

Comments