DevOps & Deployment
Monitoring Production Next.js Applications: My Setup
Last updated: April 14, 2026
TL;DR
Shipping a Next.js app to production without monitoring is flying blind. I monitor every production app I deploy using a practical stack: Vercel Analytics for traffic and engagement data, Vercel Speed Insights for real-user Web Vitals, Sentry for error tracking with source maps, structured JSON logging for debugging, Prisma metrics for database performance, and BetterUptime for health checks. This guide walks through the exact setup I use across my projects — the configuration files, the patterns, and the decisions behind each choice. No Datadog. No Grafana clusters. Just what a solo developer or small team actually needs to sleep well at night.
What to Monitor
Before installing anything, I think about what questions I need to answer when something goes wrong. Every monitoring tool should answer at least one of these:
Is the app up? The most basic question. If users can't reach the site, nothing else matters. This is uptime monitoring.
Is the app fast? A site can be "up" but painfully slow. Core Web Vitals — LCP, INP, CLS — tell me if real users are having a good experience. This is performance monitoring.
Are there errors? Unhandled exceptions, failed API calls, broken integrations. I need to know about these before users report them. This is error tracking.
What happened before the error? When something breaks at 2am, I need context. What request triggered it? What user was affected? What was the state of the system? This is structured logging.
Is the database healthy? Slow queries, connection pool exhaustion, N+1 problems that slipped through code review. This is database monitoring.
How are users engaging? Page views, unique visitors, top pages, referral sources. Not vanity metrics — these tell me if a deployment broke a critical user flow. This is analytics.
I don't monitor everything on day one. I start with uptime and error tracking, then layer in performance and analytics. The key is that every tool earns its place by answering a specific question. If I can't articulate what question a monitoring tool answers, I don't install it.
Vercel Analytics Setup
Vercel Analytics is the first thing I enable on every project. It gives me traffic data — page views, unique visitors, top pages, referrers, geographic distribution — without any third-party scripts or cookie banners.
The setup takes about thirty seconds:
npm install @vercel/analyticsThen I add it to my root layout:
// app/layout.tsx
import { Analytics } from "@vercel/analytics/react";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
{children}
<Analytics />
</body>
</html>
);
}That's it. No configuration files, no API keys, no environment variables. Vercel Analytics works by collecting anonymized page view data through their edge network. It doesn't use cookies, so there's no GDPR consent banner required — which matters for my EU-based clients.
What I actually look at in Vercel Analytics:
- Top pages after a deployment. If a page that normally gets 500 views/day suddenly drops to zero, something broke in the deploy.
- Referral sources. When I publish a blog post or launch a feature, I check where traffic is coming from to understand what's working.
- Geographic distribution. For clients with regional audiences, I verify that traffic patterns match expectations. A UK e-commerce site getting 80% US traffic suggests a routing or SEO problem.
What Vercel Analytics doesn't do: It won't tell me about performance, errors, or user behavior within pages. It's pure traffic analytics. For everything else, I need additional tools.
The free tier includes 2,500 events per month. The Pro plan (included with Vercel Pro at $20/month) bumps that to 25,000. For most of my projects, the Pro plan is more than enough. I've only hit the limit once, on a high-traffic marketing site during a product launch.
Speed Insights for Web Vitals
Vercel Speed Insights is separate from Vercel Analytics, and it answers a different question: how fast is the app for real users?
It collects Real User Monitoring (RUM) data for Core Web Vitals — the metrics Google uses for search ranking:
- LCP (Largest Contentful Paint): How quickly the main content loads. Target: under 2.5 seconds.
- INP (Interaction to Next Paint): How responsive the app feels when users click or type. Target: under 200 milliseconds.
- CLS (Cumulative Layout Shift): How much the page jumps around during load. Target: under 0.1.
Installation:
npm install @vercel/speed-insights// app/layout.tsx
import { Analytics } from "@vercel/analytics/react";
import { SpeedInsights } from "@vercel/speed-insights/next";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
{children}
<Analytics />
<SpeedInsights />
</body>
</html>
);
}The reason I use Speed Insights instead of (or alongside) Lighthouse is that Lighthouse runs synthetic tests from a single machine. Speed Insights collects data from real users on real devices and real networks. A Lighthouse score of 95 means nothing if users in rural areas on 3G connections are seeing 8-second load times.
What I watch for:
- LCP by page. Some pages load fast, others don't. Speed Insights breaks it down so I can target the slow ones. Usually it's a hero image without proper sizing or a blocking API call.
- INP spikes after deploys. If I ship a feature with heavy client-side JavaScript, INP will show the impact immediately. I've caught multiple regressions this way.
- CLS on mobile vs desktop. Layout shifts are almost always worse on mobile because of ad slots, lazy-loaded images without dimensions, or font loading. Speed Insights shows the split.
A real example: Last year, I deployed a client's e-commerce site and LCP jumped from 1.8s to 4.2s. Speed Insights flagged it within an hour. The culprit was a new product carousel component that loaded all images eagerly instead of lazy-loading. Without RUM data, I wouldn't have caught it until the client noticed slower sales.
Speed Insights is included with Vercel Pro. On the free tier, it's limited to a smaller sample of data points, but still useful for smaller projects.
Error Tracking and Alerting
Vercel gives me analytics and performance. For error tracking, I use Sentry. It catches unhandled exceptions on both the client and server, groups them intelligently, and sends me alerts before users file bug reports.
Sentry has first-class Next.js support with a wizard that handles most of the setup:
npx @sentry/wizard@latest -i nextjsThis creates the configuration files. Here's what the core setup looks like after the wizard runs:
// sentry.client.config.ts
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 0.1,
replaysSessionSampleRate: 0.05,
replaysOnErrorSampleRate: 1.0,
integrations: [
Sentry.replayIntegration({
maskAllText: true,
blockAllMedia: true,
}),
],
});// sentry.server.config.ts
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 0.1,
});Key decisions in this configuration:
- `tracesSampleRate: 0.1` — I sample 10% of transactions for performance tracing. 100% would be expensive and unnecessary. For most apps, 10% gives a statistically meaningful picture.
- `replaysOnErrorSampleRate: 1.0` — When an error occurs, I always capture a session replay. This is worth its weight in gold for debugging. I can see exactly what the user did before the error happened.
- `replaysSessionSampleRate: 0.05` — I only record 5% of normal sessions. Session replays are expensive on Sentry's billing, and I don't need to watch every user navigate around.
- `maskAllText: true` — Privacy first. Session replays mask all text content by default. I don't want to accidentally capture sensitive user data.
Source maps are critical. Without them, Sentry shows minified stack traces that are nearly useless. The Sentry wizard configures the Next.js build to upload source maps automatically. Verify this is working by checking Sentry's "Source Maps" section after a deployment.
Alerting rules I configure:
- New issue alert: Slack notification for every new error type. I want to know about novel failures immediately.
- Regression alert: If a previously resolved error reappears, Sentry flags it. This usually means a fix was incomplete or a merge conflict reintroduced the bug.
- Spike alert: If error volume jumps 300% in 5 minutes, I get a page. This usually means a deployment went bad.
I don't alert on every individual error occurrence. That's noise. I care about new error types and volume spikes.
Sentry's free tier includes 5,000 errors per month, which covers most side projects and early-stage apps. The Team plan at $26/month gives me 50,000 errors, performance monitoring, and session replays — well worth it for client projects.
Structured Logging Patterns
Console.log doesn't cut it in production. When something breaks and I'm reading through logs at midnight, I need structure — consistent fields, severity levels, and enough context to reconstruct what happened.
I use a lightweight logging utility that outputs JSON. No heavyweight logging frameworks. Here's the pattern:
// lib/logger.ts
type LogLevel = "info" | "warn" | "error" | "debug";
interface LogEntry {
level: LogLevel;
message: string;
timestamp: string;
requestId?: string;
userId?: string;
path?: string;
duration?: number;
metadata?: Record<string, unknown>;
}
function createLogEntry(
level: LogLevel,
message: string,
metadata?: Record<string, unknown>
): LogEntry {
return {
level,
message,
timestamp: new Date().toISOString(),
...metadata,
};
}
export const logger = {
info: (message: string, metadata?: Record<string, unknown>) =>
console.log(JSON.stringify(createLogEntry("info", message, metadata))),
warn: (message: string, metadata?: Record<string, unknown>) =>
console.warn(JSON.stringify(createLogEntry("warn", message, metadata))),
error: (message: string, metadata?: Record<string, unknown>) =>
console.error(JSON.stringify(createLogEntry("error", message, metadata))),
debug: (message: string, metadata?: Record<string, unknown>) => {
if (process.env.NODE_ENV === "development") {
console.debug(JSON.stringify(createLogEntry("debug", message, metadata)));
}
},
};How I use it in API routes:
// app/api/orders/route.ts
import { logger } from "@/lib/logger";
export async function POST(request: Request) {
const requestId = crypto.randomUUID();
const startTime = performance.now();
try {
const body = await request.json();
logger.info("Order creation started", {
requestId,
userId: body.userId,
itemCount: body.items?.length,
});
const order = await createOrder(body);
logger.info("Order created successfully", {
requestId,
orderId: order.id,
duration: Math.round(performance.now() - startTime),
});
return Response.json(order, { status: 201 });
} catch (error) {
logger.error("Order creation failed", {
requestId,
error: error instanceof Error ? error.message : "Unknown error",
stack: error instanceof Error ? error.stack : undefined,
duration: Math.round(performance.now() - startTime),
});
return Response.json(
{ code: "ORDER_CREATION_FAILED", message: "Failed to create order" },
{ status: 500 }
);
}
}Why JSON logging matters: Vercel's log viewer, and any log aggregation service, can parse JSON logs. This means I can filter by level: "error", search by requestId, or aggregate duration values to find slow endpoints. Unstructured string logs ("Something went wrong!") are useless at scale.
Rules I follow:
- Every log entry has a
requestIdthat ties related logs together across a single request lifecycle. - I log at the boundary — when a request enters and when it leaves. Not every intermediate step.
- Error logs include the original error message and stack trace. I never log just "An error occurred."
- I never log sensitive data — passwords, tokens, full credit card numbers, personal health information. If I need to reference a user, I log the user ID, never the email.
- Duration is logged on every response. This gives me a lightweight performance profile without a full APM tool.
API Route Monitoring
Next.js API routes are where most production issues surface. A slow third-party API, a database connection timeout, a malformed request body — these all manifest in the API layer.
I wrap my API routes with a lightweight middleware that handles timing, error catching, and structured logging in one place:
// lib/api-monitor.ts
import { logger } from "@/lib/logger";
import * as Sentry from "@sentry/nextjs";
type RouteHandler = (
request: Request,
context: { params: Promise<Record<string, string>> }
) => Promise<Response>;
export function withMonitoring(handler: RouteHandler): RouteHandler {
return async (request, context) => {
const requestId = crypto.randomUUID();
const startTime = performance.now();
const url = new URL(request.url);
logger.info("API request received", {
requestId,
method: request.method,
path: url.pathname,
});
try {
const response = await handler(request, context);
const duration = Math.round(performance.now() - startTime);
logger.info("API request completed", {
requestId,
method: request.method,
path: url.pathname,
status: response.status,
duration,
});
if (duration > 3000) {
logger.warn("Slow API response detected", {
requestId,
path: url.pathname,
duration,
});
}
return response;
} catch (error) {
const duration = Math.round(performance.now() - startTime);
Sentry.captureException(error, {
tags: { requestId, path: url.pathname },
});
logger.error("API request failed", {
requestId,
method: request.method,
path: url.pathname,
error: error instanceof Error ? error.message : "Unknown error",
duration,
});
return Response.json(
{ code: "INTERNAL_ERROR", message: "An unexpected error occurred" },
{ status: 500 }
);
}
};
}Usage in a route:
// app/api/users/[id]/route.ts
import { withMonitoring } from "@/lib/api-monitor";
export const GET = withMonitoring(async (request, context) => {
const params = await context.params;
const user = await getUserById(params.id);
if (!user) {
return Response.json(
{ code: "NOT_FOUND", message: "User not found" },
{ status: 404 }
);
}
return Response.json(user);
});The withMonitoring wrapper gives me three things automatically: a unique request ID for correlating logs, duration tracking with slow-response warnings, and Sentry integration for unhandled exceptions. Every API route gets this for free with a single function wrapper.
The 3-second threshold for slow response warnings is configurable. For most web applications, anything over 3 seconds on the API layer means the user is staring at a loading spinner. I want to know about it.
Database Performance Monitoring
If you're using Prisma (which I do for most projects), you get access to query-level metrics and logging that are invaluable for catching performance issues.
// lib/prisma.ts
import { PrismaClient } from "@prisma/client";
import { logger } from "@/lib/logger";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: [
{ emit: "event", level: "query" },
{ emit: "event", level: "error" },
{ emit: "event", level: "warn" },
],
});
if (process.env.NODE_ENV === "development") {
prisma.$on("query", (e) => {
if (e.duration > 100) {
logger.warn("Slow database query detected", {
query: e.query,
duration: e.duration,
target: e.target,
});
}
});
}
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}What this catches:
- Slow queries (over 100ms): In development, I log any query that takes more than 100ms. This catches N+1 problems and missing indexes before they hit production.
- Connection errors: Prisma logs connection pool exhaustion, which usually means I have a leak — an unclosed transaction or too many concurrent serverless function instances sharing a connection pool.
- Query patterns: By reviewing logged queries, I can spot opportunities for
includestatements to batch related queries, or places where I'm fetching more data than I need.
For production, I rely on Sentry's database span tracking (enabled through tracesSampleRate) rather than logging every query. Sentry shows me the slowest database operations in the Performance tab, broken down by endpoint.
Connection pooling matters. On Vercel's serverless environment, every function invocation can spawn a new database connection. Without pooling, this will exhaust your database's connection limit within minutes under load. I use Prisma's connection pool with Supabase's built-in PgBouncer, and I set connection_limit in the connection string:
DATABASE_URL="postgresql://user:password@host:6543/db?pgbouncer=true&connection_limit=5"The connection_limit=5 keeps each serverless instance from hogging connections. Five is enough for most API routes, and it prevents the "too many connections" error that has woken me up at 3am more than once.
Uptime and Health Checks
All the monitoring in the world doesn't help if the app is down and nobody notices. Uptime monitoring is the simplest and most important piece of the stack.
I use BetterUptime (now part of BetterStack) for external uptime monitoring. It pings my applications from multiple global locations every 30 seconds and alerts me via Slack and SMS if anything goes down.
But it needs something to ping. I add a health check endpoint to every Next.js app:
// app/api/health/route.ts
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export async function GET() {
const checks: Record<string, "healthy" | "unhealthy"> = {};
try {
await prisma.$queryRaw`SELECT 1`;
checks.database = "healthy";
} catch {
checks.database = "unhealthy";
}
const allHealthy = Object.values(checks).every((s) => s === "healthy");
return Response.json(
{
status: allHealthy ? "healthy" : "degraded",
checks,
timestamp: new Date().toISOString(),
version: process.env.VERCEL_GIT_COMMIT_SHA?.slice(0, 7) ?? "unknown",
},
{ status: allHealthy ? 200 : 503 }
);
}Why this endpoint matters:
- `force-dynamic` ensures Vercel doesn't cache the response. A cached "healthy" response when the database is down defeats the purpose.
- The database check actually queries the database. A health check that returns 200 without checking dependencies is lying.
- The version field includes the Git commit SHA. When I see a degraded status, I can immediately identify which deployment is running.
- 503 on degraded lets the uptime monitor distinguish between "app is up but degraded" and "app is fully healthy." I configure different alert severities for each.
BetterUptime's free tier monitors 10 endpoints at 3-minute intervals. The Hobby plan at $20/month gives me 30-second intervals, SMS alerts, and status pages. For client projects, the status page alone justifies the cost — clients can check the status themselves instead of emailing me.
Cost of Monitoring
Monitoring costs money, and it's easy to let the bill creep up. Here's what I actually pay for my monitoring stack across a typical project:
| Tool | Plan | Monthly Cost | What It Covers |
|---|---|---|---|
| Vercel Analytics | Pro (bundled) | $0 | Traffic, page views, referrers |
| Vercel Speed Insights | Pro (bundled) | $0 | Real-user Web Vitals |
| Sentry | Team | $26 | Error tracking, session replays |
| BetterUptime | Hobby | $20 | Uptime monitoring, status page |
| Total | $46/month |
Vercel Analytics and Speed Insights come bundled with Vercel Pro ($20/month), which I'm already paying for hosting. So the incremental cost of monitoring is $46/month — Sentry plus BetterUptime.
For side projects and early-stage apps, I run everything on free tiers. Vercel free, Sentry free (5,000 errors/month), BetterUptime free (3-minute checks). Total cost: $0. It's less coverage, but it's better than no monitoring at all.
Where I don't spend money: I don't pay for Datadog ($23/host/month), New Relic ($0.30/GB ingested), or any full APM suite. Those tools are incredible for large teams running complex microservice architectures. For a solo developer or small team running Next.js on Vercel, they're overkill. The combination of Vercel's built-in tools plus Sentry covers 95% of what I need.
The remaining 5% — deep database performance analysis, infrastructure metrics, distributed tracing across services — I handle ad-hoc with Prisma's query logging and Vercel's function logs. I'd rather invest that $100+/month into better hosting or a design tool subscription.
My Monitoring Stack
Here's the complete picture of what I run on every production Next.js application:
| Layer | Tool | Purpose |
|---|---|---|
| Traffic Analytics | Vercel Analytics | Page views, visitors, referrers, geography |
| Performance (RUM) | Vercel Speed Insights | LCP, INP, CLS from real users |
| Error Tracking | Sentry | Exceptions, source maps, session replays |
| Logging | Custom JSON logger | Structured logs with request correlation |
| API Monitoring | withMonitoring wrapper | Request timing, slow response detection |
| Database | Prisma event logging | Slow queries, connection issues |
| Uptime | BetterUptime | Health checks, status pages, SMS alerts |
The setup checklist I run through on every new project:
- Install
@vercel/analyticsand@vercel/speed-insightsin the root layout. - Run the Sentry wizard and configure sample rates.
- Copy my
logger.tsandapi-monitor.tsutilities into the project. - Add the
/api/healthendpoint with database checks. - Configure BetterUptime to ping the health endpoint every 30 seconds.
- Set up Sentry alert rules — new issues to Slack, spikes to SMS.
- Verify source maps are uploading correctly after the first deployment.
The entire setup takes about 30 minutes. After that, I don't touch it unless an alert fires. The best monitoring setup is the one you configure once and forget about until you need it.
Key Takeaways
- Start with uptime and error tracking. Everything else is secondary. If you only have 10 minutes, install Sentry and set up BetterUptime.
- Vercel's built-in tools are underrated. Analytics and Speed Insights give you traffic and performance data with zero configuration beyond installing the packages.
- Structured logging pays for itself the first time something breaks at 2am. JSON logs with request IDs let you trace a single request across your entire application.
- Sample your traces. 10% sampling on Sentry tracing gives you a meaningful performance picture without blowing up your bill. You don't need 100%.
- Health checks should check dependencies. A
/healthendpoint that returns 200 without querying the database is a liar. - Don't over-engineer monitoring. $46/month covers everything a solo developer or small team needs. Save the enterprise APM budget for when you have an enterprise.
- Monitor what matters to your users. Web Vitals, error rates, and uptime directly correlate with user experience. Server CPU usage and memory graphs are interesting but rarely actionable for a Next.js app on Vercel.
If you're building a production application and need help setting up monitoring — or any other part of your deployment pipeline — check out my services. I work with teams to build production-grade Next.js applications that don't fall over at 3am.
*Written by Uvin Vindula↗ — Web3/AI engineer building production-grade applications from Sri Lanka and the UK. I write about the tools and patterns I actually use, not the ones I read about. Follow my work at @IAMUVIN↗ or explore more at uvin.lk↗.*
Working on a Web3 or AI project?

Uvin Vindula
Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.