Developer Guide

Error Handling and Observability for Next.js + Supabase

Comprehensive guide to error handling, logging, monitoring, and observability for production Next.js and Supabase applications.

Mahdi Ben RhoumaMahdi Ben Rhouma
Updated
30 min read

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.

typescript
// 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.

typescript
// 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.

typescript
// 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.

typescript
// 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.

typescript
// 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):

typescript
// 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.

typescript
// 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.

typescript
// 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.

typescript
// 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

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

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.