I have a problem.
Every time I start building a Next.js application, I find myself fighting the exact same set of friction points:
- Fragile Type Safety: Type safety that works beautifully on the server but quietly disintegrates or requires manual alignment across the network boundary.
- Dev-Loop Bloat: Codegen tools and compilation steps that add 15 to 30 seconds of waiting time to my dev loop.
- Imprecise Error Handling: Catching errors that is either overly verbose (try-catch blocks wrapping every handler) or non-existent.
- Manual Plumbing: Wiring up Redis caching, rate limiting, and circuit breakers manually, procedure by procedure, project by project.
So, I did the classic, slightly unhealthy developer thing: I built my own solution.
Itβs called Actyx RPC. And before you roll your eyes at the mention of another "typesafe RPC framework" (trust me, I know there are like seventeen of these now) β hear me out. Actyx isn't just about sharing types; it's about solving the practical, everyday headaches of production backend development in TypeScript.
Here is how it works and why itβs become my go-to stack.
1. The Foundation: A Composable Base Procedure
Instead of setting up boilerplate for every route, Actyx uses a builder pattern. You define a base procedure instance that handles authentication, error mapping, telemetry, and input enrichment globally. Every procedure you create from this base builder automatically inherits these behaviors.
import { createProcedure, RedisCache } from "@explita/actyx-rpc";
import { redirect } from "next/navigation";
// Optional: Spin up Redis for cross-serverless caching and rate limiting
// const redisClient = new Redis("redis://localhost:6379");
export const procedure = createProcedure({
// cache: new RedisCache({ redis: redisClient, defaultTTL: "5m" }),
// 1. Establish the request context (Auth, session, user roles)
async createContext() {
const session = await getSession();
if (!session) {
return { ok: false, reason: "INVALID_SESSION" };
}
return {
ok: true,
ctx: {
userId: session.user.id,
role: session.user.role,
},
};
},
// 2. Automatically handle context failures (like redirects or custom errors)
// This is only called when the createContext returns an object with ok:false
onContextError: async ({ reason }) => {
const loginPath = "/login";
if (reason === "INVALID_SESSION") {
return {
_redirect: () => redirect(loginPath),
};
}
return {
success: false,
reason: "UNAUTHORIZED",
message: "Unauthorized access",
};
},
// 3. Centralized error mapping β no try/catch pyramids in your handlers!
onError({ error, ctx }) {
console.error(`[Error in ${ctx.handlerName}]:`, error);
// Map database constraints (like Prisma unique key violations) globally
if (error.code === "P2002") {
return {
message: "This record already exists",
reason: "CONFLICT",
statusCode: 409,
};
}
},
// 4. Enrich every incoming handler input with context variables automatically
enrichInput(ctx) {
return { userId: ctx.userId };
},
});
Now, every procedure created from this procedure builder gets auth context checks, global error transforms, and user ID injection with zero additional lines of code.
2. Defining and Calling Procedures
Actyx RPC keeps procedures simple. You define inputs using your validator of choice (Zod, Valibot, ArkType, Joi, Yup, or custom resolvers) and resolve them as either .query() (for reads) or .mutation() (for writes).
import { z } from "zod";
import { zodResolver } from "@explita/actyx-rpc/resolvers/zod";
import { db } from "@/lib/db";
import { procedure } from "./base-procedure";
// Create a mutation procedure
export const createPost = procedure
.name("createPost")
.input(
zodResolver(
z.object({
title: z.string().min(1),
body: z.string().min(10),
}),
),
)
.mutation(async ({ ctx, input }) => {
// ctx.userId is fully typed and available
// input.title and input.body are validated, typed, and sanitized
// input.userId is injected from enrichInput!
return await db.post.create({
data: {
title: input.title,
body: input.body,
authorId: input.userId,
},
});
});
When you call this procedure, Actyx returns a Go-style [data, error] tuple. No try/catch blocks needed:
const [post, err] = await createPost({
title: "A Brand New Post",
body: "This is a body longer than ten characters.",
});
if (err) {
toast.error(err.message);
return;
}
console.log(post.id); // 'post' is fully typed!
3. The Features That Surprised Me (DX Wins)
While building Actyx, I added a few features to solve my own development fatigue, and they turned out to be absolute game-changers:
π‘ The .mock() Method (For Parallel Development)
Seeding and maintaining local databases just to test a frontend feature is exhausting. Actyx introduces first-class mocking directly in the builder chain:
export const getUser = procedure
.name("getUser")
.mock(() => ({
id: "user_mock_123",
name: "Jane Doe (Mocked)",
email: "jane@example.com",
}))
.query(async ({ input }) => {
// This expensive DB query is skipped when MOCKing is active!
return await db.users.findUnique({ where: { id: input.id } });
});
Set ACTYX_MOCK="true" in your environment variables, and the backend handlers are bypassed. The middleware, session validation, and inputs are still processed, but your database is never touched. Frontend devs can code against a "perfect" mock state while the backend is still being designed.
π§ Caching with a Brain (SWR Built-in)
Actyx comes with Stale-While-revalidate (SWR) caching out of the box. You don't need Redis to get started; an in-memory cache adapter works by default, but you can plug in Redis as you scale:
export const getPost = procedure
.cache({
ttl: "5m", // Fresh for 5 minutes
staleTime: "1m", // Becomes stale after 1 minute
staleWhileRevalidate: true, // Serve stale data, refresh in the background
key: ({ input }) => `post:${input.id}`,
})
.query(async ({ input }) => {
return await db.post.findUnique({ where: { id: input.id } });
});
π‘οΈ Serverless-Friendly Rate Limiting
Because Actyx can plug directly into your Redis cache adapter, rate limiting persists across serverless function invocations (like Vercel Serverless/AWS Lambda instances).
export const sendMessage = procedure
.rateLimit({
limit: 10,
window: "1m",
key: (ctx) => ctx.userId, // Limit requests per user
message: "You're sending messages too fast!",
})
.mutation(async ({ input }) => {
return await messageService.send(input);
});
π Inter-Procedure Calling (Zero Network Hops)
When procedures call other procedures, network overhead and duplicated auth logic usually slow things down. Actyx uses AsyncLocalStorage under the hood. When one procedure calls another, it bypasses context re-creation and authentication entirely while keeping the context secure:
const getAuditLog = procedure.query(async ({ ctx }) => {
return await db.logs.findMany({ where: { userId: ctx.userId } });
});
export const getUserDashboard = procedure.query(async ({ ctx }) => {
const user = await db.users.findUnique({ where: { id: ctx.userId } });
// This nested call automatically inherits the parent's auth context!
const [logs] = await getAuditLog();
return { user, logs };
});
4. What Else is in the Box?
To make Actyx a production-ready choice, I added several advanced features:
- OpenTelemetry: Native instrumentation so you can trace why a specific procedure is running slow at 3 AM.
- Automatic OpenAPI Specs: Auto-generate standard OpenAPI JSON files to keep documentation from rotting.
- Circuit Breakers: Prevent cascading failures when third-party microservices go down.
- Adaptive Compression: Native support for gzip, deflate, and brotli.
- Timeouts: Safe defaults to prevent hanging server processes.
-
React Hooks: Built-in hooks (
useQuery,useMutation,useInfiniteQuery) for frontend state synchronization.
What I Haven't Figured Out Yet
Actyx is working great for my production projects, but itβs still in active development. Hereβs whatβs currently on my drawing board:
- Bidirectional WebSockets: SSE (Server-Sent Events) streaming works perfectly for one-way flows (like LLM stream responses), but full bidirectional WS subscriptions are still being refined.
- Performance Benchmarks: It feels lightning-fast under typical workloads, but I want to perform strict load/torture tests to publish concrete numbers.
If you have experience building scalable RPC networks or websocket adapters, I'd love to hear how you approached those challenges!
Give it a Spin π
Actyx RPC is entirely open-source (MIT licensed). Break it, inspect it, and tell me what you think.
-
NPM:
npm i @explita/actyx-rpc - GitHub: github.com/explita/actyx-rpc
P.S. β The name "Actyx" comes from "action" + "tyx" (as in type safety). Naming is hard. If you have a better suggestion, I'm all ears!
Top comments (0)