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:

  1. apps/webapp/src/server/api/trpc.ts — defines the context (session, database, worker client) and procedure builders (public, protected)
  2. apps/webapp/src/server/api/root.ts — registers all routers into a single appRouter
  3. apps/webapp/src/trpc/ — client-side and server-side tRPC wrappers

The context creation function provides every procedure with:

  • db — Drizzle ORM database client
  • session — current user's NextAuth session (or null)
  • env — environment variables
  • shopify — 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 api and client-side api are 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:

  1. publicActionClient — no authentication required. Includes Zod validation and error formatting.
  2. authenticatedActionClient — extends public with session validation. Provides ctx.user and ctx.db.
  3. 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 OAuth
  • loginWithCredentaionsAction — validates OTP code and creates session cookie
  • loginWithEmailPasswordAction — 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.message for tRPC vs. result.serverError for Server Actions. Don't mix the patterns.