Skip to main content

Performance Optimization for SaaS Boilerplates 2026

·StarterPick Team
Share:

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. Combined, these typically cut FCP by 0.5–1.5 seconds and improve dashboard Time to Interactive by 40–60%. This guide covers each with specific Next.js implementation patterns.

Key Takeaways

  • Bundle size is the #1 FCP killer — dynamic import heavy libs (charts, editors, calendars) for 0.3–1s FCP gain
  • N+1 queries are the #1 dashboard performance killer — use Prisma _count and include instead of loops
  • Next/Image handles format conversion, sizing, and lazy loading automatically — replace all <img> tags
  • Database indexes are free performance — most boilerplate schemas ship without composite indexes
  • React cache deduplicates identical queries within a request — no extra roundtrips for auth data
  • Measure first — Lighthouse and EXPLAIN ANALYZE before any optimization

Measuring First

Before optimizing, measure. The biggest performance mistake is optimizing the wrong thing. A developer who reduces bundle size by 100KB may be ignoring a database query that costs 800ms on every page load.

# 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

What to Look For

When running Lighthouse on your SaaS dashboard, target these scores: FCP under 1.8s, LCP under 2.5s, CLS under 0.1, INP under 200ms. A Lighthouse performance score of 90+ means your boilerplate is in good shape. A score of 50–70 — common for unoptimized SaaS dashboards — means significant wins are available.

The bundle analyzer treemap shows why your JavaScript is large. Look for: any single library over 100KB, duplicate copies of the same library (often React or date formatting utilities appearing twice due to version mismatches), and libraries you import globally that are only used on one or two pages.

For database performance, enable Prisma query logging in development and watch for the N+1 pattern: the same query type appearing N times with different ID parameters in a single request. Then use EXPLAIN ANALYZE in PostgreSQL to confirm which queries are doing sequential table scans instead of index scans.


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

Understanding What's Actually Slow

Performance problems in SaaS boilerplates cluster into three categories, each with different diagnostic and fix approaches.

Client-side bundle bloat causes slow FCP and LCP. JavaScript must be downloaded, parsed, and executed before the page can paint meaningful content. A 500KB JavaScript bundle on a 4G connection takes 1–2 seconds just to transfer; parsing adds another 0.5–1s on a mid-range mobile device. The bundle analyzer exposes which packages are responsible — and the fix is almost always dynamic imports for components that don't need to be present at initial paint.

Server-side query bottlenecks cause slow Time to First Byte (TTFB) and slow dashboard loading. If your dashboard page awaits five sequential database queries, your TTFB is the sum of all query times. A 100ms query run five times sequentially costs 500ms before the page can even start rendering. The fix is parallelizing independent queries with Promise.all and adding indexes to make each query faster. EXPLAIN ANALYZE in PostgreSQL surfaces which queries scan the full table instead of using an index.

Render blocking and layout shift cause poor CLS and INP scores. Fonts loaded without font-display: swap block rendering until the font file downloads. Images without explicit dimensions cause layout shift when they load and push content down the page. Heavy components that hydrate synchronously on the client delay Time to Interactive even after the page is visually complete.

Most boilerplates have all three problems. Fix them in priority order: bundle size first (highest impact on FCP), N+1 queries second (highest impact on dashboard load time), then fonts, images, and third-party scripts.


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)

The general principle: anything the user sees only after interacting (a rich text editor that appears on click, a chart that appears on a secondary tab, a date picker that appears in a form) should be dynamically imported. The skeleton/loading state renders immediately; the heavy component loads in the background while the user reads the page.


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();
}

Use priority on images that are above the fold (hero images, the first avatar in a list, the header logo) — this tells Next.js to preload them. Omit it for below-the-fold images where lazy loading is appropriate. The placeholder="blur" prevents layout shift while the full image loads.


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'],
});

Where N+1 Queries Hide in Boilerplates

N+1 patterns consistently appear in the same places across SaaS dashboards. Knowing where to look saves diagnosis time.

Activity feeds: Fetching activities and then separately fetching user data for each activity's author. Fix: include the user relation in the activity query with include: { user: { select: { name: true, avatar: true } } }.

Permission checks inside loops: Checking a user's plan, role, or subscription status inside a loop that processes multiple items. Fix: fetch the user record once before the loop and pass the plan/role down. Alternatively, use React's cache() to deduplicate within the same request.

Related resource counts on list pages: Showing "3 projects, 2 members" badges on each organization card by running separate COUNT queries per organization. Fix: use Prisma's _count include syntax, which generates a single GROUP BY query instead of N COUNT queries.

Tag and label rendering: Fetching tags separately for each item in a paginated list. Fix: fetch all tags for all visible items in one query, then join them in application code using a Map keyed by item ID.


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

When to Add Indexes

