When something goes wrong in production, you need enough context to reproduce and fix it. That means structured logs, useful error messages, source maps for readable stack traces, and optionally an error-tracking service.
The debugging challenge in production
Production differs from development:
| Development | Production |
|---|---|
| Full error details visible | Errors hidden from users |
| Source code available | Code minified |
| Console always open | Logs go to stdout |
| Reproduce easily | Hard to reproduce |
You need visibility into production without exposing sensitive information to users.
Structured logging
Why structure matters
Unstructured logs are hard to search:
Error: User not found
Something went wrong with payment
Structured logs are queryable:
{"level":"error","message":"User not found","userId":"123","requestId":"abc","timestamp":"2026-02-15T10:30:00Z"}
{"level":"error","message":"Payment failed","userId":"456","paymentId":"pay_789","error":"card_declined","requestId":"def"}
Building a logger
Create a simple logger wrapper:
// lib/logger.ts
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
interface LogContext {
requestId?: string;
userId?: string;
[key: string]: unknown;
}
function log(level: LogLevel, message: string, context?: LogContext) {
const entry = {
level,
message,
timestamp: new Date().toISOString(),
...context,
};
if (process.env.NODE_ENV === 'development') {
console.log(JSON.stringify(entry, null, 2));
} else {
console.log(JSON.stringify(entry));
}
}
export const logger = {
debug: (msg: string, ctx?: LogContext) => log('debug', msg, ctx),
info: (msg: string, ctx?: LogContext) => log('info', msg, ctx),
warn: (msg: string, ctx?: LogContext) => log('warn', msg, ctx),
error: (msg: string, ctx?: LogContext) => log('error', msg, ctx),
};
Using the logger
// In Route Handlers
export async function POST(request: Request) {
const requestId = crypto.randomUUID();
try {
const body = await request.json();
logger.info('Processing order', { requestId, userId: body.userId });
// ... process order
logger.info('Order completed', { requestId, orderId: order.id });
return Response.json({ success: true });
} catch (error) {
logger.error('Order failed', {
requestId,
error: error instanceof Error ? error.message : 'Unknown error',
});
return Response.json({ error: 'Order failed' }, { status: 500 });
}
}
What to log
| Do log | Do not log |
|---|---|
| Request IDs | Passwords |
| User IDs | Full tokens |
| Error messages | Credit card numbers |
| Relevant context | PII beyond what is needed |
| Timestamps | Full request bodies |
| Operation names | Stack traces to users |
Error boundaries in App Router
Creating error.tsx
Add error.tsx to catch errors in route segments:
// app/dashboard/error.tsx
'use client';
import { useEffect } from 'react';
import { Button } from '@/components/ui';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Log to error tracking service
console.error('Dashboard error:', error);
}, [error]);
return (
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
<h2 className="text-xl font-semibold">Something went wrong</h2>
<p className="text-gray-500">
We have been notified and are working on a fix.
</p>
<Button onClick={reset}>Try again</Button>
</div>
);
}
Global error handling
For root-level errors, add global-error.tsx:
// app/global-error.tsx
'use client';
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html>
<body>
<div>
<h2>Something went wrong</h2>
<button onClick={reset}>Try again</button>
</div>
</body>
</html>
);
}
Error boundary hierarchy
app/
error.tsx # Catches app-level errors
global-error.tsx # Catches root layout errors
dashboard/
error.tsx # Catches dashboard errors
settings/
error.tsx # Catches settings errors
More specific error boundaries take precedence.
Source maps
The problem
Production code is minified:
TypeError: Cannot read property 'map' of undefined
at r (/_next/static/chunks/pages/_app-123abc.js:1:12345)
This is not useful for debugging.
Enabling source maps
In next.config.js:
module.exports = {
productionBrowserSourceMaps: true, // For client-side
// Or upload to error tracking service privately
};
Private source maps
Better approach: keep source maps private and upload to your error tracking service:
// next.config.js
const { withSentryConfig } = require('@sentry/nextjs');
module.exports = withSentryConfig(
{ /* your config */ },
{
hideSourceMaps: true, // Do not expose publicly
widenClientFileUpload: true,
}
);
Sentry (and similar tools) decode stack traces server-side.
Error tracking services
What they provide
| Feature | Benefit |
|---|---|
| Error aggregation | See all errors in one place |
| Stack traces | Decoded with source maps |
| Frequency | Know which errors are common |
| User context | See who was affected |
| Release tracking | Know when errors started |
| Alerts | Get notified of new errors |
Setting up Sentry
npx @sentry/wizard@latest -i nextjs
This configures:
- SDK initialization
- Source map uploads
- Error boundaries
- Performance monitoring
Manual integration
// lib/sentry.ts
import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 0.1, // 10% of transactions
environment: process.env.NODE_ENV,
});
// Capture errors
export function captureError(error: Error, context?: Record<string, unknown>) {
Sentry.captureException(error, { extra: context });
}
// Add user context
export function setUser(user: { id: string; email?: string }) {
Sentry.setUser(user);
}
Request tracing
Adding request IDs
// middleware.ts
import { NextResponse } from 'next/server';
export function middleware(request: Request) {
const requestId = crypto.randomUUID();
const response = NextResponse.next();
response.headers.set('x-request-id', requestId);
return response;
}
Using request IDs in logs
export async function GET(request: Request) {
const requestId = request.headers.get('x-request-id') || 'unknown';
logger.info('Handling request', { requestId, path: '/api/users' });
// ... rest of handler
}
This lets you trace a request through your logs.
Debugging checklist
| Step | Action |
|---|---|
| 1. Find the error | Check error tracking dashboard or logs |
| 2. Get context | Request ID, user ID, timestamp |
| 3. Read stack trace | Use source maps to decode |
| 4. Check related logs | Filter by request ID |
| 5. Reproduce locally | Use the same inputs |
| 6. Fix and deploy | Add tests to prevent regression |
Summary
Effective production debugging requires:
- Structured logging with request IDs and context
- Error boundaries for graceful React error handling
- Source maps for readable stack traces (kept private)
- Error tracking services for centralized monitoring
- Request tracing to follow requests through logs
Set these up before you need them. When production breaks, you will have the context to fix it quickly.
