← Back to blog

How to catch and handle errors to report logs on server side

nextjsreactjavascriptwebdevfrontend

How to catch and handle errors to report logs on server side

In the fast-evolving landscape of modern web development, Next.js stands as a titan, offering unparalleled capabilities for building performant, full-stack applications. With the App Router, Server Components, and Server Actions now firmly established as the backbone of Next.js applications in 2026, our server-side logic is more robust and pervasive than ever. But with great power comes great responsibility, especially when it comes to reliability.

One of the most critical, yet often overlooked, aspects of any production-ready application is robust error handling and logging. While client-side errors might disrupt a single user’s experience, server-side errors can cascade, leading to data inconsistencies, service outages, security vulnerabilities, and ultimately, a loss of trust. Simply letting your server crash or sending generic “something went wrong” messages is no longer acceptable.

This post will dive deep into how to effectively catch and handle errors on the server side of your Next.js application, ensuring you can report meaningful logs to keep your systems stable and your users happy. We’ll cover various server-side contexts, from Server Actions to API Routes and even the Edge Runtime, equipped with practical code examples and best practices for 2026.

The Criticality of Server-Side Error Logging

Unlike client-side errors, which often manifest in the browser’s console and might be caught by client-side error boundaries (like Next.js’s error.tsx), server-side errors are silent killers. They can occur during database interactions, API calls to external services, complex business logic computations, or even environment misconfigurations. Without proper logging, these errors can:

  • Go undetected: You might not even know there’s a problem until users complain or data becomes corrupted.
  • Be difficult to diagnose: A lack of contextual information makes debugging a nightmare.
  • Lead to cascading failures: A single unhandled error can take down entire processes or microservices.
  • Expose sensitive information: If error messages are not carefully crafted, they can leak internal system details to the client.

Our goal is to implement a proactive strategy: catch errors at their source, log comprehensive details to a centralized system, and provide graceful fallbacks to the user without revealing internal server state.

Establishing a Centralized Logging Utility

Before we dive into specific Next.js contexts, let’s create a simple, yet effective, logging utility. In a real-world scenario, you’d integrate with services like Sentry, Logflare, Datadog, or use Node.js-specific libraries like Pino or Winston. For demonstration purposes, we’ll build a basic one that can be easily extended.

This logger.ts will allow us to abstract away the logging mechanism and send structured logs, potentially to an internal API route that then forwards them to a persistent storage or a third-party service.

// lib/logger.ts
import pino from 'pino'; // A popular, fast Node.js logger
import { headers } from 'next/headers'; // To get request context

type LogLevel = 'info' | 'warn' | 'error' | 'debug';

interface LogData {
  [key: string]: any;
}

// Initialize Pino logger for server environments.
// For Edge, you might need a different setup or rely on direct HTTP calls.
const pinoLogger = pino({
  name: 'nextjs-app-server',
  level: process.env.NODE_ENV === 'development' ? 'debug' : 'info',
  transport: {
    target: 'pino-pretty', // For development console readability
    options: {
      colorize: true,
      translateTime: 'SYS:standard',
      ignore: 'pid,hostname',
    },
  },
});

async function sendLog(level: LogLevel, message: string, error?: any, data?: LogData) {
  const isProduction = process.env.NODE_ENV === 'production';

  // Extract a unique request ID if available (e.g., from Vercel's x-vercel-id or custom middleware)
  let requestId: string | undefined;
  try {
    // This will only work in Server Components/Actions/API Routes, not client-side contexts
    requestId = headers().get('x-vercel-id') || headers().get('x-request-id') || 'unknown';
  } catch (e) {
    // headers() can only be called in a Server Context.
    // If called from client or other contexts, it will throw.
    requestId = 'client-context-or-unavailable';
  }

  const logPayload = {
    level,
    message,
    timestamp: new Date().toISOString(),
    requestId,
    error: error ? {
      name: error.name,
      message: error.message,
      stack: error.stack,
      digest: (error as any).digest // Next.js specific error digest
    } : undefined,
    data: {
      ...data,
      env: process.env.NEXT_PUBLIC_VERCEL_ENV || process.env.NODE_ENV,
      // Add other relevant context like user ID (anonymized), path, etc.
    }
  };

  // Log to Pino in dev/prod directly
  if (level === 'error') {
    pinoLogger.error(logPayload.error, logPayload.message, { ...logPayload.data, requestId: logPayload.requestId });
  } else if (level === 'warn') {
    pinoLogger.warn(logPayload.message, { ...logPayload.data, requestId: logPayload.requestId });
  } else if (level === 'info') {
    pinoLogger.info(logPayload.message, { ...logPayload.data, requestId: logPayload.requestId });
  } else if (level === 'debug') {
    pinoLogger.debug(logPayload.message, { ...logPayload.data, requestId: logPayload.requestId });
  }

  // In a real production system, you'd also forward this to an external service.
  // This could be via direct SDK integration (e.g., Sentry.captureException)
  // or by sending it to a custom API route, especially for Edge Runtime compatibility.
  if (isProduction && process.env.LOGGING_API_URL) {
    try {
      // Non-blocking fetch for performance, but ensure critical errors are awaited
      // For critical errors, consider awaiting the fetch or using a send-and-forget mechanism
      await fetch(process.env.LOGGING_API_URL, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(logPayload),
        // Important: set a timeout or abort controller for network requests
      });
    } catch (logForwardError) {
      pinoLogger.error("Failed to forward log to external system:", logForwardError);
      pinoLogger.error("Original log was:", logPayload);
    }
  }
}

