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.warnfor user errors,console.errorfor 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
| Parameter | Type | Description |
|---|---|---|
request | NextRequest | The incoming request (used for IP/device extraction) |
error | unknown | The caught error |
options | HandleApiErrorOptions | Configuration 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
| Code | Status | Description |
|---|---|---|
AUTH_REQUIRED | 401 | Authentication required |
UNAUTHORIZED | 401 | Invalid credentials |
SESSION_EXPIRED | 401 | Session has expired |
INVALID_CREDENTIALS | 401 | Wrong username/password |
INVALID_TOKEN | 400 | Token is invalid |
TOKEN_EXPIRED | 400 | Token has expired |
Two-Factor Authentication
| Code | Status | Description |
|---|---|---|
INVALID_CODE | 400 | Invalid verification code |
TWO_FA_NOT_ENABLED | 400 | 2FA is not enabled |
TWO_FA_ALREADY_ENABLED | 400 | 2FA is already enabled |
TWO_FA_REQUIRED | 403 | 2FA is required |
NO_PENDING_SETUP | 400 | No pending 2FA setup |
User/Account
| Code | Status | Description |
|---|---|---|
USER_NOT_FOUND | 404 | User not found |
EMAIL_ALREADY_EXISTS | 409 | Email already registered |
USERNAME_TAKEN | 409 | Username is taken |
ACCOUNT_DELETED | 410 | Account has been deleted |
ACCOUNT_BANNED | 403 | Account is banned |
ACCOUNT_NOT_FOUND | 404 | Platform account not found |
Permissions & Resources
| Code | Status | Description |
|---|---|---|
NOT_FOUND | 404 | Resource not found |
PERMISSION_DENIED | 403 | Permission denied |
FORBIDDEN | 403 | Forbidden |
RATE_LIMITED | 429 | Too many requests |
Validation
| Code | Status | Description |
|---|---|---|
VALIDATION_ERROR | 400 | Validation error |
INVALID_INPUT | 400 | Invalid input |
INVALID_EMAIL | 400 | Invalid email address |
WEAK_PASSWORD | 400 | Password too weak |
Server
| Code | Status | Description |
|---|---|---|
INTERNAL_ERROR | 500 | Internal server error |
SERVICE_UNAVAILABLE | 503 | Service 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
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";