Performance Optimization for SaaS Boilerplates
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
| Metric | Good | Poor | What It Measures |
|---|---|---|---|
| FCP | < 1.8s | > 3s | First content appears |
| LCP | < 2.5s | > 4s | Largest content appears |
| CLS | < 0.1 | > 0.25 | Layout shift score |
| INP | < 200ms | > 500ms | Interaction 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
| Optimization | FCP Impact | Effort |
|---|---|---|
| Dynamic import heavy libs | -0.3 to -1s | 1-2 hours |
| Next/Image for hero images | -0.2 to -0.5s | 1 hour |
| Eliminate N+1 queries | Dashboard TTI -50% | Half day |
| Add DB indexes | Query time -80% | 1 hour |
| Parallel data fetching | -0.3 to -0.8s | 1-2 hours |
Compare boilerplate Lighthouse scores on StarterPick.
Check out this boilerplate
View ShipFast on StarterPick →