tRPC and Server Actions
Eventuall uses two complementary approaches for server communication: tRPC for type-safe API calls (queries and mutations) and Server Actions for form submissions. Understanding when to use each is important.
tRPC: Type-Safe API Layer
tRPC gives you end-to-end type safety without code generation. When you define a procedure on the server, the client automatically knows the input types, output types, and available methods. Change a field name on the server, and TypeScript will immediately flag every client that uses the old name.
How tRPC Is Set Up
The tRPC setup spans three files:
apps/webapp/src/server/api/trpc.ts— defines the context (session, database, worker client) and procedure builders (public, protected)apps/webapp/src/server/api/root.ts— registers all routers into a singleappRouterapps/webapp/src/trpc/— client-side and server-side tRPC wrappers
The context creation function provides every procedure with:
db— Drizzle ORM database clientsession— current user's NextAuth session (or null)env— environment variablesshopify— Shopify client (for storefront integration)worker— Hono client for communicating with Cloudflare Workers
The Router Tree
All routers are registered in apps/webapp/src/server/api/root.ts:
export const appRouter = createTRPCRouter({
event: eventRouter, // Events, rooms (nested sub-routers)
user: userRouter, // Users, roles, permissions
account: accountRouter, // Account management
asset: assetRouter, // Image/video uploads
livekit: livekitRouter, // LiveKit token generation
twilio: twilioRouter, // Chat, OTP verification
cloudcaster: cloudcasterRouter, // Recording management
room: roomRouter, // Room listing, participants
config: configRouter, // App configuration
telemetry: telemetryRouter, // Client-side logging
mcp: mcpRouter, // API key management
order: orderRouter, // Order processing
});
Some routers have nested sub-routers. For example, userRouter contains permission and role sub-routers, and eventRouter contains a room sub-router which itself contains an invitation sub-router.
Writing a Query
A query reads data. It takes an optional input schema (validated with Zod) and returns data:
// In a router file
getEventById: protectedProcedure
.input(z.object({ eventId: z.string() }))
.query(async ({ input, ctx }) => {
return ctx.db.query.events.findFirst({
where: eq(events.id, input.eventId),
with: { rooms: true },
});
}),
The input is automatically validated before the handler runs. If validation fails, the client receives a structured error with per-field messages.
Writing a Mutation
A mutation changes data. Same pattern, but uses .mutation():
createEventWithDefaults: protectedProcedure
.input(z.object({
name: z.string().min(3),
accountId: z.string(),
}))
.mutation(async ({ input, ctx }) => {
const [event] = await ctx.db
.insert(events)
.values({ ...input, status: "draft" })
.returning();
// Also creates a default room
await ctx.db.insert(rooms).values({
eventId: event.id,
name: "Main Room",
isMainRoom: true,
});
return event;
}),
Using tRPC in Server Components
In Server Components, tRPC calls are direct function invocations — no HTTP requests involved:
// apps/webapp/src/app/event/[eventId]/page.tsx (Server Component)
import { api } from "@/trpc/server";
export default async function EventPage({ params }) {
const { eventId } = await params;
const event = await api.event.getEventById({ eventId });
return <h1>{event.name}</h1>;
}
The api import from @/trpc/server creates a "caller" that invokes procedures directly on the server. This is fast — no serialization or HTTP overhead.
Using tRPC in Client Components
In Client Components, tRPC uses React Query hooks that make HTTP requests to /api/trpc:
// Client Component
"use client";
import { api } from "@/trpc/client";
export function EventList() {
const { data, isLoading } = api.event.getMyEvents.useQuery();
const createEvent = api.event.createEventWithDefaults.useMutation({
onSuccess: () => {
// Invalidate the query cache to refetch
utils.event.getMyEvents.invalidate();
},
});
// ...
}
The client setup (apps/webapp/src/trpc/client.tsx) configures:
- HTTP batch stream link — batches multiple queries into a single HTTP request
- SuperJSON transformer — serializes Dates, Maps, Sets (which JSON.stringify can't handle)
- 30-second stale time — cached data is considered fresh for 30 seconds before refetching
- Development logger — logs all tRPC requests in development mode
Pitfall: The server-side
apiand client-sideapiare different objects with different APIs. The server-side one is a direct caller (returns Promises). The client-side one returns React Query hooks (.useQuery(),.useMutation()). Mixing them up causes confusing TypeScript errors.
Prefetching for Hydration
A common pattern is prefetching data on the server and hydrating the client:
// Server Component
export default async function Page() {
// Prefetch data on the server
void api.event.getCategories.prefetch();
return (
<HydrateClient>
<ClientComponent /> {/* Will use cached data immediately */}
</HydrateClient>
);
}
The HydrateClient component (from @/trpc/server) dehydrates the query cache and sends it to the client. The Client Component's useQuery hook picks up the cached data without making another request.
Server Actions: Form Handling
Server Actions are functions that run on the server but can be called directly from Client Components, typically as form actions. Eventuall uses next-safe-action to add validation, error handling, and authentication to Server Actions.
The Safe Action Chain
The base setup in apps/webapp/src/server/safe-action.ts defines three action clients:
publicActionClient— no authentication required. Includes Zod validation and error formatting.authenticatedActionClient— extends public with session validation. Providesctx.userandctx.db.adminActionClient— extends authenticated with role checking (not currently used, but available).
Each client adds middleware layers:
publicActionClient
→ Zod validation
→ Error formatting
→ Action handler
authenticatedActionClient
→ All of the above
→ Session check (throws ActionError if not logged in)
→ Provides user context
→ Action handler
Writing a Server Action
Server Actions live in apps/webapp/src/server/actions/ and are marked with "use server":
// apps/webapp/src/server/actions/event.ts
"use server";
import { authenticatedActionClient } from "@/server/safe-action";
import { z } from "zod";
export const createVettingChannelAction = authenticatedActionClient
.metadata({ actionName: "createVettingChannel" })
.schema(z.object({
eventId: z.string(),
roomId: z.string(),
userId: z.string(),
}))
.action(async ({ parsedInput, ctx }) => {
// Create Twilio conversation for vetting
// ...
return { success: true, conversationSid };
});
Using Server Actions in Forms
In Client Components, use the useAction hook from next-safe-action:
"use client";
import { useAction } from "next-safe-action/hooks";
import { createVettingChannelAction } from "@/server/actions/event";
export function VettingButton({ eventId, roomId, userId }) {
const { execute, isExecuting } = useAction(createVettingChannelAction, {
onSuccess: (data) => toast.success("Vetting channel created"),
onError: (error) => toast.error(error.serverError),
});
return (
<button
onClick={() => execute({ eventId, roomId, userId })}
disabled={isExecuting}
>
{isExecuting ? "Creating..." : "Start Vetting"}
</button>
);
}
Login Actions
The login flow uses Server Actions rather than tRPC because it involves cookie manipulation and redirects, which fit the Server Action model better. The login actions in apps/webapp/src/server/actions/login.ts include:
loginWithGoogleAction— redirects to Google OAuthloginWithCredentaionsAction— validates OTP code and creates session cookieloginWithEmailPasswordAction— validates email/password and creates session cookie
Architect's Note: The credential-based login actions manually create sessions and set cookies, bypassing NextAuth's standard flow. This is because NextAuth's Credentials provider doesn't natively support the database session strategy. If NextAuth adds this support in a future version, these custom actions could be simplified.
When to Use Which
| Scenario | Use | Why |
|---|---|---|
| Fetching data for display | tRPC query | Automatic caching, refetching, loading states |
| Submitting a form | Server Action | Progressive enhancement, built-in validation |
| Mutating data from a button click | tRPC mutation | Optimistic updates, cache invalidation |
| Authentication (login/logout) | Server Action | Needs cookie access and redirects |
| Real-time data | tRPC query with refetchInterval |
React Query handles polling |
| File upload | tRPC mutation (for presigned URL) + fetch | tRPC generates the URL, browser uploads directly to R2 |
The general rule: if it's a form with inputs, use Server Actions. If it's data fetching or programmatic mutations, use tRPC.
Error Handling
Both systems have structured error handling:
tRPC errors use TRPCError with standard codes (UNAUTHORIZED, NOT_FOUND, BAD_REQUEST). Zod validation errors are automatically formatted and sent to the client.
Server Action errors use ActionError with a message and optional code. The handleServerError function in the safe-action setup catches exceptions and returns user-friendly messages without leaking internal error details.
Pitfall: tRPC errors are caught by React Query's error handling. Server Action errors are caught by
next-safe-action's error handling. They use different APIs on the client:error.messagefor tRPC vs.result.serverErrorfor Server Actions. Don't mix the patterns.