Add indexes on columns that appear in WHERE clauses or ORDER BY on queries that run on every page load. For most SaaS dashboards:

  • organizationId on every tenant-scoped table (ensures queries scan only one org's rows, not the whole table)
  • userId on user-specific data (similar scoping benefit)
  • (organizationId, createdAt) composite — for "this org's items sorted by date" patterns
  • (organizationId, status) composite — for "this org's active/pending items" filter patterns
  • createdAt standalone — if you sort activity feeds or sort records by date across orgs

Don't over-index. Each index adds write overhead and storage cost. Only add an index after EXPLAIN ANALYZE confirms a sequential scan is causing the slowdown. Tables under 10,000 rows rarely need indexes — PostgreSQL's sequential scan is fast enough at small scale.


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}
    />
  );
}

The only legitimate reason for sequential awaits is when the second query depends on the result of the first. Fetching org stats, activity feed, and notification count for the same user are all independent — they should be parallelized. Sequential awaiting is a common habit carried over from imperative code, but it's a mistake in async server components where parallelism is free.


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

Caching Strategy by Data Type

Not all data should be cached with the same TTL. The rule: cache aggressiveness should be inversely proportional to how harmful a stale read would be.

Cache aggressively (hours to days): Public marketing pages, pricing information, blog content, feature documentation, public user profiles. This data rarely changes and stale reads cause no harm.

Cache with short TTL (minutes): Dashboard summary stats, leaderboards, usage metrics. Slightly stale data is acceptable — users won't notice 5-minute staleness on a usage chart.

Don't cache (always fresh): User permissions and billing status (stale permissions are a security risk), shopping cart state, notification counts (users expect real-time), anything the user just modified. Use React's cache() for within-request deduplication of these — it prevents duplicate queries within a single request but never serves stale data across requests.


Font Optimization

Unoptimized fonts are a common source of layout shift (CLS) and render blocking. Boilerplates that use a <link> tag to load Google Fonts introduce: a DNS lookup to fonts.googleapis.com, a separate TCP connection, and a render-blocking stylesheet. Replace all of this with next/font:

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

// next/font automatically:
// - Downloads and serves from your domain (no Google DNS lookup)
// - Adds font-display: swap (prevents render blocking)
// - Generates CSS variables for the font
const inter = Inter({
  subsets: ['latin'],
  variable: '--font-inter',
  display: 'swap',
});

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

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={`${inter.variable} ${mono.variable}`}>
      <body>{children}</body>
    </html>
  );
}

next/font inlines the critical font CSS, eliminating the external stylesheet request that blocks rendering. The font files themselves are served from your domain under /_next/static/, so they benefit from the same CDN caching as your other static assets. This is a one-line change per font and typically eliminates 0.1–0.3 CLS points from font-related layout shift.


Third-Party Script Management

Analytics, chat widgets, and ad scripts are major performance offenders that most boilerplate documentation ignores. Every third-party script adds: a DNS lookup, a TCP connection, a script download, script parsing time, and potentially blocking JavaScript execution.

// Use next/script with appropriate loading strategy:
import Script from 'next/script';

// In app/layout.tsx:
<>
  {/* Deferred: loads after page is interactive (analytics) */}
  <Script
    src="https://cdn.posthog.com/posthog.js"
    strategy="lazyOnload"
  />

  {/* After interactive: loads after hydration (Intercom, Crisp) */}
  <Script
    src="https://widget.intercom.io/widget/app_id"
    strategy="afterInteractive"
  />
</>

The practical strategy mapping: PostHog/Mixpanel/Segment → lazyOnload. Intercom/Crisp → afterInteractive. Google Tag Manager → afterInteractive. Any A/B testing script where the page variation depends on the result → beforeInteractive (required, unavoidable).

Loading analytics as lazyOnload instead of in the document <head> typically saves 0.2–0.5s FCP with no impact on data collection — analytics events queue while the script loads and flush once it initializes.


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 Sentry
    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
Font optimization with next/fontCLS -0.05 to -0.130 min
Third-party script lazy loadingFCP -0.2 to -0.5s30 min

For boilerplate selection with performance-optimized defaults — RSC patterns, Image components, font loading — React Server Components in boilerplates covers the rendering patterns that make the biggest difference to FCP. For observability tools that monitor Core Web Vitals in production and alert on regressions, SaaS observability stack covers the Sentry and OpenTelemetry setup. For the org-scoped data model that prevents cross-tenant query performance issues at scale, how to convert single-tenant to multi-tenant covers the indexing and query patterns.


Methodology

Performance benchmarks and optimization estimates based on testing across ShipFast, T3 Stack, and Supastarter boilerplates using Lighthouse 12 and Chrome DevTools Performance panel. N+1 query patterns identified from production Next.js application reviews. Core Web Vitals targets from Google's Web Vitals documentation as of Q1 2026. Bundle size figures for third-party libraries sourced from bundlephobia.com.

Compare boilerplate Lighthouse scores on StarterPick.

Check out this boilerplate

View ShipFaston StarterPick →

The SaaS Boilerplate Matrix (Free PDF)

20+ SaaS starters compared: pricing, tech stack, auth, payments, and what you actually ship with. Updated monthly. Used by 150+ founders.

Join 150+ SaaS founders. Unsubscribe in one click.