Home/Case Studies/Server actions as a transport layer
Migration

Server actions as a transport layer

Why the project moved from tRPC to server actions, and what that decision actually cost.

Ledger started with tRPC as the API layer. End-to-end type safety, a clean procedure model, and genuine framework portability. The switch to Next.js server actions via next-safe-action was a deliberate tradeoff: less portability, a mature middleware model, and TanStack Query for server state caching.

Why tRPC first

tRPC gives you end-to-end type safety without a code generator, a clean middleware model in `procedure.ts`, and adapters for every major framework. The portability argument was real. Swap the route handler, keep everything else. The full stack could move to SvelteKit or Nuxt with surface-level changes only.

ConcerntRPCnext-safe-action + TanStack Query
API layertRPC, adapter swap to portNext.js server actions (POST only)
AuthhttpOnly cookie via tRPC context.use(withAuth) middleware chain
Server stateTanStack Query via tRPC hooksTanStack Query. Server hydrates cache, client reads
Type safetyEnd-to-end via tRPC, no code generationTyped server action responses + Zod input schemas
MiddlewareOnce in procedure.ts, applied everywhere.use() chaining. withAuth, withFeatureFlag, withRateLimit
BundletRPC client + TanStack QueryTanStack Query only (server actions have no client bundle)

The tipping point

The tipping point was not a technical failure of tRPC. It was a scope question. This is a portfolio project, not a product targeting multiple frameworks. The portability argument is compelling in theory, but there is no SvelteKit migration on the roadmap. Carrying the tRPC mental model and the adapter wiring for a benefit that would never be realised was ceremony without payoff. next-safe-action with .use() chaining provides the same middleware model (auth, rate limiting, feature flags) with Zod schema validation and a typed error boundary.

What next-safe-action provides

next-safe-action provides the same middleware chaining model tRPC had. Each server action chains .use(withAuth).use(withFeatureFlag).inputSchema(schema). Composable, type-safe, and consistent. handleServerError is the single catch boundary that maps domain exceptions to client-facing error responses. The handleActionResponse() utility bridges the serialisation gap to TanStack Query.

next-safe-action vs tRPC procedure. Equivalent patterns

// tRPC middleware chain
const protectedProcedure = publicProcedure.use(authMiddleware);
const loginRouter = router({ login: publicProcedure.mutation(...) });

// next-safe-action .use() chaining
const createBudgetAction = actionClient
  .metadata({ actionName: 'createBudget' })
  .use(withAuth)
  .use(withFeatureFlag(FEATURE_KEYS.BUDGET_WRITE))
  .inputSchema(createBudgetSchema)
  .action(async ({ ctx, parsedInput }) => {
    return budgetsService.createBudget(ctx.userId, parsedInput.category, parsedInput.monthlyLimit);
  });

What was genuinely lost

Framework portability is the real loss. If the project ever needs to run outside of Next.js, the transport layer is now coupled to the framework. The domain core (`src/core/`) remains portable with zero Next.js dependencies. But the action layer would need to be rewritten, not just re-adapted. For a portfolio project, this is an acceptable tradeoff. For a product with an uncertain frontend future, it would not be.