export const logServerError = (message: string, error?: any, data?: LogData) => sendLog('error', message, error, data);
export const logServerInfo = (message: string, data?: LogData) => sendLog('info', message, undefined, data);
export const logServerWarn = (message: string, data?: LogData) => sendLog('warn', message, undefined, data);
export const logServerDebug = (message: string, data?: LogData) => sendLog('debug', message, undefined, data);

Note: For the pino setup, remember to install it: npm install pino pino-pretty. pino-pretty is for development only. In production, Pino typically logs JSON to stdout/stderr which is then picked up by log aggregators.

Implementing Server-Side Error Handling in Next.js

Now, let’s apply our logServerError utility across different Next.js server-side contexts.

1. Server Actions

Server Actions are a game-changer for mutating data on the server without explicit API routes. Error handling here is crucial because they directly interact with databases and other backend services.

// app/actions/user.ts
'use server';

import { logServerError } from '@/lib/logger';
import { db } from '@/lib/db'; // Assume you have a DB client initialized

interface ActionResponse {
  success: boolean;
  message: string;
  error?: string;
}

export async function updateUserProfile(formData: FormData): Promise<ActionResponse> {
  const userId = formData.get('userId')?.toString();
  const userName = formData.get('userName')?.toString();

  if (!userId || !userName) {
    logServerWarn("updateUserProfile received invalid data", { userId, userName });
    return { success: false, message: "User ID and name are required." };
  }

  try {
    // Simulate a database operation that might fail
    // throw new Error("Database connection timed out during update");

    await db.user.update({
      where: { id: userId },
      data: { name: userName },
    });

    logServerInfo(`User profile updated for ID: ${userId}`, { userId, userName });
    return { success: true, message: "Profile updated successfully!" };
  } catch (error: any) {
    logServerError(`Failed to update user profile for ID: ${userId}`, error, { userId, userName });
    // Return a generic error message to the client for security
    return { success: false, message: "Failed to update profile. Please try again later." };
  }
}

Key takeaways for Server Actions:

  • Always wrap your critical logic in try...catch.
  • Use logServerError in the catch block, providing contextual data.
  • Return a user-friendly, non-sensitive error message to the client.

2. API Routes (App Router)

API Routes continue to be essential for handling complex requests, webhooks, or when a simple Server Action isn’t enough.

// app/api/products/[id]/route.ts
import { NextResponse } from 'next/server';
import { logServerError } from '@/lib/logger';
import { db } from '@/lib/db'; // Database client

export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  const productId = params.id;

  try {
    // Simulate fetching a product from a database
    // throw new Error("Product service is temporarily down");
    const product = await db.product.findUnique({
      where: { id: productId },
    });

    if (!product) {
      logServerInfo(`Product with ID ${productId} not found`, { productId });
      return NextResponse.json(
        { message: `Product with ID ${productId} not found` },
        { status: 404 }
      );
    }

    logServerInfo(`Fetched product ID: ${productId}`, { productId });
    return NextResponse.json(product);
  } catch (error: any) {
    logServerError(`API /api/products/${productId} GET failed`, error, { url: request.url, productId });
    return NextResponse.json(
      { message: "Internal Server Error" },
      { status: 500 }
    );
  }
}

Key takeaways for API Routes:

  • Standard try...catch around the entire handler logic.
  • Return appropriate HTTP status codes (e.g., 404 for not found, 500 for server errors).
  • Avoid sending raw error messages in the NextResponse.json body for server errors.

3. Server Components (Loading Data)

Server Components fetch data directly on the server. If these data fetches fail, the error will bubble up.

// app/products/[id]/page.tsx
import { logServerError } from '@/lib/logger';
import { Suspense } from 'react';
import ErrorBoundary from '@/components/ErrorBoundary'; // Custom client-side error boundary

// Function to fetch product data (could be a DB call or internal API)
async function getProduct(id: string) {
  try {
    // Simulate a data fetching error
    // if (Math.random() > 0.5) {
    //   throw new Error("Failed to connect to product data source");
    // }
    const res = await fetch(`https://api.example.com/products/${id}`, { cache: 'no-store' }); // Example external API
    if (!res.ok) {
      const errorText = await res.text();
      throw new Error(`Failed to fetch product ${id}: ${res.status} ${errorText}`);
    }
    return res.json();
  } catch (error: any) {
    logServerError(`Failed to fetch product ${id} in Server Component`, error, { productId: id });
    // Re-throw the error so that Next.js's error.tsx or a parent Error Boundary can catch it
    throw error;
  }
}

