Zum Hauptinhalt springen

Guide: Neuen tRPC-Router anlegen

Wann tRPC vs. REST?

VerwendeFür
tRPCApp-interne CRUD-Operationen, Datenabfragen, Geschäftslogik
RESTOAuth-Flows, Webhooks, Cron-Jobs, öffentliche APIs, File-Uploads

Schritt 1: Zod-Schema definieren

In src/lib/validations/{domain}.ts:

import { z } from 'zod';

export const createMyEntitySchema = z.object({
name: z.string().min(1).max(100),
weightKg: z.number().positive(),
status: z.enum(['active', 'inactive']),
});

export const updateMyEntitySchema = createMyEntitySchema.partial();

Schritt 2: tRPC-Router erstellen

In src/server/trpc/routers/{domain}.ts:

import { z } from 'zod';
import { eq, and } from 'drizzle-orm';
import { createTRPCRouter, protectedProcedure } from '../init';
import { myTable } from '../../db/schema';
import { createMyEntitySchema, updateMyEntitySchema } from '@/lib/validations/my-domain';

export const myDomainRouter = createTRPCRouter({
list: protectedProcedure
.input(z.object({
status: z.enum(['active', 'inactive']).optional(),
}).optional())
.query(async ({ ctx, input }) => {
const { tenantId } = ctx.session.user;

return ctx.db.select()
.from(myTable)
.where(
and(
eq(myTable.tenantId, tenantId),
input?.status ? eq(myTable.status, input.status) : undefined,
)
)
.orderBy(myTable.createdAt);
}),

create: protectedProcedure
.input(createMyEntitySchema)
.mutation(async ({ ctx, input }) => {
const { tenantId } = ctx.session.user;

const [result] = await ctx.db.insert(myTable)
.values({ ...input, tenantId })
.returning();

return result;
}),

update: protectedProcedure
.input(z.object({
id: z.string().uuid(),
data: updateMyEntitySchema,
}))
.mutation(async ({ ctx, input }) => {
const { tenantId } = ctx.session.user;

const [result] = await ctx.db.update(myTable)
.set(input.data)
.where(
and(
eq(myTable.id, input.id),
eq(myTable.tenantId, tenantId),
)
)
.returning();

return result;
}),

delete: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const { tenantId } = ctx.session.user;

// Soft Delete
await ctx.db.update(myTable)
.set({ deletedAt: new Date() })
.where(
and(
eq(myTable.id, input.id),
eq(myTable.tenantId, tenantId),
)
);
}),
});

Schritt 3: Router registrieren

In src/server/trpc/router.ts:

import { myDomainRouter } from './routers/my-domain';

export const appRouter = createTRPCRouter({
// ... bestehende Router
myDomain: myDomainRouter,
});

Schritt 4: Im Client verwenden

// Query
const { data, isLoading } = trpc.myDomain.list.useQuery({ status: 'active' });

// Mutation
const createMutation = trpc.myDomain.create.useMutation({
onSuccess: () => {
utils.myDomain.list.invalidate();
toast.success('Erstellt');
},
});

Checkliste

  • Zod-Schema für alle Inputs definiert
  • protectedProcedure für Auth-Check
  • Tenant-Isolation: tenantId aus ctx.session.user
  • Permission-Check für Rollen-Einschränkungen
  • Soft-Delete (deletedAt) statt Hard-Delete
  • Kein console.log im Production-Code
  • Keine Secrets im Code
  • Tests geschrieben

REST-Route anlegen (Sonderfälle)

Für REST-Endpoints unter src/app/api/v1/{domain}/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/server/auth';
import { rateLimit, getRateLimitKey } from '@/lib/rate-limit';

export async function POST(request: NextRequest) {
// 1. Rate Limiting
const key = getRateLimitKey(request, 'my-action');
const { success } = rateLimit(key, { limit: 10, windowSeconds: 60 });
if (!success) {
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 });
}

// 2. Auth
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

// 3. Validierung + Logik
// ...
}

Timezone-Hinweis

Für Datumswerte immer Europe/Berlin verwenden:

// FALSCH:
const today = new Date().toISOString().slice(0, 10);

// RICHTIG:
const today = new Date().toLocaleDateString('sv-SE', {
timeZone: 'Europe/Berlin',
});