Complete guide to creating secure, type-safe API endpoints in ShipSafe.
Overview
ShipSafe API routes follow Next.js 15 App Router conventions and include built-in security layers. This tutorial covers creating API endpoints with authentication, validation, and error handling.
What You'll Learn:
- Creating API routes with Next.js App Router
- Implementing authentication guards
- Validating inputs with Zod
- Handling errors consistently
- Following ShipSafe patterns
Route Structure
File Location
API routes are located in src/app/api/:
src/app/api/your-endpoint/
route.ts- HTTP method handlers
File Name
Always use route.ts in a folder named after your endpoint:
- ✅
src/app/api/users/route.ts→/api/users - ✅
src/app/api/posts/route.ts→/api/posts - ❌
src/app/api/users.ts→ Invalid (no folder)
Standard Route Pattern
Basic Structure
Every API route should follow this pattern:
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireAuth } from "@/lib/firebase/auth";
import { someSchema } from "@/features/domain/schema";
export async function POST(req: NextRequest) {
try {
// 1. Verify authentication (if required)
const user = await requireAuth(req);
// 2. Parse and validate request body
const body = await req.json();
const data = someSchema.parse(body);
// 3. Business logic (call feature functions)
const result = await doSomething(user.uid, data);
// 4. Return success response
return NextResponse.json({
success: true,
data: result,
});
} catch (error) {
// 5. Handle errors
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "Validation failed", details: error.errors },
{ status: 400 }
);
}
if (error instanceof Error && error.message.includes("Unauthorized")) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
console.error("API error:", error);
return NextResponse.json(
{ error: "An unexpected error occurred" },
{ status: 500 }
);
}
}
HTTP Methods
GET Request
import { NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/firebase/auth";
export async function GET(req: NextRequest) {
try {
const user = await requireAuth(req);
// Fetch data
const data = await fetchUserData(user.uid);
return NextResponse.json({
success: true,
data,
});
} catch (error) {
// Error handling...
}
}
POST Request
import { NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/firebase/auth";
import { z } from "zod";
import { createItemSchema } from "@/features/items/schema";
export async function POST(req: NextRequest) {
try {
const user = await requireAuth(req);
// Parse and validate
const body = await req.json();
const data = createItemSchema.parse(body);
// Create item
const item = await createItem(user.uid, data);
return NextResponse.json(
{
success: true,
data: item,
},
{ status: 201 }
);
} catch (error) {
// Error handling...
}
}
PUT/PATCH Request
export async function PUT(req: NextRequest) {
try {
const user = await requireAuth(req);
const body = await req.json();
const data = updateItemSchema.parse(body);
// Update item
const updated = await updateItem(user.uid, data.id, data);
return NextResponse.json({
success: true,
data: updated,
});
} catch (error) {
// Error handling...
}
}
export async function PATCH(req: NextRequest) {
// Similar to PUT but for partial updates
}
DELETE Request
export async function DELETE(req: NextRequest) {
try {
const user = await requireAuth(req);
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
if (!id) {
return NextResponse.json(
{ error: "ID is required" },
{ status: 400 }
);
}
await deleteItem(user.uid, id);
return NextResponse.json({
success: true,
});
} catch (error) {
// Error handling...
}
}
Authentication
Required Authentication
Use requireAuth() for endpoints that require authentication:
import { requireAuth } from "@/lib/firebase/auth";
export async function POST(req: NextRequest) {
try {
// Throws error if not authenticated
const user = await requireAuth(req);
// Use user.uid, user.email, etc.
return NextResponse.json({
success: true,
data: { userId: user.uid },
});
} catch (error) {
if (error instanceof Error && error.message.includes("Unauthorized")) {
return NextResponse.json(
{ error: "Authentication required" },
{ status: 401 }
);
}
// Handle other errors...
}
}
Optional Authentication
Use getCurrentUserServer() for endpoints with optional auth:
import { getCurrentUserServer } from "@/lib/firebase/auth";
export async function GET(req: NextRequest) {
// Returns null if not authenticated
const user = await getCurrentUserServer(req);
if (user) {
// Authenticated user - personalized content
return NextResponse.json({
success: true,
data: { personalized: true, user: user.email },
});
}
// Not authenticated - public content
return NextResponse.json({
success: true,
data: { personalized: false },
});
}
Input Validation
Using Zod Schemas
Always validate inputs using Zod schemas from features/:
import { z } from "zod";
import { createItemSchema } from "@/features/items/schema";
export async function POST(req: NextRequest) {
try {
const body = await req.json();
// Validate with Zod (throws ZodError if invalid)
const data = createItemSchema.parse(body);
// data is now typed and validated
// Use data safely...
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{
error: "Validation failed",
details: error.errors.map(e => ({
field: e.path.join("."),
message: e.message,
})),
},
{ status: 400 }
);
}
// Handle other errors...
}
}
Creating Validation Schemas
Create schemas in src/features/<domain>/schema.ts:
// src/features/items/schema.ts
import { z } from "zod";
export const createItemSchema = z.object({
title: z.string().min(1, "Title is required").max(100),
description: z.string().max(500).optional(),
price: z.number().positive("Price must be positive"),
});
export type CreateItemInput = z.infer<typeof createItemSchema>;
Safe Parse (No Exception)
Use safeParse() if you want to handle validation without exceptions:
const result = createItemSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{
error: "Validation failed",
details: result.error.errors,
},
{ status: 400 }
);
}
// result.data is typed and validated
const { title, description, price } = result.data;
Query Parameters
Reading Query Params
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const page = searchParams.get("page");
const limit = searchParams.get("limit");
const search = searchParams.get("search");
// Use params...
}
Validating Query Params
import { z } from "zod";
const querySchema = z.object({
page: z.string().regex(/^\d+$/).transform(Number).default("1"),
limit: z.string().regex(/^\d+$/).transform(Number).default("10"),
search: z.string().optional(),
});
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const query = Object.fromEntries(searchParams.entries());
// Validate and transform
const validated = querySchema.parse(query);
// validated.page and validated.limit are numbers
const items = await getItems({
page: validated.page,
limit: validated.limit,
search: validated.search,
});
return NextResponse.json({ success: true, data: items });
}
Error Handling
Standard Error Responses
// Validation error (400)
return NextResponse.json(
{ error: "Invalid input", details: validationErrors },
{ status: 400 }
);
// Authentication error (401)
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
// Authorization error (403)
return NextResponse.json(
{ error: "Forbidden" },
{ status: 403 }
);
// Not found (404)
return NextResponse.json(
{ error: "Resource not found" },
{ status: 404 }
);
// Server error (500)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
Comprehensive Error Handling
export async function POST(req: NextRequest) {
try {
// Your code...
} catch (error) {
// Zod validation errors
if (error instanceof z.ZodError) {
return NextResponse.json(
{
error: "Validation failed",
details: error.errors,
},
{ status: 400 }
);
}
// Authentication errors
if (error instanceof Error && error.message.includes("Unauthorized")) {
return NextResponse.json(
{ error: "Authentication required" },
{ status: 401 }
);
}
// Not found errors
if (error instanceof Error && error.message.includes("not found")) {
return NextResponse.json(
{ error: "Resource not found" },
{ status: 404 }
);
}
// Already exists errors
if (error instanceof Error && error.message.includes("already exists")) {
return NextResponse.json(
{ error: "Resource already exists" },
{ status: 409 }
);
}
// Generic server errors
console.error("API error:", error);
return NextResponse.json(
{ error: "An unexpected error occurred" },
{ status: 500 }
);
}
}
Response Format
Success Response
Always use this format:
return NextResponse.json({
success: true,
data: {
// Your data here
},
});
Error Response
Always use this format:
return NextResponse.json({
error: "Error message",
details: {}, // Optional details
}, { status: 400 });
Complete Examples
Example 1: Get User Profile
import { NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/firebase/auth";
import { getFirestoreInstance } from "@/lib/firebase/init";
import { userFromFirestore } from "@/models/user";
export async function GET(req: NextRequest) {
try {
const user = await requireAuth(req);
// Get user from Firestore
const firestore = getFirestoreInstance();
const userDoc = await firestore.collection("users").doc(user.uid).get();
if (!userDoc.exists) {
return NextResponse.json(
{ error: "User not found" },
{ status: 404 }
);
}
const userData = userFromFirestore({
...userDoc.data(),
uid: user.uid,
} as any);
return NextResponse.json({
success: true,
data: {
uid: userData.uid,
email: userData.email,
displayName: userData.displayName,
createdAt: userData.createdAt,
},
});
} catch (error) {
if (error instanceof Error && error.message.includes("Unauthorized")) {
return NextResponse.json(
{ error: "Authentication required" },
{ status: 401 }
);
}
console.error("Error fetching user:", error);
return NextResponse.json(
{ error: "Failed to fetch user" },
{ status: 500 }
);
}
}
Example 2: Create Item
import { NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/firebase/auth";
import { z } from "zod";
import { getFirestoreInstance } from "@/lib/firebase/init";
import { Timestamp } from "firebase-admin/firestore";
const createItemSchema = z.object({
title: z.string().min(1).max(100),
description: z.string().max(500).optional(),
});
export async function POST(req: NextRequest) {
try {
const user = await requireAuth(req);
// Parse and validate
const body = await req.json();
const data = createItemSchema.parse(body);
// Create item in Firestore
const firestore = getFirestoreInstance();
const itemRef = await firestore.collection("items").add({
userId: user.uid,
title: data.title,
description: data.description || null,
createdAt: Timestamp.now(),
updatedAt: Timestamp.now(),
});
const itemDoc = await itemRef.get();
return NextResponse.json(
{
success: true,
data: {
id: itemDoc.id,
...itemDoc.data(),
},
},
{ status: 201 }
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{
error: "Validation failed",
details: error.errors,
},
{ status: 400 }
);
}
if (error instanceof Error && error.message.includes("Unauthorized")) {
return NextResponse.json(
{ error: "Authentication required" },
{ status: 401 }
);
}
console.error("Error creating item:", error);
return NextResponse.json(
{ error: "Failed to create item" },
{ status: 500 }
);
}
}
Example 3: Update Item
import { NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/firebase/auth";
import { z } from "zod";
import { getFirestoreInstance } from "@/lib/firebase/init";
import { Timestamp } from "firebase-admin/firestore";
const updateItemSchema = z.object({
id: z.string().min(1),
title: z.string().min(1).max(100).optional(),
description: z.string().max(500).optional(),
});
export async function PUT(req: NextRequest) {
try {
const user = await requireAuth(req);
const body = await req.json();
const data = updateItemSchema.parse(body);
// Verify ownership
const firestore = getFirestoreInstance();
const itemDoc = await firestore.collection("items").doc(data.id).get();
if (!itemDoc.exists) {
return NextResponse.json(
{ error: "Item not found" },
{ status: 404 }
);
}
const itemData = itemDoc.data();
if (itemData?.userId !== user.uid) {
return NextResponse.json(
{ error: "Forbidden" },
{ status: 403 }
);
}
// Update item
await firestore.collection("items").doc(data.id).update({
...(data.title && { title: data.title }),
...(data.description !== undefined && { description: data.description }),
updatedAt: Timestamp.now(),
});
return NextResponse.json({
success: true,
data: { message: "Item updated successfully" },
});
} catch (error) {
// Error handling...
}
}
Security Features
All API routes are automatically protected by:
- HTTPS Enforcement - All traffic encrypted
- Rate Limiting - Per-IP request limits
- API Firewall - Blocks invalid requests
- CSRF Protection - For mutation requests
- Security Headers - Strong HTTP headers
These are applied automatically by middleware. No additional configuration needed.
Best Practices
- Always validate input - Use Zod schemas
- Handle errors gracefully - Return user-friendly messages
- Use proper HTTP status codes - 200, 201, 400, 401, 403, 404, 500
- Authenticate when needed - Use
requireAuth()for protected routes - Type your responses - Use TypeScript types
- Log errors server-side - For debugging, not in response
- Don't leak sensitive data - Never expose internal errors
- Follow consistent patterns - Use the standard route structure
Testing API Routes
Local Testing
# Start dev server
npm run dev
# Test with curl
curl -X POST http://localhost:3000/api/your-endpoint \
-H "Content-Type: application/json" \
-d '{"key": "value"}'
With Authentication
# Get auth token first (from client)
# Then include in request
curl -X GET http://localhost:3000/api/your-endpoint \
-H "Authorization: Bearer YOUR_ID_TOKEN"
Troubleshooting
Route Not Found
- Check file is in
src/app/api/<endpoint>/route.ts - Verify folder name matches endpoint path
- Restart dev server after creating new routes
Authentication Failing
- Check token is included in Authorization header
- Verify token hasn't expired
- Check Firebase Admin SDK configuration
Validation Errors
- Check Zod schema matches request body
- Verify all required fields are provided
- Check field types match schema
Learn More
- Validation Features - Zod validation patterns
- Error Handling Features - Error handling patterns
- Security Features - Security architecture
- Protected Pages Tutorial - Securing pages