export default async function ProductPage({ params }: { params: { id: string } }) {
  // Errors from getProduct will bubble up and be caught by the nearest error.tsx or global-error.tsx
  const product = await getProduct(params.id);

  return (
    <main>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      {/* More product details */}
    </main>
  );
}

Key takeaways for Server Components:

  • Wrap data fetching logic in try...catch.
  • Log the error, then re-throw it. This allows error.tsx (a client component) to render a fallback UI. The logging is done on the server before the error crosses the server-client boundary.

4. global-error.tsx (Catch-all for Server Components/Layouts)

global-error.tsx is a special file (a Client Component) that catches unhandled errors in Server Components and Layouts, replacing the root <html> and <body>. While useful for displaying a fallback UI, it’s a last resort for logging errors that somehow slipped past your explicit try...catch blocks. If an error reaches global-error.tsx without prior logging, it indicates a gap in your handling.

// app/global-error.tsx
'use client'; // Error boundaries must be client components

import { useEffect } from 'react';
import { logServerError } from '@/lib/logger'; // Re-use our logger, but understand context

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    // IMPORTANT: Only log here if you're certain the error wasn't logged upstream.
    // This catches errors that bubble up from Server Components to the client root.
    // For client-side rendering errors, use Sentry or similar client-side SDKs.
    logServerError("Unhandled global error caught by global-error.tsx", error, {
      component: 'GlobalError',
      digest: error.digest,
    });
  }, [error]);

  return (
    <html>
      <body>
        <h2>Something went wrong!</h2>
        <p>We're sorry for the inconvenience. Our team has been notified.</p>
        <button onClick={() => reset()}>Try again</button>
      </body>
    </html>
  );
}

Gotcha for global-error.tsx: It runs on the client. If your logServerError internally relies on headers() or pino directly, it might not work as intended in this context. A robust logger.ts should detect its environment and either make an API call to a server-side logging endpoint or use a client-side SDK (e.g., Sentry’s browser SDK). For simplicity, our provided logServerError is designed to be universal, falling back to basic console logging if headers() throws.

5. Edge Runtime Considerations

When deploying Next.js functions to the Edge Runtime (e.g., for middleware or specific API routes), there are critical distinctions:

  • Limited Node.js APIs: Edge environments are leaner. File system access (fs), process management, and some other Node.js built-in modules are typically unavailable. This affects traditional file-based loggers like Winston.
  • Performance: Edge functions are optimized for low latency. Logging should be extremely fast and ideally non-blocking.
  • HTTP-based Logging: The most common pattern is to send logs via HTTP requests to an external logging service (Sentry, Logflare, etc.) or a dedicated logging API endpoint. Your lib/logger.ts design with a fetch call is well-suited for this.
  • Asynchronous Logging: Consider sending logs without awaiting the fetch call for non-critical informational logs to avoid blocking the response, but for errors, it’s often safer to await to ensure the log is sent before the function exits.

Common Pitfalls and Best Practices

  1. Don’t console.log in Production: While console.log is great for development, it’s generally unstructured, hard to query, and might be rate-limited or stripped in production environments. Use structured logging.
  2. Context is King: Always include relevant contextual information:
    • Request ID: To trace requests across logs.
    • User ID (anonymized): To identify affected users without compromising privacy.
    • Route/Action Name: Where the error occurred.
    • Timestamp: For chronological sorting.
    • Environment: development, production, staging.
    • Request payload/params (sanitized): To understand the input that led to the error.
  3. Security: Avoid Leaking Information: Never send raw database errors, stack traces with file paths, or sensitive configuration details directly to the client. Log them internally, provide generic user messages.
  4. Performance Impact: Excessive or synchronous logging can introduce latency. Balance the need for detailed logs with performance. Asynchronous mechanisms or batching logs can help.
  5. Testing Your Error Paths: Crucially, write tests for your server-side logic that specifically trigger errors to ensure your handling and logging mechanisms work as expected.
  6. Observability Stack: Beyond just logging, consider a full observability stack including metrics (e.g., Prometheus, Datadog) and distributed tracing (e.g., OpenTelemetry, Sentry Performance) to get a complete picture of your application’s health.

Conclusion

Mastering server-side error handling and logging in Next.js is not just a best practice; it’s a fundamental requirement for building reliable and maintainable applications in 2026. By embracing try...catch blocks, leveraging a centralized logging utility, and understanding the nuances of different server-side contexts like Server Actions, API Routes, and the Edge Runtime, you can transform silent failures into actionable insights. This proactive approach ensures you’re always aware of potential issues, can diagnose them rapidly, and keep your Next.js application performing flawlessly.


Discussion Questions

  1. What’s your preferred logging stack for Next.js server-side, especially when deploying to the Edge Runtime, and why?
  2. How do you balance comprehensive logging (more data, potentially more performance overhead) with ensuring optimal performance in high-traffic Server Actions?

Comments

No comments yet. Be the first!

We use cookies to enhance your browsing experience, serve personalized content, and analyze our traffic. By clicking "OK", you consent to our use of cookies and agree to our Terms of Service & Privacy Policy.