name: glapi-backend-guidelines description: GLAPI backend development guide for Next.js + TRPC + Drizzle ORM + PostgreSQL + Clerk. TRPC for internal type-safety, REST API exposure via OpenAPI. Covers layered architecture (routers → services → Drizzle), dual TRPC/REST endpoints, Clerk authentication, and testing strategies.
GLAPI Backend Development Guidelines
Purpose
Establish consistency and best practices for GLAPI's backend development using:
- Next.js 15 - API routes and server architecture
- TRPC - Internal type-safe procedures
- REST API - External access via TRPC conversion layer
- Drizzle ORM - Type-safe database queries
- PostgreSQL - Primary database
- Clerk - Authentication
- Zod - Input validation
- TypeScript - Full type safety
When to Use This Skill
Automatically activates when working on:
- Creating or modifying TRPC routers, procedures
- Building Next.js API routes (REST endpoints)
- Converting TRPC to REST
- Database operations with Drizzle ORM
- Clerk authentication integration
- Input validation with Zod
- Backend services and business logic
- Backend testing and refactoring
Architecture Overview
Dual API Pattern
Your Unique Architecture:
- TRPC (Internal): Type-safe procedures for Next.js frontend consumption
- REST (External): Public API endpoints created by converting TRPC procedures
- Shared Services: Business logic used by both TRPC and REST
- Drizzle ORM: Database access layer
Client Request (Frontend)
↓
┌─────────────────┐
│ TRPC (Internal) │ ← Next.js frontend
│ Type-safe │
└─────────────────┘
↓
Service Layer ← Shared business logic
↓
Drizzle ORM
↓
PostgreSQL
External Request (API)
↓
┌──────────────────┐
│ REST API Routes │ ← External clients
│ (Next.js) │
└──────────────────┘
↓
TRPC Procedures (reuse)
↓
Service Layer
↓
Drizzle ORM
↓
PostgreSQL
Layered Architecture
HTTP/TRPC Request
↓
TRPC Router OR Next.js API Route (thin controller)
↓
Service Layer (business logic)
↓
Drizzle ORM (data access)
↓
Database (PostgreSQL)
Key Principle: Each layer has ONE responsibility.
- TRPC Routers: Type-safe procedures, validation, auth checks
- Next.js API Routes: REST endpoints that call TRPC procedures
- Services: Business logic, orchestration, transactions
- Drizzle: Database queries and mutations
Directory Structure
apps/
api/
src/
app/
api/ # REST API routes (external)
users/
[id]/
route.ts # GET/PATCH/DELETE /api/users/:id
route.ts # GET/POST /api/users
web/
src/
app/
api/ # REST API routes (can be here too)
server/
routers/
_app.ts # Main router composition
users.router.ts # TRPC routers
posts.router.ts
trpc.ts # TRPC setup + procedures
context.ts # TRPC context creation
packages/
database/
src/
schema/ # Drizzle schemas
users.ts
posts.ts
index.ts # Export all schemas
index.ts # Export db + helpers
drizzle.config.ts
services/
src/
user.service.ts # Business logic services
post.service.ts
index.ts # Export all services
Naming Conventions:
- TRPC Routers:
camelCase.router.ts-users.router.ts - Next.js API Routes:
route.tsin directory structure - Services:
camelCase.service.ts-user.service.ts - Schemas:
camelCase.ts-users.ts
Quick Start
New TRPC Procedure Checklist
- Define input schema with Zod
- Create procedure in appropriate router file
- Use
publicProcedureorprotectedProcedure - Implement business logic in a service function
- Use Drizzle for database queries
- Handle errors properly
- Add TypeScript types
- Consider if this needs REST exposure
New REST Endpoint Checklist
- Create Next.js API route in
apps/web/src/app/api/orapps/api/src/app/api/ - Reuse existing TRPC procedure
- Create TRPC caller in API route
- Handle Clerk authentication with
auth() - Return proper HTTP status codes
- Add error handling
- Test both TRPC and REST versions
New Service Function Checklist
- Create in
packages/services/src/ - Accept typed parameters
- Use Drizzle for database access
- Keep database logic separate from business logic
- Return typed results
- Handle errors with try/catch
- Make it reusable by both TRPC and REST
Core Principles (7 Key Rules)
1. Routers Are Thin, Services Are Thick
// ❌ NEVER: Business logic in routers
export const usersRouter = createTRPCRouter({
create: publicProcedure
.input(createUserSchema)
.mutation(async ({ input }) => {
// 200 lines of business logic here ❌
const user = await db.insert(users).values(input);
await sendWelcomeEmail(user);
await createDefaultSettings(user);
// ... more logic
}),
});
// ✅ ALWAYS: Delegate to service
export const usersRouter = createTRPCRouter({
create: publicProcedure
.input(createUserSchema)
.mutation(async ({ input, ctx }) => {
return userService.createUser(input);
}),
});
// packages/services/src/user.service.ts
export const userService = {
async createUser(data: CreateUserInput) {
// All business logic here ✅
const [user] = await db.insert(users).values(data).returning();
await this.sendWelcomeEmail(user);
await this.createDefaultSettings(user);
return user;
},
};
2. Use Protected/Public Procedures
// ❌ NEVER: Manual auth checks
export const usersRouter = createTRPCRouter({
getProfile: publicProcedure.query(async ({ ctx }) => {
if (!ctx.userId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
// ...
}),
});
// ✅ ALWAYS: Use protectedProcedure
export const usersRouter = createTRPCRouter({
getProfile: protectedProcedure.query(async ({ ctx }) => {
// ctx.userId is guaranteed to exist (from Clerk)
return userService.getProfile(ctx.userId);
}),
});
3. Validate All Inputs with Zod
import { z } from "zod";
// Define schemas
const createUserSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email"),
age: z.number().min(18, "Must be 18+").optional(),
});
// Use in procedure
export const usersRouter = createTRPCRouter({
create: publicProcedure
.input(createUserSchema)
.mutation(async ({ input }) => {
// input is typed and validated! ✅
return userService.create(input);
}),
});
4. Return Data Directly, No Wrappers
// ❌ NEVER: Wrapper objects in TRPC
return {
success: true,
data: user,
message: "User created",
};
// ✅ ALWAYS: Return data directly in TRPC
return user;
// Exception: REST API routes may use standard structure
// This is fine in Next.js API routes:
return NextResponse.json({ success: true, data: user });
5. Use Drizzle Queries from @glapi/database
import { db } from '@glapi/database';
import { users } from '@glapi/database/schema';
import { eq, and } from 'drizzle-orm';
// ❌ NEVER: Raw SQL
const userList = await db.execute(sql`SELECT * FROM users WHERE id = ${id}`);
// ✅ ALWAYS: Drizzle query builder
const user = await db.query.users.findFirst({
where: eq(users.id, id),
});
// OR using select builder
const [user] = await db
.select()
.from(users)
.where(eq(users.id, id))
.limit(1);
6. REST Endpoints Reuse TRPC Procedures
// apps/web/src/app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@clerk/nextjs';
import { appRouter } from '@/server/routers/_app';
import { createContext } from '@/server/trpc';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { userId } = auth();
// Create TRPC context
const ctx = await createContext({ userId });
// Call TRPC procedure ✅
const caller = appRouter.createCaller(ctx);
const user = await caller.users.getById({ id: params.id });
return NextResponse.json(user);
} catch (error) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
}
7. Clerk for All Authentication
import { auth, currentUser } from '@clerk/nextjs';
// In TRPC context
export const createContext = async ({ userId }: { userId?: string | null }) => {
return {
userId, // From Clerk
};
};
// In protected procedure
export const protectedProcedure = publicProcedure.use(async ({ ctx, next }) => {
if (!ctx.userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
userId: ctx.userId,
},
});
});
// In Next.js API route
export async function GET(request: NextRequest) {
const { userId } = auth();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Protected logic here
}
Common Patterns
See resources/complete-examples.md for full code examples including:
- TRPC Procedure + REST Endpoint pairs
- Service layer patterns
- Drizzle query examples
- Pagination and filtering
- Error handling
- Testing strategies
Quick Reference Templates
TRPC Router Template
import { z } from 'zod';
import { createTRPCRouter, publicProcedure, protectedProcedure } from '../trpc';
import { myService } from '@glapi/services';
export const myRouter = createTRPCRouter({
// Public query
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return myService.getById(input.id);
}),
// Protected mutation
create: protectedProcedure
.input(z.object({
name: z.string(),
description: z.string().optional(),
}))
.mutation(async ({ input, ctx }) => {
return myService.create(input, ctx.userId);
}),
// List with pagination
list: publicProcedure
.input(z.object({
limit: z.number().min(1).max(100).default(50),
offset: z.number().min(0).default(0),
}))
.query(async ({ input }) => {
return myService.list(input);
}),
});
Next.js API Route Template
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@clerk/nextjs';
import { appRouter } from '@/server/routers/_app';
import { createContext } from '@/server/trpc';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const ctx = await createContext({});
const caller = appRouter.createCaller(ctx);
const item = await caller.myRouter.getById({ id: params.id });
return NextResponse.json(item);
} catch (error) {
return NextResponse.json(
{ error: 'Not found' },
{ status: 404 }
);
}
}
export async function POST(request: NextRequest) {
try {
const { userId } = auth();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const ctx = await createContext({ userId });
const caller = appRouter.createCaller(ctx);
const created = await caller.myRouter.create(body);
return NextResponse.json(created, { status: 201 });
} catch (error) {
return NextResponse.json(
{ error: 'Failed to create' },
{ status: 500 }
);
}
}
Service Template
import { db } from '@glapi/database';
import { myTable } from '@glapi/database/schema';
import { eq } from 'drizzle-orm';
import { TRPCError } from '@trpc/server';
export const myService = {
async getById(id: string) {
const item = await db.query.myTable.findFirst({
where: eq(myTable.id, id),
});
if (!item) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Item not found',
});
}
return item;
},
async create(data: CreateInput, userId: string) {
const [item] = await db.insert(myTable)
.values({
...data,
userId,
})
.returning();
return item;
},
async list({ limit, offset }: ListInput) {
const items = await db.query.myTable.findMany({
limit,
offset,
orderBy: (myTable, { desc }) => [desc(myTable.createdAt)],
});
return items;
},
};
Common Mistakes to Avoid
❌ DON'T
- Don't put business logic in routers
- Don't skip input validation
- Don't use raw SQL (use Drizzle)
- Don't forget authentication checks
- Don't duplicate logic between TRPC and REST
- Don't skip error handling
- Don't return wrapper objects in TRPC
✅ DO
- Use services for business logic
- Validate all inputs with Zod
- Use Drizzle's type-safe queries
- Use protectedProcedure for auth
- Reuse TRPC procedures in REST routes
- Handle errors with try/catch
- Return data directly in TRPC
- Type everything with TypeScript
Remember: TRPC for internal type-safety, REST for external access, services for shared logic, and Drizzle for database operations. Keep it simple, type-safe, and DRY!