Chapter 17: Performance Monitoring & Observability
Our SAAS application is now feature-complete, having passed through rigorous CI/CD pipelines (Chapter 14) and automated testing (Chapter 16). It's deployed to production on Vercel. But once our app 'leaves' our development and testing environment and enters the unpredictable devices and networks of real users, how do we know it's running well?
Chapter 17: Performance Monitoring & Observability
Our SAAS application is now feature-complete, having passed through rigorous CI/CD pipelines (Chapter 14) and automated testing (Chapter 16). It's deployed to production on Vercel. But once our app "leaves" our development and testing environment and enters the unpredictable devices and networks of real users, how do we know it's running well?
"It works on my machine" means nothing in production.
This is where Observability comes in. For Python/Django developers, you might be familiar with Sentry (sentry-python), New Relic, or Datadog. These tools help you answer three core questions:
- Is it slow? (Performance monitoring)
- Did it crash? (Error tracking)
- What are users doing with it? (Product analytics)
In the Next.js/Vercel ecosystem, we have more modern, integrated tools to answer these questions. In this chapter, we'll build a three-pillared observability stack for our SAAS application.
17.1. Performance Monitoring: Vercel Analytics & Web Vitals
Our first concern is user-perceived performance. If your SAAS loads slowly or stutters during interactions, users will churn before they click the "Upgrade" button.
Core Web Vitals are a set of key metrics defined by Google to measure real-user page experience:
- LCP (Largest Contentful Paint): Measures loading performance. (e.g., how fast does your dashboard chart appear?)
- FID (First Input Delay) / INP (Interaction to Next Paint): Measures interactivity. (e.g., how fast does the page respond after a user clicks "Save"?)
- CLS (Cumulative Layout Shift): Measures visual stability. (e.g., does the button suddenly "jump" when the page loads, causing users to click the wrong thing?)
Vercel Analytics is a zero-configuration solution provided by the Vercel platform, deeply integrated with Next.js.
You don't need to install any SDK or write any code. You simply click the "Enable" button in your Vercel project dashboard.
Once enabled, Vercel automatically:
- Injects an ultra-lightweight (~1KB) script into your Next.js application.
- Collects the above Web Vitals data from real users visiting your site (this is called RUM - Real User Monitoring).
- Provides you with a beautiful performance report in your Vercel dashboard, broken down by page and country/region.
Why Vercel Analytics?
- Zero configuration, zero performance impact: It's designed to have no negative impact on your app's performance (especially INP).
- Privacy-first: It doesn't use cookies, doesn't track individual users, and fully complies with privacy regulations like GDPR.
- RSC-aware: It understands Next.js routing (App Router) well and provides meaningful page-level reports.
For measuring and ensuring Core Web Vitals, Vercel Analytics is the top choice for Next.js developers.
17.2. Error Tracking: Integrating Sentry
Vercel Analytics tells us if a page is slow, but it doesn't tell us why it crashed. When users encounter a white screen, a failed Server Action, or a crashed client component, we need to know immediately and have complete context.
Sentry is the industry standard for modern application monitoring and error tracking. From Python to JavaScript, it provides full-stack coverage.
For our Next.js 15+ SAAS application, the @sentry/nextjs SDK is a game-changer. Because it automatically captures errors from all execution contexts:
- Client Components (
"use client"): e.g., a null reference in a Zustand store. - Server Components (RSC): e.g., Drizzle throws an exception when accessing the database directly (
db.query...) in an RSC. - Server Actions: e.g., our
consumeCreditsaction fails. - Route Handlers (API): e.g., our Stripe webhook handler crashes.
[Code Analysis]: Integrating Sentry
Integrating Sentry is straightforward. The Sentry team provides a wizard:
npx @sentry/wizard -i nextjsThis wizard automatically:
- Installs the
@sentry/nextjsdependency. - Adds
SENTRY_DSNandSENTRY_AUTH_TOKENto your.env.local. - Creates Sentry configuration files:
sentry.client.config.ts(for client-side)sentry.server.config.ts(for server-side - Node.js runtime)sentry.edge.config.ts(for Edge runtime, e.g., Middleware)
- Modifies your
next.config.mjsto wrap it withwithSentryConfig, automatically uploading Source Maps during build (crucial for debugging minified production code).
Why is Sentry so critical?
When an error occurs, Sentry doesn't just log a console.error. It captures a complete "event", including:
- Stack Trace: Precise line numbers in your TSX source code (thanks to Source Maps).
- User Context: We can (and should) configure Sentry to tell it the currently logged-in
userId. This way we can see "User A encountered this error 5 times." - Breadcrumbs: What actions the user performed before the crash (e.g., "Clicked button A", "Navigated to page B").
- RSC & Server Action tags: It automatically tags whether the error occurred in which Server Action or RSC page.
An example of Sentry setTag configured with userId:
// src/actions/payment.actions.ts
'use server';
import { auth } from '@/lib/auth';
import * as Sentry from '@sentry/nextjs';
export async function createCheckoutSession(priceId: string) {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
// Add context to Sentry!
Sentry.setUser({ id: session.user.id, email: session.user.email });
Sentry.setTag("action", "createCheckoutSession");
try {
// ... our Stripe logic ...
} catch (error) {
// Manually capture and report the error
Sentry.captureException(error, {
extra: { priceId: priceId },
});
return { error: 'Failed to create session.' };
}
}17.3. [Code Analysis]: Analyzing Multi-Provider Integration in src/analytics/
We now have performance (Vercel) and errors (Sentry). But we're still missing product analytics.
Vercel Analytics won't tell us:
- "How many users clicked the 'Upgrade' button for the Pro plan?"
- "What's the average time users spend on the dashboard page?"
- "What's the conversion rate from homepage to signup page?"
These are the data points product managers and growth teams need. Traditionally, we'd use Google Analytics (GA), but GA is increasingly constrained by privacy regulations (GDPR) and can slow down your site.
Modern SAAS tends to use privacy-first analytics tools like Plausible or Umami. They're lightweight, cookie-free alternatives.
The question is: how do we integrate them without tying our codebase to a specific provider? The answer is: create an abstraction layer.
Directory Structure
src/
└── analytics/
├── AnalyticsProvider.tsx (Client component for loading scripts)
└── events.ts (Global event tracking functions)File 1: src/analytics/AnalyticsProvider.tsx
This is a client component responsible for dynamically loading analytics scripts based on environment variables.
// src/analytics/AnalyticsProvider.tsx
'use client';
import Script from 'next/script';
import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect } from 'react';
// Read from .env.local or Vercel environment variables
const PLAUSIBLE_DOMAIN = process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN;
const UMAMI_SITE_ID = process.env.NEXT_PUBLIC_UMAMI_SITE_ID;
const UMAMI_SCRIPT_URL = process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL;
export function AnalyticsProvider() {
const pathname = usePathname();
const searchParams = useSearchParams();
// This Effect ensures that when Next.js routes (App Router) change,
// Umami correctly captures page views
useEffect(() => {
if (!UMAMI_SITE_ID || !window.umami) return;
// `usePathname` and `useSearchParams` ensure this Effect re-runs
// every time the URL changes, sending page view events
const url = pathname + (searchParams.toString() ? '?' + searchParams.toString() : '');
window.umami.track(url);
}, [pathname, searchParams]);
return (
<>
{/* 1. Plausible Analytics */}
{PLAUSIBLE_DOMAIN && (
<Script
defer
data-domain={PLAUSIBLE_DOMAIN}
src="https://plausible.io/js/script.js"
/>
)}
{/* 2. Umami Analytics */}
{UMAMI_SITE_ID && UMAMI_SCRIPT_URL && (
<Script
async
defer
data-website-id={UMAMI_SITE_ID}
src={UMAMI_SCRIPT_URL}
// Umami auto-tracks page views by default
// but in Next.js App Router, we manually track with the useEffect above
data-auto-track="false"
data-do-not-track="true" // Disable auto-tracking, let the Effect take over
/>
)}
</>
);
}Then, we add this AnalyticsProvider to our root layout src/app/[locale]/layout.tsx.
File 2: src/analytics/events.ts
This file is our abstraction layer. Any other component in the application (like a pricing table) will call functions in this file, without needing to know whether it's Plausible or Umami handling it.
// src/analytics/events.ts
// Extend global window type to include Plausible and Umami
declare global {
interface Window {
plausible?: (event: string, options?: { props: Record<string, any> }) => void;
umami?: (event: string, data?: Record<string, any>) => void;
}
}
type AnalyticsEvent =
| 'click_upgrade'
| 'register_success'
| 'start_checkout';
interface EventProps {
planId?: string;
source?: string;
}
/**
* Track a custom event
* @param eventName Event name (e.g., 'click_upgrade')
* @param props Additional properties (e.g., { planId: 'pro' })
*/
export function trackEvent(eventName: AnalyticsEvent, props: EventProps = {}) {
// 1. Send to Plausible
if (window.plausible) {
window.plausible(eventName, { props });
}
// 2. Send to Umami
if (window.umami) {
// Umami's format is 'event_name', { data: { ... } }
window.umami(eventName, { data: props });
}
// 3. (Optional) Print to console (development only)
if (process.env.NODE_ENV === 'development') {
console.log(`[ANALYTICS] Event: ${eventName}`, props);
}
}Now, in our PricingTable.tsx (from Chapter 12), we can do this:
// src/components/payment/PricingTable.tsx
'use client';
import { trackEvent } from '@/analytics/events'; // Import our abstraction function
// ...
function PlanCard({ plan }: { plan: PricePlan }) {
// ...
const handleUpgrade = async () => {
//...
// Before starting checkout, track this key conversion event!
trackEvent('click_upgrade', {
planId: plan.id,
source: 'pricing_page'
});
// ... (call createCheckoutSession, etc.)
};
// ...
}Chapter 17 Summary
In this chapter, we built a comprehensive, three-pillared observability stack for our SAAS application, ensuring we have 360-degree visibility into production.
- Performance Monitoring (Vercel Analytics): We leverage Vercel's zero-config RUM to automatically collect Core Web Vitals. This ensures our app maintains high performance for real users, safeguarding user experience and SEO.
- Error Tracking (Sentry): We integrated Sentry and used its
@sentry/nextjsSDK to automatically capture errors from the full stack (RSC, Server Actions, client). By adding user context (userId), we dramatically improved our ability to debug production bugs. - Product Analytics (Plausible/Umami): We built an abstracted analytics layer (
AnalyticsProviderandtrackEvent). This enables us to integrate one or more privacy-first analytics tools to track key user behaviors (like "click_upgrade") without locking our codebase to any specific provider.
With this stack, we don't just know if our app is working—we know how well it's working, how many people are using it, and how they're using it.
Categories
src/analytics/AnalyticsProvider.tsxFile 2: src/analytics/events.tsChapter 17 SummaryMore Posts
Chapter 2: TypeScript - The Architectural Contract for SaaS Projects (vs. Mypy)
In Chapter 1, we explored Node.js's asynchronous philosophy. Now, let's talk about JavaScript's safety belt: TypeScript (TS).
Chapter 4: App Router - Built for SaaS Performance
If the algorithmic thinking you built in section 1.4 is 'computational primitives', then Next.js App Router is the masterwork that 'engineers' these primitives. It's a highly optimized architecture designed to solve SaaS application (especially data-driven and I/O-intensive) performance bottlenecks.
Chapter 15: Code Quality (Biome) and Content (Fumadocs)
In Chapter 14, we established a solid CI/CD pipeline that acts as the 'gatekeeper' for our SAAS application. One of the core responsibilities of this gatekeeper is running pnpm lint and pnpm typecheck. But what's actually working behind these commands?