| name | platform-api-layer |
| description | API layer guide for the `apps/platform` workspace. Use when adding new API endpoints, creating tRPC routers, writing client services, or wiring up data fetching from stores or components. Triggers on any task involving API calls, tRPC procedures, server-side data access, or client-side service methods. |
Platform โ API Layer
The platform uses tRPC as its sole API communication layer. There is no plain REST fetch code in components or stores โ all data fetching flows through a single pre-configured tRPC client instance.
Architecture Overview
Zustand Store Action
โโโ service/<entity>/client.ts (client service โ wraps lambdaClient calls)
โโโ lib/trpc/client/lambda.ts (single tRPC client instance โ /api/trpc/lambda)
โโโ app/(backend)/api/trpc/lambda/route.ts (Next.js route handler)
โโโ server/routers/lambda/<entity>.ts (tRPC router per entity)
โโโ server/service/<entity>/ (server-side business logic)
โโโ database/models/<entity>.ts (Drizzle ORM queries)
1. The Single tRPC Client Instance
There is one pre-configured tRPC client, created in lib/trpc/client/lambda.ts. Never create additional tRPC client instances elsewhere.
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import superjson from "superjson";
import type { LambdaRouter } from "@/server/routers/lambda";
const customHttpBatchLink = httpBatchLink({
url: `/api/trpc/lambda`,
transformer: superjson,
maxURLLength: 2083,
});
export const lambdaClient = createTRPCClient<LambdaRouter>({ links: [customHttpBatchLink] });
export const lambdaQuery = createTRPCReact<LambdaRouter>();
export const lambdaQueryClient = lambdaQuery.createClient({ links: [customHttpBatchLink] });
Import rule: Always import from @/lib/trpc/client:
import { lambdaClient } from '@/lib/trpc/client/lambda';
2. tRPC Router Definition
Each entity gets its own router file under server/routers/lambda/<entity>.ts. All routers are combined in server/routers/lambda/index.ts into the root lambdaRouter.
Router file pattern
import { z } from 'zod';
import { authedProcedure, router } from '@/lib/trpc/lambda';
import { serverDatabase } from '@/lib/trpc/lambda/middleware';
import { OrganizationModel } from '@/database/models/organization';
import { OrganizationService } from '@/server/service/organization';
const organizationProcedure = authedProcedure
.use(serverDatabase)
.use(async (opts) => {
const { ctx } = opts;
return opts.next({
ctx: {
organizationModel: new OrganizationModel(ctx.serverDB, ctx.userId),
organizationService: new OrganizationService(ctx.serverDB, ctx.userId),
},
});
});
export const organizationRouter = router({
getAll: organizationProcedure.query(async ({ ctx }) => {
return ctx.organizationService.getAll();
}),
getById: organizationProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.organizationService.getById(input.id);
}),
syncFromGitHub: organizationProcedure
.input(z.object({ accessToken: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.organizationService.syncFromGitHub(input.accessToken);
}),
delete: organizationProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.organizationService.delete(input.id);
}),
});
Registering a new router
Add every new entity router to server/routers/lambda/index.ts:
import { router } from '@/lib/trpc/lambda';
import { organizationRouter } from './organization';
import { installationRouter } from './installation';
export const lambdaRouter = router({
organization: organizationRouter,
installation: installationRouter,
});
export type LambdaRouter = typeof lambdaRouter;
3. Client Service
The client service wraps lambdaClient calls into a typed class. It lives in service/<entity>/client.ts and is exported as a singleton. Stores and components import the singleton โ they never call lambdaClient directly.
import { lambdaClient } from '@/lib/trpc/client/lambda';
import { OrganizationItem } from '@/store/organization/initialState';
export class OrganizationClientService {
getAll = async (): Promise<OrganizationItem[]> => {
const result = await lambdaClient.organization.getAll.query();
return result.map((org) => ({
id: org.id,
githubOrgId: org.githubOrgId,
login: org.login ?? undefined,
}));
};
syncFromGitHub = async (accessToken: string): Promise<void> => {
await lambdaClient.organization.syncFromGitHub.mutate({ accessToken });
};
getById = async (id: string) => {
return lambdaClient.organization.getById.query({ id });
};
delete = async (id: string) => {
return lambdaClient.organization.delete.mutate({ id });
};
}
export const organizationClientService = new OrganizationClientService();
service/<entity>/index.ts re-exports from client.ts:
export * from "./client";
4. Consuming the Client Service from a Zustand Store
Store actions call the client service singleton and update state:
import { organizationClientService } from '@/service/organization';
fetchOrganizations: async () => {
const organizations = await organizationClientService.getAll();
get().internal_updateOrganizations(organizations);
},
syncFromGitHub: async () => {
const token = await getSupabaseAccessToken();
await organizationClientService.syncFromGitHub(token);
await get().fetchOrganizations();
},
5. Using tRPC React-Query Hooks in Components
For data fetching directly in React components (where React-Query is preferred over Zustand), use lambdaQuery:
"use client"
import { lambdaQuery } from '@/lib/trpc/client/lambda';
export function OrganizationList() {
const { data, isLoading } = lambdaQuery.organization.getAll.useQuery();
}
6. Procedure Types
| Procedure type | When to use | tRPC method |
|---|
authedProcedure | Any endpoint requiring a logged-in user | base for all entity procedures |
publicProcedure | Endpoints that do not require auth (rare) | standalone use |
.query() | Read-only data fetching | lambdaClient.<router>.<proc>.query() |
.mutation() | Create, update, delete operations | lambdaClient.<router>.<proc>.mutate() |
Always use authedProcedure from @/lib/trpc/lambda for any endpoint that accesses user data. Never use publicProcedure for protected resources.
7. Input Validation
All procedure inputs must be validated with zod. Define schemas inline or import from types/:
import { z } from 'zod';
.input(z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100),
}))
8. File & Naming Conventions
| Artifact | Location | Naming |
|---|
| tRPC router | server/routers/lambda/<entity>.ts | organizationRouter, installationRouter |
| Root router | server/routers/lambda/index.ts | lambdaRouter |
| Server-side service | server/service/<entity>/ | OrganizationService class |
| Client service file | service/<entity>/client.ts | OrganizationClientService class |
| Client service singleton | service/<entity>/client.ts | organizationClientService |
| tRPC client setup | lib/trpc/client/lambda.ts | lambdaClient, lambdaQuery |
| tRPC server init | lib/trpc/lambda/init.ts | trpc (internal only) |
| tRPC server exports | lib/trpc/lambda/index.ts | router, authedProcedure, publicProcedure |
9. Checklist for Adding a New Entity Endpoint
- Server service โ add logic in
server/service/<entity>/
- Database model โ add query in
database/models/<entity>.ts
- tRPC router โ create
server/routers/lambda/<entity>.ts with procedures
- Register router โ add to
server/routers/lambda/index.ts
- Client service โ add method in
service/<entity>/client.ts wrapping lambdaClient
- Store action โ call client service from
store/<entity>/slices/<entity>/action.ts
Do not call lambdaClient directly from components or pages. Always go through the client service singleton.