Every API endpoint in Astrelo follows the same structure. Once you understand the pattern, you can read any of the 68 routes in seconds. This chapter dissects the pattern, then shows how it varies for different use cases.
The Canonical Pattern
Here’s the template every route follows:
// 1. Swagger documentation (for auto-generated API docs)
/**
* @swagger
* /api/something:
* get:
* tags: [Something]
* security:
* - BearerAuth: []
* - CookieAuth: []
* parameters: [...]
* responses:
* 200: { description: Success }
* 401: { description: Unauthorized }
*/
// 2. Imports
import { requireAuth, AuthenticatedRequest } from '@infrastructure/auth/middleware';
import pool from '@infrastructure/database/connection';
// 3. Handler function
async function handler(req: AuthenticatedRequest, res: NextApiResponse) {
// 4. Method check
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
// 5. Extract authenticated user
const userId = req.userId!;
try {
// 6. Parse and validate input
// 7. Execute business logic
// 8. Return response
} catch (error) {
// 9. Error handling
console.error('[ENDPOINT] Error:', error);
return res.status(500).json({ error: 'Internal server error' });
}
}
// 10. Export with auth middleware
export default requireAuth(handler);Let’s see this in action with a real route.
Example 1: A Clean GET Endpoint
The alert feed endpoint is a textbook example:
// src/pages/api/alerts/index.ts
/**
* @swagger
* /api/alerts:
* get:
* tags: [Alerts]
* summary: Get alert feed for the authenticated user
* security:
* - BearerAuth: []
* - CookieAuth: []
* parameters:
* - name: limit
* in: query
* schema: { type: integer, default: 20, maximum: 50 }
* - name: offset
* in: query
* schema: { type: integer, default: 0 }
* responses:
* 200:
* description: Alert feed with unviewed count
*/
async function handler(req: AuthenticatedRequest, res: NextApiResponse) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
const userId = req.userId!;
try {
const limit = Math.min(parseInt(String(req.query.limit)) || 20, 50);
const offset = parseInt(String(req.query.offset)) || 0;
const [alerts, unviewedCount] = await Promise.all([
getAlertFeed(userId, limit, offset),
getUnviewedCount(userId),
]);
res.setHeader('Cache-Control', 'private, no-store');
return res.status(200).json({ alerts, unviewedCount });
} catch (error) {
console.error('[Alerts API] Error fetching feed:', error);
return res.status(500).json({ error: 'Internal server error' });
}
}
export default requireAuth(handler);Dissecting Each Step
Step 1: The Swagger Block
The JSDoc comment at the top isn’t just documentation — tools can read it to auto-generate an OpenAPI specification. Every route documents its parameters, security requirements, and responses.
Notice the security section lists both BearerAuth (JWT in header) and CookieAuth (JWT in cookie). Both paths are supported — Chapter 2 explained why.
Step 2: Method Check
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}Next.js routes handle ALL HTTP methods by default. If someone sends a POST to /api/alerts, this guard returns a 405 instead of crashing. Always check the method first — it’s the cheapest validation.
Step 3: req.userId!
const userId = req.userId!;The ! (non-null assertion) is safe because requireAuth guarantees that userId is set before handler executes. If the token were invalid, requireAuth would have returned a 401 and handler would never run.
Step 4: Input Parsing with Safety
const limit = Math.min(parseInt(String(req.query.limit)) || 20, 50);
const offset = parseInt(String(req.query.offset)) || 0;This is defensive parsing:
String(req.query.limit)— query parameters can bestring | string[] | undefinedin Next.js. Wrapping inString()handles all cases.parseInt(...)— converts to a number. ReturnsNaNif the string isn’t a number.|| 20— ifparseIntreturnedNaN(falsy), use 20 as the default.Math.min(..., 50)— cap at 50. Even if someone sends?limit=10000, they get 50. This prevents abuse and protects the database from massive queries.
Step 5: Parallel Data Fetching
const [alerts, unviewedCount] = await Promise.all([
getAlertFeed(userId, limit, offset),
getUnviewedCount(userId),
]);Promise.all runs both database queries simultaneously. If getAlertFeed takes 50ms and getUnviewedCount takes 20ms, the total is ~50ms (the slower one), not 70ms (sequential). This pattern is used throughout the codebase whenever two or more independent queries are needed.
Step 6: Cache Control
res.setHeader('Cache-Control', 'private, no-store');private means intermediary caches (CDNs, proxies) must not cache this response — it contains user-specific data. no-store means even the browser shouldn’t cache it. Alert data changes frequently and contains sensitive information.
Example 2: A POST Mutation
Marking all alerts as read is a state-change operation:
// src/pages/api/alerts/mark-all-read.ts
async function handler(req: AuthenticatedRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const userId = req.userId!;
try {
const result = await pool.query(
`UPDATE realtime_alerts
SET viewed_at = NOW()
WHERE user_id = $1
AND viewed_at IS NULL
AND dismissed_at IS NULL`,
[userId]
);
return res.status(200).json({
success: true,
markedCount: result.rowCount,
});
} catch (error) {
console.error('[Alerts] Error marking all read:', error);
return res.status(500).json({ error: 'Internal server error' });
}
}
export default requireAuth(handler);Key differences from GET:
- Method check is
POST, notGET - Uses
pool.query()directly for a simple SQL UPDATE - Returns
result.rowCount— how many rows were affected. This lets the frontend show “12 alerts marked as read” instead of just “done.” - The SQL filters by
user_id = $1(multi-tenancy) ANDviewed_at IS NULL(only unread ones) ANDdismissed_at IS NULL(not dismissed ones)
Example 3: Multi-Method CRUD
Some routes handle multiple HTTP methods. The autonomous actions endpoint is a full CRUD handler:
// src/pages/api/autonomous-actions/index.ts (simplified)
async function handler(req: AuthenticatedRequest, res: NextApiResponse) {
const userId = req.userId!;
try {
if (req.method === 'GET') {
// List actions with filtering
const status = String(req.query.status || 'pending');
const limit = Math.min(parseInt(String(req.query.limit)) || 20, 50);
const actions = await listPendingActions(userId, { status, limit });
return res.status(200).json({ actions });
}
if (req.method === 'POST') {
// Approve or cancel an action
const { actionId, action } = req.body;
if (!actionId || !action) {
return res.status(400).json({ error: 'actionId and action are required' });
}
if (action === 'approve') {
const result = await approveAction(userId, actionId);
return res.status(200).json(result);
} else if (action === 'cancel') {
const result = await cancelAction(userId, actionId, req.body.reason);
return res.status(200).json(result);
}
return res.status(400).json({ error: 'Invalid action' });
}
if (req.method === 'PUT') {
// Edit an action before approval
const { actionId, updates } = req.body;
const result = await editAction(userId, actionId, updates);
return res.status(200).json(result);
}
return res.status(405).json({ error: 'Method not allowed' });
} catch (error) {
console.error('[Autonomous Actions] Error:', error);
return res.status(500).json({ error: 'Internal server error' });
}
}The pattern: check the method, handle each case, and fall through to 405 for anything unexpected. Each method block is self-contained with its own validation and response.
Why one file for multiple methods? In Next.js Pages Router, each file maps to one URL. /api/autonomous-actions → autonomous-actions/index.ts. If you wanted separate files per method, you’d need a different framework. The convention is to handle all methods in one handler, organized with if (req.method === ...) blocks.
Input Validation with Zod
For complex inputs, we use Zod — a TypeScript-first schema validation library. The login route is the most thorough example:
// src/pages/api/auth/login.ts, lines 94-97
const loginSchema = z.object({
email: z.string().email(),
password: z.string()
});This defines a schema: the request body must be an object with an email field (valid email format) and a password field (any string).
// Line 105
const validated = loginSchema.parse(req.body);parse() does two things:
- Validates — checks that
req.bodymatches the schema - Returns typed data —
validatedis typed as{ email: string; password: string }
If validation fails, parse() throws a ZodError:
// Lines 159-161
if (error instanceof z.ZodError) {
return res.status(400).json({ error: error.errors });
}The error response includes details about what was wrong:
{
"error": [
{ "code": "invalid_string", "path": ["email"], "message": "Invalid email" }
]
}Why Zod over manual validation? Compare:
// Manual validation — verbose, error-prone:
if (!req.body.email || typeof req.body.email !== 'string') {
return res.status(400).json({ error: 'Email is required' });
}
if (!req.body.email.includes('@')) {
return res.status(400).json({ error: 'Invalid email format' });
}
if (!req.body.password || typeof req.body.password !== 'string') {
return res.status(400).json({ error: 'Password is required' });
}
// Zod — one line:
const validated = loginSchema.parse(req.body);Zod is also the single source of truth for the expected shape. TypeScript infers the type from the schema, so your validation and your types are always in sync.
Admin-Only Routes
Some routes should only be accessible to administrators. The requireAdmin middleware (Chapter 2) handles this:
// src/pages/api/admin/simulate-webhook.ts
import { requireAdmin } from '@infrastructure/auth/middleware';
async function handler(req: AuthenticatedRequest, res: NextApiResponse) {
// Only runs if user is authenticated AND is_admin = true
const userId = req.userId!;
if (req.method === 'POST') {
// ... simulate webhook scenarios
}
if (req.method === 'DELETE') {
// ... clean up demo data
}
return res.status(405).json({ error: 'Method not allowed' });
}
export default requireAdmin(handler);The only difference from a normal route: requireAdmin instead of requireAuth. Internally, requireAdmin calls requireAuth first (checks the JWT), then queries the database for SELECT is_admin FROM users WHERE id = $1. If the user isn’t an admin, they get a 403 Forbidden.
Error Handling: The Two-Layer Strategy
Every route has two layers of error protection:
Layer 1: Expected Errors (400-level)
These are invalid inputs, missing resources, and business rule violations. Handle them explicitly:
if (!actionId || !action) {
return res.status(400).json({ error: 'actionId and action are required' });
}
if (action !== 'approve' && action !== 'cancel') {
return res.status(400).json({ error: 'Invalid action' });
}Layer 2: Unexpected Errors (500-level)
These are database connection failures, null pointer exceptions, and other bugs. The try-catch catches everything:
catch (error) {
console.error('[Alerts API] Error fetching feed:', error);
return res.status(500).json({ error: 'Internal server error' });
}Why not expose the actual error? The error message might contain database details, SQL queries, or stack traces — information that helps attackers. The generic “Internal server error” is deliberate. The real error goes to console.error, which is written to server logs that only developers can see.
The [Alerts API] prefix in the log message is a convention across the codebase. It makes grep-searching logs easy: grep "\[Alerts API\]" logs.txt shows all alert-related errors.
How Next.js Maps URLs to Files
Understanding the routing is essential:
URL → File
/api/alerts → src/pages/api/alerts/index.ts
/api/alerts/mark-all-read → src/pages/api/alerts/mark-all-read.ts
/api/alerts/[id]/view → src/pages/api/alerts/[id]/view.ts
/api/autonomous-actions → src/pages/api/autonomous-actions/index.ts
/api/admin/simulate-webhook → src/pages/api/admin/simulate-webhook.tsThe [id] is a dynamic segment. When a request comes to /api/alerts/abc-123/view, Next.js sets req.query.id = 'abc-123'. The handler accesses it as:
const alertId = String(req.query.id);This is file-based routing — no router configuration needed. The file system IS the router.
The Response Pattern
Successful responses follow a consistent shape:
// Lists: return array + metadata
return res.status(200).json({ alerts, unviewedCount });
return res.status(200).json({ actions, total, hasMore });
// Mutations: return success flag + affected data
return res.status(200).json({ success: true, markedCount: result.rowCount });
return res.status(200).json({ success: true, action: updatedAction });
// Errors: return error message
return res.status(400).json({ error: 'Missing required field' });
return res.status(500).json({ error: 'Internal server error' });The frontend relies on this consistency. React Query hooks know that res.ok means the response has data, and !res.ok means the response has an error field.
Key Takeaways
-
Every route follows the same 10-step pattern. Method check, userId extraction, input parsing, business logic, error handling, and
requireAuthwrapper. Once you’ve read one route, you can read all 68. -
Method checks are the first line of defense. Return 405 immediately for unsupported methods.
-
req.userId!is safe becauserequireAuthguarantees it exists. The non-null assertion is intentional, not lazy. -
Input parsing is defensive.
Math.min()caps limits,|| defaulthandles missing values,String()normalizes query parameters. -
Promise.allparallelizes independent queries. Never run two independent database queries sequentially. -
Zod validates and types in one step. The schema IS the type definition.
-
Error messages are generic on purpose. Real errors go to server logs, not to the client.
Next chapter: we dive into the ML scoring engine, starting with how Astrelo converts industry codes into 384-dimensional vectors.