4.2 KiB
4.2 KiB
Route Handlers + Drizzle Guide
Use this guide when a feature should stop relying on mock data and start using the app's real server boundary.
Default request flow
The preferred flow in this repo is:
component -> service.ts -> /api route handler -> Drizzle -> database
This keeps:
- React components UI-focused
- route handlers responsible for auth, org resolution, and HTTP behavior
- Drizzle access on the server only
Avoid these paths as defaults:
component -> mock-apicomponent -> Drizzle directlyclient component -> external DB client
Build order
1. Schema first
Define tables and relations before writing React code.
Typical pieces:
- feature table such as
orders - optional
organizationIdforeign key - audit columns such as
createdAtandupdatedAt
If the task includes org-aware access, make tenant ownership explicit in the schema.
2. API contract second
In src/features/<feature>/api/types.ts, define:
- entity shape used by the UI
- list filters
- list response
- detail response
- mutation payloads
Keep response types stable even if the schema is denormalized underneath.
3. Route handlers third
Implement:
src/app/api/<feature>/route.tsforGETlist andPOSTcreatesrc/app/api/<feature>/[id]/route.tsforGET,PATCHorPUT, andDELETE
Responsibilities:
- authenticate the request
- resolve active organization
- check membership or role
- parse request params and body
- query or mutate via Drizzle
- return consistent JSON
Example list handler
import { and, count, desc, eq, ilike } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
import { db } from '@/lib/db';
import { memberships, orders } from '@/db/schema';
export async function GET(request: NextRequest) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = request.nextUrl;
const page = Number(searchParams.get('page') ?? 1);
const limit = Number(searchParams.get('limit') ?? 10);
const search = searchParams.get('search') ?? '';
const offset = (page - 1) * limit;
const membership = await db.query.memberships.findFirst({
where: eq(memberships.userId, session.user.id)
});
if (!membership) {
return NextResponse.json({ message: 'Organization membership required' }, { status: 403 });
}
const whereClause = and(
eq(orders.organizationId, membership.organizationId),
search ? ilike(orders.customerName, `%${search}%`) : undefined
);
const [items, totalRows] = await Promise.all([
db.select().from(orders).where(whereClause).orderBy(desc(orders.createdAt)).limit(limit).offset(offset),
db.select({ value: count() }).from(orders).where(whereClause)
]);
return NextResponse.json({
items,
totalItems: totalRows[0]?.value ?? 0
});
}
Service layer pattern
service.ts should call the local route handlers through apiClient.
import { apiClient } from '@/lib/api-client';
import type { Order, OrderFilters, OrdersResponse, OrderMutationPayload } from './types';
export async function getOrders(filters: OrderFilters): Promise<OrdersResponse> {
const searchParams = new URLSearchParams();
if (filters.page) searchParams.set('page', String(filters.page));
if (filters.limit) searchParams.set('limit', String(filters.limit));
if (filters.search) searchParams.set('search', filters.search);
if (filters.status) searchParams.set('status', filters.status);
if (filters.sort) searchParams.set('sort', filters.sort);
return apiClient<OrdersResponse>(`/orders?${searchParams.toString()}`);
}
export async function createOrder(values: OrderMutationPayload): Promise<Order> {
return apiClient<Order>('/orders', {
method: 'POST',
body: JSON.stringify(values)
});
}
Migration advice for this repo
If the task is migrating existing code:
- start with
productsorusersbecause both already have route-handler shells - swap
service.tsaway fromfakeProductsorfakeUsers - replace route-handler mock implementations with Drizzle queries
- keep
queries.tsand UI mostly stable where possible
That minimizes UI churn and isolates the migration to the intended seam.