Error Handling and Observability for Next.js + Supabase
Comprehensive guide to error handling, logging, monitoring, and observability for production Next.js and Supabase applications.
This guide shows how to implement error handling, structured logging and monitoring for a Next.js App Router application backed by Supabase.
Proper error handling prevents crashes and provides graceful degradation when things go wrong. Observability gives you visibility into your application's behavior, helping you identify issues before they impact users and debug problems quickly when they occur.
It covers error boundaries, Supabase and PostgREST error mapping, structured logging, request correlation, performance monitoring and alerting, grounded in the Next.js error handling documentation and the Supabase docs.
Error Handling Fundamentals#
Structured Error Types#
Define consistent error types and handling patterns across your application.
// lib/errors/types.ts
export enum ErrorCode {
// Authentication errors
UNAUTHORIZED = 'UNAUTHORIZED',
FORBIDDEN = 'FORBIDDEN',
SESSION_EXPIRED = 'SESSION_EXPIRED',
// Validation errors
VALIDATION_ERROR = 'VALIDATION_ERROR',
INVALID_INPUT = 'INVALID_INPUT',
// Database errors
DATABASE_ERROR = 'DATABASE_ERROR',
RECORD_NOT_FOUND = 'RECORD_NOT_FOUND',
CONSTRAINT_VIOLATION = 'CONSTRAINT_VIOLATION',
// Network errors
NETWORK_ERROR = 'NETWORK_ERROR',
TIMEOUT_ERROR = 'TIMEOUT_ERROR',
// Business logic errors
BUSINESS_RULE_VIOLATION = 'BUSINESS_RULE_VIOLATION',
INSUFFICIENT_PERMISSIONS = 'INSUFFICIENT_PERMISSIONS',
// System errors
INTERNAL_ERROR = 'INTERNAL_ERROR',
SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE'
}
export class AppError extends Error {
public readonly code: ErrorCode
public readonly statusCode: number
public readonly isOperational: boolean
public readonly context?: Record<string, any>
public readonly timestamp: string
public readonly requestId?: string
constructor(
message: string,
code: ErrorCode,
statusCode = 500,
isOperational = true,
context?: Record<string, any>,
requestId?: string
) {
super(message)
this.name = this.constructor.name
this.code = code
this.statusCode = statusCode
this.isOperational = isOperational
this.context = context
this.timestamp = new Date().toISOString()
this.requestId = requestId
Error.captureStackTrace(this, this.constructor)
}
toJSON() {
return {
name: this.name,
message: this.message,
code: this.code,
statusCode: this.statusCode,
isOperational: this.isOperational,
context: this.context,
timestamp: this.timestamp,
requestId: this.requestId,
stack: this.stack
}
}
}
// Specific error classes
export class ValidationError extends AppError {
constructor(message: string, context?: Record<string, any>, requestId?: string) {
super(message, ErrorCode.VALIDATION_ERROR, 400, true, context, requestId)
}
}
export class AuthenticationError extends AppError {
constructor(message: string, context?: Record<string, any>, requestId?: string) {
super(message, ErrorCode.UNAUTHORIZED, 401, true, context, requestId)
}
}
export class DatabaseError extends AppError {
constructor(message: string, context?: Record<string, any>, requestId?: string) {
super(message, ErrorCode.DATABASE_ERROR, 500, true, context, requestId)
}
}
export class BusinessRuleError extends AppError {
constructor(message: string, context?: Record<string, any>, requestId?: string) {
super(message, ErrorCode.BUSINESS_RULE_VIOLATION, 422, true, context, requestId)
}
}Supabase Error Handling#
Implement comprehensive error handling for Supabase operations.
// lib/errors/supabase-handler.ts
import { PostgrestError, isAuthApiError } from '@supabase/supabase-js'
import { AppError, ErrorCode, DatabaseError, AuthenticationError, ValidationError } from './types'
export class SupabaseErrorHandler {
static handleError(error: any, context?: Record<string, any>, requestId?: string): AppError {
// Check Auth errors first: an AuthApiError also carries string `code`
// and `message` properties, so the PostgREST shape check below would
// otherwise swallow it.
if (isAuthApiError(error)) {
return this.handleAuthError(error, context, requestId)
}
// Handle PostgREST / PostgreSQL errors from the data API
if (this.isPostgrestError(error)) {
return this.handlePostgrestError(error, context, requestId)
}
// Handle network errors
if (this.isNetworkError(error)) {
return new AppError(
'Network connection failed',
ErrorCode.NETWORK_ERROR,
503,
true,
{ ...context, originalError: error.message },
requestId
)
}
// Handle unknown errors
return new AppError(
'An unexpected error occurred',
ErrorCode.INTERNAL_ERROR,
500,
false,
{ ...context, originalError: error.message },
requestId
)
}
private static isPostgrestError(error: any): error is PostgrestError {
return error && typeof error.code === 'string' && typeof error.message === 'string'
}
private static isNetworkError(error: any): boolean {
return error && (
error.code === 'NETWORK_ERROR' ||
error.message?.includes('fetch') ||
error.message?.includes('network')
)
}
// `23505`, `23503`, `23514` and `42501` are real PostgreSQL error codes —
// see the full list in the PostgreSQL error codes appendix
// (https://www.postgresql.org/docs/current/errcodes-appendix.html).
// `PGRST116` is a PostgREST-specific code (Supabase's data API runs on
// PostgREST) — see the PostgREST error reference
// (https://docs.postgrest.org/en/stable/references/errors.html).
private static handlePostgrestError(
error: PostgrestError,
context?: Record<string, any>,
requestId?: string
): AppError {
const errorContext = {
...context,
postgrestCode: error.code,
details: error.details,
hint: error.hint
}
// Map PostgreSQL/PostgREST error codes to application errors
switch (error.code) {
case '23505': // unique_violation
return new ValidationError(
'A record with this information already exists',
errorContext,
requestId
)
case '23503': // foreign_key_violation
return new ValidationError(
'Referenced record does not exist',
errorContext,
requestId
)
case '23514': // check_violation
return new ValidationError(
'Data validation failed',
errorContext,
requestId
)
case '42501': // insufficient_privilege (RLS or GRANT): PostgREST answers 403 if authenticated, else 401
return new AppError(
'Insufficient permissions to perform this operation',
ErrorCode.FORBIDDEN,
403,
true,
errorContext,
requestId
)
case 'PGRST116': // .single() got zero rows or more than one (HTTP 406)
return new AppError(
'Record not found',
ErrorCode.RECORD_NOT_FOUND,
404,
true,
errorContext,
requestId
)
default:
return new DatabaseError(
error.message || 'Database operation failed',
errorContext,
requestId
)
}
}
// AuthApiError always carries a documented `code` (e.g. `session_expired`,
// `session_not_found`, `refresh_token_not_found`). The Auth error codes
// reference (https://supabase.com/docs/guides/auth/debugging/error-codes)
// says to branch on `error.code`, never on message text.
private static handleAuthError(
error: any,
context?: Record<string, any>,
requestId?: string
): AppError {
const sessionGone = ['session_expired', 'session_not_found', 'refresh_token_not_found']
if (sessionGone.includes(error.code)) {
return new AppError(
'Session expired. Please log in again.',
ErrorCode.SESSION_EXPIRED,
401,
true,
context,
requestId
)
}
return new AuthenticationError(
error.message || 'Authentication failed',
{ ...context, authCode: error.code, status: error.status },
requestId
)
}
}
// Usage in data access layer
export async function safeSupabaseOperation<T>(
operation: () => Promise<{ data: T | null, error: any }>,
context?: Record<string, any>,
requestId?: string
): Promise<T> {
try {
const { data, error } = await operation()
if (error) {
throw SupabaseErrorHandler.handleError(error, context, requestId)
}
if (data === null) {
throw new AppError(
'No data returned from operation',
ErrorCode.RECORD_NOT_FOUND,
404,
true,
context,
requestId
)
}
return data
} catch (error) {
if (error instanceof AppError) {
throw error
}
throw SupabaseErrorHandler.handleError(error, context, requestId)
}
}Error Boundaries and Recovery#
Implement React error boundaries for graceful error handling in the UI. Next.js App Router has first-class conventions for this — error.tsx for route-level boundaries and global-error.tsx for the root layout — documented at the error.js file convention reference.
// components/ErrorBoundary.tsx
'use client'
import React, { Component, ErrorInfo, ReactNode } from 'react'
import { AppError } from '@/lib/errors/types'
import { Logger } from '@/lib/logging/logger'
interface Props {
children: ReactNode
fallback?: ReactNode
onError?: (error: Error, errorInfo: ErrorInfo) => void
}
interface State {
hasError: boolean
error?: Error
errorId?: string
}
export class ErrorBoundary extends Component<Props, State> {
private logger = new Logger('ErrorBoundary')
constructor(props: Props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error): State {
return {
hasError: true,
error,
errorId: Math.random().toString(36).substring(2, 15)
}
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
const errorId = this.state.errorId!
// Log the error
this.logger.error('React Error Boundary caught an error', {
error: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
errorId,
timestamp: new Date().toISOString()
})
// Call custom error handler
this.props.onError?.(error, errorInfo)
// Report to error monitoring service
this.reportError(error, errorInfo, errorId)
}
private async reportError(error: Error, errorInfo: ErrorInfo, errorId: string) {
try {
await fetch('/api/errors/report', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
errorId,
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString()
})
})
} catch (reportingError) {
console.error('Failed to report error:', reportingError)
}
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback
}
return (
<div className="error-boundary">
<div className="error-content">
<h2>Something went wrong</h2>
<p>We apologize for the inconvenience. The error has been reported and we're working to fix it.</p>
<details className="error-details">
<summary>Error Details (ID: {this.state.errorId})</summary>
<pre>{this.state.error?.message}</pre>
</details>
<button
onClick={() => window.location.reload()}
className="retry-button"
>
Reload Page
</button>
</div>
</div>
)
}
return this.props.children
}
}
// Global error boundary for the app
// app/global-error.tsx
'use client'
import { ErrorBoundary } from '@/components/ErrorBoundary'
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<html>
<body>
<div className="global-error">
<h1>Application Error</h1>
<p>A critical error occurred. Please try refreshing the page.</p>
{error.digest && (
<p className="error-digest">Error ID: {error.digest}</p>
)}
<button onClick={reset}>Try Again</button>
</div>
</body>
</html>
)
}
// Route-level error handling
// app/posts/error.tsx
'use client'
export default function PostsError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="posts-error">
<h2>Failed to load posts</h2>
<p>There was an error loading the posts. Please try again.</p>
<button onClick={reset}>Retry</button>
</div>
)
}On Next.js 16.3 and later, the error.js reference documents a retry prop (stable since v16.3.0) that re-fetches and re-renders the segment, and recommends it over reset, which only clears the error state without re-fetching. The same reference types error as unknown, because a value thrown in a Client Component may not be an Error: narrow it with instanceof Error before reading message or digest. error.tsx also does not catch errors thrown by the layout.tsx of its own segment; those go to the parent boundary, or to global-error.tsx for the root layout.
Structured Logging System#
Logger Implementation#
Implement a comprehensive logging system with structured output and multiple transports.
// lib/logging/logger.ts
export enum LogLevel {
ERROR = 0,
WARN = 1,
INFO = 2,
DEBUG = 3
}
export interface LogEntry {
level: LogLevel
message: string
timestamp: string
context?: Record<string, any>
requestId?: string
userId?: string
organizationId?: string
source: string
environment: string
}
export interface LogTransport {
log(entry: LogEntry): Promise<void>
}
export class Logger {
private transports: LogTransport[] = []
private minLevel: LogLevel
private source: string
constructor(source: string, minLevel = LogLevel.INFO) {
this.source = source
this.minLevel = minLevel
// Add default transports
if (typeof window === 'undefined') {
// Server-side transports
this.transports.push(new ConsoleTransport())
if (process.env.NODE_ENV === 'production') {
this.transports.push(new FileTransport())
this.transports.push(new DatabaseTransport())
}
} else {
// Client-side transports
this.transports.push(new ConsoleTransport())
this.transports.push(new RemoteTransport())
}
}
private async log(level: LogLevel, message: string, context?: Record<string, any>) {
if (level > this.minLevel) return
const entry: LogEntry = {
level,
message,
timestamp: new Date().toISOString(),
context,
requestId: this.getRequestId(),
userId: this.getUserId(),
organizationId: this.getOrganizationId(),
source: this.source,
environment: process.env.NODE_ENV || 'development'
}
// Log to all transports
await Promise.allSettled(
this.transports.map(transport => transport.log(entry))
)
}
error(message: string, context?: Record<string, any>) {
return this.log(LogLevel.ERROR, message, context)
}
warn(message: string, context?: Record<string, any>) {
return this.log(LogLevel.WARN, message, context)
}
info(message: string, context?: Record<string, any>) {
return this.log(LogLevel.INFO, message, context)
}
debug(message: string, context?: Record<string, any>) {
return this.log(LogLevel.DEBUG, message, context)
}
private getRequestId(): string | undefined {
// Get from async local storage or headers
return typeof window === 'undefined'
? process.env.REQUEST_ID
: undefined
}
private getUserId(): string | undefined {
// Get from session or context
return typeof window !== 'undefined'
? localStorage.getItem('userId') || undefined
: undefined
}
private getOrganizationId(): string | undefined {
// Get from session or context
return typeof window !== 'undefined'
? localStorage.getItem('organizationId') || undefined
: undefined
}
}
// Console transport
class ConsoleTransport implements LogTransport {
async log(entry: LogEntry): Promise<void> {
const levelNames = ['ERROR', 'WARN', 'INFO', 'DEBUG']
const levelName = levelNames[entry.level]
const logMessage = `[${entry.timestamp}] ${levelName} [${entry.source}] ${entry.message}`
switch (entry.level) {
case LogLevel.ERROR:
console.error(logMessage, entry.context)
break
case LogLevel.WARN:
console.warn(logMessage, entry.context)
break
case LogLevel.INFO:
console.info(logMessage, entry.context)
break
case LogLevel.DEBUG:
console.debug(logMessage, entry.context)
break
}
}
}
// File transport (server-side)
class FileTransport implements LogTransport {
async log(entry: LogEntry): Promise<void> {
if (typeof window !== 'undefined') return
const fs = await import('fs/promises')
const path = await import('path')
const logDir = path.join(process.cwd(), 'logs')
const logFile = path.join(logDir, `app-${new Date().toISOString().split('T')[0]}.log`)
try {
await fs.mkdir(logDir, { recursive: true })
await fs.appendFile(logFile, JSON.stringify(entry) + '\n')
} catch (error) {
console.error('Failed to write to log file:', error)
}
}
}
// Database transport (server-side)
class DatabaseTransport implements LogTransport {
async log(entry: LogEntry): Promise<void> {
if (typeof window !== 'undefined') return
if (entry.level > LogLevel.WARN) return // Only log warnings and errors to DB
try {
const { createClient } = await import('@/lib/supabase/server')
const supabase = createClient()
await supabase.from('application_logs').insert({
level: entry.level,
message: entry.message,
context: entry.context,
request_id: entry.requestId,
user_id: entry.userId,
organization_id: entry.organizationId,
source: entry.source,
environment: entry.environment,
timestamp: entry.timestamp
})
} catch (error) {
console.error('Failed to write to database log:', error)
}
}
}
// Remote transport (client-side)
class RemoteTransport implements LogTransport {
private buffer: LogEntry[] = []
private flushInterval: NodeJS.Timeout
constructor() {
// Flush logs every 5 seconds
this.flushInterval = setInterval(() => this.flush(), 5000)
// Flush on page unload
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => this.flush())
}
}
async log(entry: LogEntry): Promise<void> {
this.buffer.push(entry)
// Flush immediately for errors
if (entry.level === LogLevel.ERROR) {
await this.flush()
}
}
private async flush(): Promise<void> {
if (this.buffer.length === 0) return
const logs = [...this.buffer]
this.buffer = []
try {
await fetch('/api/logs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ logs })
})
} catch (error) {
console.error('Failed to send logs to server:', error)
// Put logs back in buffer for retry
this.buffer.unshift(...logs)
}
}
}Request Correlation and Tracing#
Implement request correlation for tracking requests across services.
// lib/tracing/correlation.ts
import { AsyncLocalStorage } from 'async_hooks'
interface RequestContext {
requestId: string
userId?: string
organizationId?: string
startTime: number
path: string
method: string
}
export class CorrelationManager {
private static storage = new AsyncLocalStorage<RequestContext>()
static run<T>(context: RequestContext, callback: () => T): T {
return this.storage.run(context, callback)
}
static getContext(): RequestContext | undefined {
return this.storage.getStore()
}
static getRequestId(): string | undefined {
return this.getContext()?.requestId
}
static getUserId(): string | undefined {
return this.getContext()?.userId
}
static getOrganizationId(): string | undefined {
return this.getContext()?.organizationId
}
}
// proxy.ts (middleware.ts exporting `middleware` before Next.js 16)
import { NextRequest, NextResponse } from 'next/server'
export function proxy(request: NextRequest) {
const requestId = request.headers.get('x-request-id') ?? crypto.randomUUID()
// Forward the ID upstream as a REQUEST header so route handlers and
// Server Components can read it with request.headers / headers()
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-request-id', requestId)
const response = NextResponse.next({ request: { headers: requestHeaders } })
// Also echo it on the response so clients can quote it in bug reports
response.headers.set('x-request-id', requestId)
return response
}
// Open the AsyncLocalStorage scope inside the route handler, not in Proxy:
// CorrelationManager.run({ requestId: request.headers.get('x-request-id')!, ... }, () => handle())
export const config = {
matcher: [
'/api/:path*',
'/((?!_next/static|_next/image|favicon.ico).*)'
]
}Two details matter here. Proxy (middleware) runs separately from your render code, and the Next.js proxy.js reference warns against relying on shared modules or globals to pass data from it: an AsyncLocalStorage scope opened in Proxy does not reach the route handler, so the context has to travel as a header. The header must also be set through the request option of NextResponse.next(); a header set only on response.headers goes back to the client and never reaches request.headers in the handler. Next.js 16 renamed middleware.ts to proxy.ts; on earlier versions the same code works in middleware.ts with the function exported as middleware.
Performance Monitoring#
Core Web Vitals Tracking#
Monitor and optimize Core Web Vitals for better user experience and SEO. Next.js App Router ships a built-in useReportWebVitals hook for exactly this — see the useReportWebVitals reference — instead of hand-rolling a generic wrapper around the plain web-vitals package (whose API also changed: getCLS/getFID/etc. were removed in web-vitals v3+ in favor of onCLS/onINP/etc., and INP replaced FID as a Core Web Vital in 2024):
// app/_components/web-vitals-reporter.tsx
'use client'
import { useReportWebVitals } from 'next/web-vitals'
const THRESHOLDS: Record<string, { good: number; poor: number }> = {
CLS: { good: 0.1, poor: 0.25 },
INP: { good: 200, poor: 500 },
FCP: { good: 1800, poor: 3000 },
LCP: { good: 2500, poor: 4000 },
TTFB: { good: 800, poor: 1800 }
}
function getRating(name: string, value: number): 'good' | 'needs-improvement' | 'poor' {
const threshold = THRESHOLDS[name]
if (!threshold) return 'good'
if (value <= threshold.good) return 'good'
if (value <= threshold.poor) return 'needs-improvement'
return 'poor'
}
export function WebVitalsReporter() {
useReportWebVitals((metric) => {
fetch('/api/analytics/web-vitals', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: metric.name,
value: metric.value,
rating: getRating(metric.name, metric.value),
id: metric.id,
url: window.location.href,
timestamp: Date.now()
})
}).catch((error) => console.error('Failed to report web vital:', error))
})
return null
}Mount <WebVitalsReporter /> once, near the root of app/layout.tsx.
API Performance Monitoring#
Monitor API route performance and database query times.
// lib/monitoring/api-performance.ts
export class APIPerformanceMonitor {
private static instance: APIPerformanceMonitor
private metrics: Map<string, PerformanceMetric[]> = new Map()
static getInstance(): APIPerformanceMonitor {
if (!this.instance) {
this.instance = new APIPerformanceMonitor()
}
return this.instance
}
async trackAPICall<T>(
operation: string,
fn: () => Promise<T>,
context?: Record<string, any>
): Promise<T> {
const startTime = Date.now()
const requestId = context?.requestId || crypto.randomUUID()
try {
const result = await fn()
const duration = Date.now() - startTime
await this.recordMetric({
operation,
duration,
status: 'success',
requestId,
timestamp: new Date().toISOString(),
context
})
return result
} catch (error) {
const duration = Date.now() - startTime
await this.recordMetric({
operation,
duration,
status: 'error',
error: error instanceof Error ? error.message : 'Unknown error',
requestId,
timestamp: new Date().toISOString(),
context
})
throw error
}
}
private async recordMetric(metric: PerformanceMetric) {
// Store in memory
const existing = this.metrics.get(metric.operation) || []
existing.push(metric)
this.metrics.set(metric.operation, existing.slice(-100)) // Keep last 100
// Send to monitoring service
await this.sendToMonitoring(metric)
}
private async sendToMonitoring(metric: PerformanceMetric) {
try {
if (typeof window === 'undefined') {
// Server-side: store in database
const { createClient } = await import('@/lib/supabase/server')
const supabase = createClient()
await supabase.from('api_metrics').insert({
operation: metric.operation,
duration: metric.duration,
status: metric.status,
error: metric.error,
request_id: metric.requestId,
context: metric.context,
timestamp: metric.timestamp
})
} else {
// Client-side: send to API
await fetch('/api/monitoring/metrics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(metric)
})
}
} catch (error) {
console.error('Failed to send metric to monitoring:', error)
}
}
getMetrics(operation?: string): PerformanceMetric[] {
if (operation) {
return this.metrics.get(operation) || []
}
return Array.from(this.metrics.values()).flat()
}
getAverageResponseTime(operation: string, timeWindow = 300000): number {
const cutoff = Date.now() - timeWindow
const metrics = this.getMetrics(operation)
.filter(m => new Date(m.timestamp).getTime() > cutoff)
.filter(m => m.status === 'success')
if (metrics.length === 0) return 0
const total = metrics.reduce((sum, m) => sum + m.duration, 0)
return total / metrics.length
}
getErrorRate(operation: string, timeWindow = 300000): number {
const cutoff = Date.now() - timeWindow
const metrics = this.getMetrics(operation)
.filter(m => new Date(m.timestamp).getTime() > cutoff)
if (metrics.length === 0) return 0
const errors = metrics.filter(m => m.status === 'error').length
return errors / metrics.length
}
}
interface PerformanceMetric {
operation: string
duration: number
status: 'success' | 'error'
error?: string
requestId: string
timestamp: string
context?: Record<string, any>
}
// Decorator for automatic API monitoring
export function MonitorPerformance(operation?: string) {
return function (target: any, propertyName: string, descriptor: PropertyDescriptor) {
const method = descriptor.value
const operationName = operation || `${target.constructor.name}.${propertyName}`
descriptor.value = async function (...args: any[]) {
const monitor = APIPerformanceMonitor.getInstance()
return monitor.trackAPICall(operationName, () => method.apply(this, args))
}
return descriptor
}
}
// Usage in API routes
// app/api/posts/route.ts
import { APIPerformanceMonitor } from '@/lib/monitoring/api-performance'
import { createClient } from '@/lib/supabase/server'
export async function GET(request: Request) {
const monitor = APIPerformanceMonitor.getInstance()
return monitor.trackAPICall('posts.list', async () => {
const supabase = createClient()
const { data, error } = await supabase
.from('posts')
.select('*')
.eq('status', 'published')
.order('created_at', { ascending: false })
if (error) throw error
return Response.json(data)
}, {
requestId: request.headers.get('x-request-id') || undefined
})
}Database Query Monitoring#
Monitor Supabase query performance and identify slow queries. Wrap each query at the call site rather than proxying the client with object spread: spreading a query builder copies only its own enumerable properties, not its prototype (MDN, spread syntax), so chained methods such as .eq() or .order() would be lost.
// lib/monitoring/database-monitor.ts
export class DatabaseMonitor {
private queryMetrics: Map<string, QueryMetric[]> = new Map()
async trackQuery<T>(
queryName: string,
query: () => Promise<T>,
context?: Record<string, any>
): Promise<T> {
const startTime = performance.now()
const requestId = context?.requestId || crypto.randomUUID()
try {
const result = await query()
const duration = performance.now() - startTime
await this.recordQueryMetric({
queryName,
duration,
status: 'success',
requestId,
timestamp: new Date().toISOString(),
context
})
// Log slow queries
if (duration > 1000) { // Queries taking more than 1 second
console.warn(`Slow query detected: ${queryName} took ${duration.toFixed(2)}ms`, {
context,
requestId
})
}
return result
} catch (error) {
const duration = performance.now() - startTime
await this.recordQueryMetric({
queryName,
duration,
status: 'error',
error: error instanceof Error ? error.message : 'Unknown error',
requestId,
timestamp: new Date().toISOString(),
context
})
throw error
}
}
private async recordQueryMetric(metric: QueryMetric) {
// Store in memory for immediate access
const existing = this.queryMetrics.get(metric.queryName) || []
existing.push(metric)
this.queryMetrics.set(metric.queryName, existing.slice(-50)) // Keep last 50
// Store in database for long-term analysis
if (typeof window === 'undefined') {
try {
const { createClient } = await import('@/lib/supabase/server')
const supabase = createClient()
await supabase.from('query_metrics').insert({
query_name: metric.queryName,
duration: metric.duration,
status: metric.status,
error: metric.error,
request_id: metric.requestId,
context: metric.context,
timestamp: metric.timestamp
})
} catch (error) {
console.error('Failed to store query metric:', error)
}
}
}
getSlowQueries(thresholdMs = 1000): QueryMetric[] {
return Array.from(this.queryMetrics.values())
.flat()
.filter(metric => metric.duration > thresholdMs)
.sort((a, b) => b.duration - a.duration)
}
getQueryStats(queryName: string): {
averageDuration: number
errorRate: number
totalExecutions: number
} {
const metrics = this.queryMetrics.get(queryName) || []
if (metrics.length === 0) {
return { averageDuration: 0, errorRate: 0, totalExecutions: 0 }
}
const successfulQueries = metrics.filter(m => m.status === 'success')
const averageDuration = successfulQueries.length > 0
? successfulQueries.reduce((sum, m) => sum + m.duration, 0) / successfulQueries.length
: 0
const errorRate = metrics.filter(m => m.status === 'error').length / metrics.length
return {
averageDuration,
errorRate,
totalExecutions: metrics.length
}
}
}
interface QueryMetric {
queryName: string
duration: number
status: 'success' | 'error'
error?: string
requestId: string
timestamp: string
context?: Record<string, any>
}
export const databaseMonitor = new DatabaseMonitor()
// Usage: Supabase query builders are thenables, so pass the builder itself
// const { data, error } = await databaseMonitor.trackQuery(
// 'posts.select',
// () => supabase.from('posts').select('*').eq('status', 'published')
// )Alerting and Incident Response#
Alert Configuration#
Set up intelligent alerting for critical issues and performance degradation.
// lib/alerting/alert-manager.ts
export enum AlertSeverity {
LOW = 'low',
MEDIUM = 'medium',
HIGH = 'high',
CRITICAL = 'critical'
}
export interface AlertRule {
id: string
name: string
condition: AlertCondition
severity: AlertSeverity
channels: AlertChannel[]
cooldownMinutes: number
enabled: boolean
}
export interface AlertCondition {
metric: string
operator: 'gt' | 'lt' | 'eq' | 'gte' | 'lte'
threshold: number
timeWindow: number // minutes
evaluationInterval: number // minutes
}
export interface AlertChannel {
type: 'email' | 'slack' | 'webhook'
config: Record<string, any>
}
export class AlertManager {
private rules: Map<string, AlertRule> = new Map()
private lastAlerts: Map<string, number> = new Map()
constructor() {
this.loadDefaultRules()
this.startEvaluationLoop()
}
private loadDefaultRules() {
const defaultRules: AlertRule[] = [
{
id: 'high-error-rate',
name: 'High Error Rate',
condition: {
metric: 'error_rate',
operator: 'gt',
threshold: 0.05, // 5%
timeWindow: 5,
evaluationInterval: 1
},
severity: AlertSeverity.HIGH,
channels: [
{ type: 'email', config: { recipients: ['admin@example.com'] } },
{ type: 'slack', config: { webhook: process.env.SLACK_WEBHOOK_URL } }
],
cooldownMinutes: 15,
enabled: true
},
{
id: 'slow-response-time',
name: 'Slow Response Time',
condition: {
metric: 'avg_response_time',
operator: 'gt',
threshold: 2000, // 2 seconds
timeWindow: 10,
evaluationInterval: 2
},
severity: AlertSeverity.MEDIUM,
channels: [
{ type: 'slack', config: { webhook: process.env.SLACK_WEBHOOK_URL } }
],
cooldownMinutes: 30,
enabled: true
},
{
id: 'database-connection-errors',
name: 'Database Connection Errors',
condition: {
metric: 'db_connection_errors',
operator: 'gt',
threshold: 10,
timeWindow: 5,
evaluationInterval: 1
},
severity: AlertSeverity.CRITICAL,
channels: [
{ type: 'email', config: { recipients: ['admin@example.com'] } },
{ type: 'slack', config: { webhook: process.env.SLACK_WEBHOOK_URL } }
],
cooldownMinutes: 5,
enabled: true
}
]
defaultRules.forEach(rule => this.rules.set(rule.id, rule))
}
private startEvaluationLoop() {
setInterval(() => {
this.evaluateRules()
}, 60000) // Evaluate every minute
}
private async evaluateRules() {
for (const rule of this.rules.values()) {
if (!rule.enabled) continue
try {
const shouldAlert = await this.evaluateRule(rule)
if (shouldAlert && this.canSendAlert(rule.id, rule.cooldownMinutes)) {
await this.sendAlert(rule)
this.lastAlerts.set(rule.id, Date.now())
}
} catch (error) {
console.error(`Failed to evaluate rule ${rule.id}:`, error)
}
}
}
private async evaluateRule(rule: AlertRule): Promise<boolean> {
const metric = await this.getMetricValue(rule.condition)
switch (rule.condition.operator) {
case 'gt': return metric > rule.condition.threshold
case 'lt': return metric < rule.condition.threshold
case 'eq': return metric === rule.condition.threshold
case 'gte': return metric >= rule.condition.threshold
case 'lte': return metric <= rule.condition.threshold
default: return false
}
}
private async getMetricValue(condition: AlertCondition): Promise<number> {
const endTime = Date.now()
const startTime = endTime - (condition.timeWindow * 60 * 1000)
// This would integrate with your metrics storage system
switch (condition.metric) {
case 'error_rate':
return this.calculateErrorRate(startTime, endTime)
case 'avg_response_time':
return this.calculateAverageResponseTime(startTime, endTime)
case 'db_connection_errors':
return this.countDatabaseErrors(startTime, endTime)
default:
return 0
}
}
// `calculate_error_rate` and `calculate_avg_response_time` are not Supabase
// or PostgreSQL built-ins — as with the RPC functions in the database design
// guide, `supabase.rpc()` can only call a function you actually created.
// These two compute the same aggregates directly from application_logs,
// the same table countDatabaseErrors below already queries.
private async calculateErrorRate(startTime: number, endTime: number): Promise<number> {
const { createClient } = await import('@/lib/supabase/server')
const supabase = createClient()
const [{ count: total }, { count: errors }] = await Promise.all([
supabase
.from('application_logs')
.select('*', { count: 'exact', head: true })
.gte('timestamp', new Date(startTime).toISOString())
.lte('timestamp', new Date(endTime).toISOString()),
supabase
.from('application_logs')
.select('*', { count: 'exact', head: true })
.eq('level', 0) // ERROR level
.gte('timestamp', new Date(startTime).toISOString())
.lte('timestamp', new Date(endTime).toISOString()),
])
return total ? (errors || 0) / total : 0
}
private async calculateAverageResponseTime(startTime: number, endTime: number): Promise<number> {
const { createClient } = await import('@/lib/supabase/server')
const supabase = createClient()
// Assumes a duration_ms column on application_logs — add it if your
// logging middleware doesn't already record request duration.
const { data } = await supabase
.from('application_logs')
.select('duration_ms')
.gte('timestamp', new Date(startTime).toISOString())
.lte('timestamp', new Date(endTime).toISOString())
if (!data?.length) return 0
return data.reduce((sum, row) => sum + (row.duration_ms || 0), 0) / data.length
}
private async countDatabaseErrors(startTime: number, endTime: number): Promise<number> {
const { createClient } = await import('@/lib/supabase/server')
const supabase = createClient()
const { count } = await supabase
.from('application_logs')
.select('*', { count: 'exact', head: true })
.eq('level', 0) // ERROR level
.ilike('message', '%database%')
.gte('timestamp', new Date(startTime).toISOString())
.lte('timestamp', new Date(endTime).toISOString())
return count || 0
}
private canSendAlert(ruleId: string, cooldownMinutes: number): boolean {
const lastAlert = this.lastAlerts.get(ruleId)
if (!lastAlert) return true
const cooldownMs = cooldownMinutes * 60 * 1000
return Date.now() - lastAlert > cooldownMs
}
private async sendAlert(rule: AlertRule) {
const alertData = {
rule: rule.name,
severity: rule.severity,
timestamp: new Date().toISOString(),
condition: rule.condition
}
for (const channel of rule.channels) {
try {
await this.sendToChannel(channel, alertData)
} catch (error) {
console.error(`Failed to send alert to ${channel.type}:`, error)
}
}
}
private async sendToChannel(channel: AlertChannel, alertData: any) {
switch (channel.type) {
case 'email':
await this.sendEmailAlert(channel.config, alertData)
break
case 'slack':
await this.sendSlackAlert(channel.config, alertData)
break
case 'webhook':
await this.sendWebhookAlert(channel.config, alertData)
break
}
}
private async sendEmailAlert(config: any, alertData: any) {
// Implement email sending logic
console.log('Sending email alert:', alertData)
}
private async sendSlackAlert(config: any, alertData: any) {
if (!config.webhook) return
const message = {
text: `🚨 Alert: ${alertData.rule}`,
attachments: [
{
color: this.getSeverityColor(alertData.severity),
fields: [
{ title: 'Severity', value: alertData.severity, short: true },
{ title: 'Time', value: alertData.timestamp, short: true },
{ title: 'Condition', value: JSON.stringify(alertData.condition), short: false }
]
}
]
}
await fetch(config.webhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(message)
})
}
private async sendWebhookAlert(config: any, alertData: any) {
await fetch(config.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(alertData)
})
}
private getSeverityColor(severity: AlertSeverity): string {
switch (severity) {
case AlertSeverity.LOW: return 'good'
case AlertSeverity.MEDIUM: return 'warning'
case AlertSeverity.HIGH: return 'danger'
case AlertSeverity.CRITICAL: return '#ff0000'
default: return 'good'
}
}
}
export const alertManager = new AlertManager()Start with route-level error boundaries and structured logging, then add request correlation, query timing and alerting as traffic grows.
Further reading: Stripe Webhook Signature Verification Failed in Next.js, Next.js + Supabase: 10 Production Mistakes + Fixes (2026).
Frequently Asked Questions
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
One email a month — the latest explained errors
What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.
Related Guides
Next.js & Supabase Stripe Subscriptions: SaaS Guide
SaaS billing with Stripe subscriptions in Next.js + Supabase: webhooks, user syncing, and gated content for production.
Next.js Server Actions with Supabase: Complete Guide
Complete guide to Next.js Server Actions with Supabase. Learn validation, error handling, optimistic updates, and production patterns for type-safe forms.
Type Safety Guide for Next.js + Supabase in TypeScript
Type safety in Next.js with Supabase: database type generation, Zod validation, type-safe queries, and production TypeScript patterns.

