Skip to main content

Error Handling

The @elcto/api package provides a shared error handler for consistent error responses across all Next.js API routes.

Overview

All API routes should use handleApiError for:

  • Consistent JSON error responses with error codes
  • IP and device logging for security auditing
  • Pattern-based error mapping (auth errors, validation, rate limiting, etc.)
  • Clean console output: console.warn for user errors, console.error for unexpected errors

Basic Usage

import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { gql, extractClientInfo, handleApiError } from "@elcto/api";

export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);

if (!session?.user) {
return NextResponse.json(
{ error: "Unauthorized", code: "UNAUTHORIZED" },
{ status: 401 }
);
}

const { userAgent, clientIp } = extractClientInfo(request);

const data = await gql<{ resource: ResourceType }>(
`query Resource { resource { id name } }`,
{},
{ userAgent, clientIp }
);

return NextResponse.json(data.resource);
} catch (error) {
return handleApiError(request, error, {
route: "resource/get",
defaultMessage: "Failed to fetch resource",
});
}
}

API Reference

handleApiError

function handleApiError(
request: NextRequest,
error: unknown,
options: HandleApiErrorOptions
): NextResponse<ApiErrorResponse>

Parameters

ParameterTypeDescription
requestNextRequestThe incoming request (used for IP/device extraction)
errorunknownThe caught error
optionsHandleApiErrorOptionsConfiguration options

Options

interface HandleApiErrorOptions {
/** Route identifier for logging (e.g., "user/profile", "2fa/verify") */
route: string;
/** Default error message if no pattern matches */
defaultMessage?: string;
/** Additional error mappings to check before defaults */
mappings?: ErrorMapping[];
}

createErrorHandler

Create a pre-configured error handler for a specific route:

import { createErrorHandler } from "@elcto/api";

const handleError = createErrorHandler({
route: "2fa/verify",
defaultMessage: "Failed to verify 2FA",
});

export async function POST(request: NextRequest) {
try {
// ... route logic
} catch (error) {
return handleError(request, error);
}
}

Error Codes

Authentication

CodeStatusDescription
AUTH_REQUIRED401Authentication required
UNAUTHORIZED401Invalid credentials
SESSION_EXPIRED401Session has expired
INVALID_CREDENTIALS401Wrong username/password
INVALID_TOKEN400Token is invalid
TOKEN_EXPIRED400Token has expired

Two-Factor Authentication

CodeStatusDescription
INVALID_CODE400Invalid verification code
TWO_FA_NOT_ENABLED4002FA is not enabled
TWO_FA_ALREADY_ENABLED4002FA is already enabled
TWO_FA_REQUIRED4032FA is required
NO_PENDING_SETUP400No pending 2FA setup

User/Account

CodeStatusDescription
USER_NOT_FOUND404User not found
EMAIL_ALREADY_EXISTS409Email already registered
USERNAME_TAKEN409Username is taken
ACCOUNT_DELETED410Account has been deleted
ACCOUNT_BANNED403Account is banned
ACCOUNT_NOT_FOUND404Platform account not found

Permissions & Resources

CodeStatusDescription
NOT_FOUND404Resource not found
PERMISSION_DENIED403Permission denied
FORBIDDEN403Forbidden
RATE_LIMITED429Too many requests

Validation

CodeStatusDescription
VALIDATION_ERROR400Validation error
INVALID_INPUT400Invalid input
INVALID_EMAIL400Invalid email address
WEAK_PASSWORD400Password too weak

Server

CodeStatusDescription
INTERNAL_ERROR500Internal server error
SERVICE_UNAVAILABLE503Service unavailable

Response Format

All errors return a consistent JSON format:

{
"error": "User-friendly error message",
"code": "ERROR_CODE"
}

Logging

The error handler logs errors differently based on type:

Known User Errors

[auth/2fa/verify] INVALID_CODE | ip=192.168.1.1 device=Chrome | POST /api/auth/2fa/verify

Uses console.warn - these are expected user errors (wrong code, not found, etc.)

Unexpected Errors

[auth/2fa/verify] Unexpected error | ip=192.168.1.1 device=Chrome | POST /api/auth/2fa/verify
Error: Something went wrong
at ...

Uses console.error with full stack trace - these need investigation.

Early Return Error Codes

For validation errors before the try block, include error codes:

export async function POST(request: NextRequest) {
const body = await request.json();

// Validation error - before try block
if (!body.email) {
return NextResponse.json(
{ error: "Email is required", code: "VALIDATION_ERROR" },
{ status: 400 }
);
}

// Auth error - before try block
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json(
{ error: "Unauthorized", code: "UNAUTHORIZED" },
{ status: 401 }
);
}

try {
// ... route logic
} catch (error) {
return handleApiError(request, error, {
route: "user/email",
defaultMessage: "Failed to update email",
});
}
}

Custom Error Mappings

Add custom error patterns for specific routes:

return handleApiError(request, error, {
route: "custom/route",
defaultMessage: "Operation failed",
mappings: [
{
patterns: ["specific error message"],
status: 400,
code: "CUSTOM_ERROR" as any,
message: "A custom error occurred",
},
],
});

Using with apiRequest (REST)

When using apiRequest for REST calls, throw errors instead of returning directly to ensure proper logging:

import { NextRequest, NextResponse } from "next/server";
import { apiRequest, handleApiError } from "@elcto/api";

export async function POST(request: NextRequest) {
try {
const body = await request.json();

const result = await apiRequest<ResponseType>("/v1/endpoint", {
method: "POST",
body: { data: body.data },
});

// ✅ CORRECT: Throw to use handleApiError for logging
if (result.error) {
throw new Error(result.error);
}

// ❌ WRONG: Direct return bypasses logging
// if (result.error) {
// return NextResponse.json({ error: result.error }, { status: 500 });
// }

return NextResponse.json(result.data);
} catch (error) {
return handleApiError(request, error, {
route: "endpoint/action",
defaultMessage: "Failed to perform action",
});
}
}

Server-Only Import

warning

handleApiError imports NextRequest and NextResponse which are server-only modules. Never import it in client components.

In API Routes (Server)

Import directly from @elcto/api:

// ✅ Server-side API routes
import { handleApiError } from "@elcto/api";

In Client Components

The @/lib/api re-export file only includes client-safe exports. It intentionally excludes handleApiError:

// ✅ Client components - use @/lib/api
import { gql, createWebSocket } from "@/lib/api";

// ❌ Never import handleApiError in client components

TypeScript Types

import type {
ErrorCodeType,
ApiErrorResponse,
ErrorMapping,
HandleApiErrorOptions,
} from "@elcto/api";