
← Back
16.3.26
From GraphQL Schemas to Custom RPC: Why We Built Our Own Solution
How we ditched GraphQL friction for a custom TS-based RPC that eliminates boilerplate and boosts developer velocity on our Optimus platform.

ByIdo Elyakim
Full-Stack Engineer
Background
Our internal admin platform, Optimus, is a React frontend backed by a Node.js/TypeScript backend. For years, GraphQL served as the communication layer between the two. Over time, however, the friction introduced by GraphQL's schema-driven approach became a real drag on developer velocity - especially for a codebase where a single team owns both sides of the stack.
This post covers why we moved away from GraphQL, the alternatives we evaluated, and how we ended up building a custom RPC layer that generates fully typed frontend hooks directly from backend function signatures.
The Problem with GraphQL (For Us)
GraphQL is a powerful technology, and this isn't a critique of GraphQL in general. But in our specific context — an internal tool where one team controls both frontend and backend - it introduced overhead that outweighed its benefits.
Too many files per feature
Adding a single endpoint required touching roughly 15 files scattered across the project:
For what was fundamentally "call a function and return data," the ceremony was disproportionate.
GraphQL-specific limitations
We hit recurring friction with GraphQL:
Evaluating Alternatives
Given the mounting complaints, we set out to find a better approach. Our core requirements were:
We evaluated several options:
The deciding factor was our requirement for a schema-less solution. We didn't want developers writing any extra type definitions, validation schemas, or configuration beyond the backend function itself. Most off-the-shelf solutions still required some form of schema or validation layer to generate frontend types.
Since no existing tool matched our needs, we built our own.
How the Custom RPC Works
The entire system hinges on a single convention: backend functions annotated with @OptimusResolver become API endpoints automatically.
Writing an endpoint
A developer writes a standard TypeScript function in a file with the Resolvers postfix:
1 @OptimusResolver({ 2 resolverType: ResolverType.Mutation, 3 permission: Permission.Admin 4 }) 5 export async function stageLiveopGroup( 6 configId: string, 7 params: LiveopGroupParams 8 ): Promise { 9 return await liveopService.stageGroups(configId, params); 10 }That's the entire contract. The function's parameter types become the input. The return type becomes the output. The decorator specifies whether it's a query or mutation and what permissions are required.
No schema files. No converters. No fragments.
The codegen pipeline
From that single function, a four-step codegen process generates everything needed:
All @OptimusResolver functions are scanned. Their parameter types and return types are extracted and compiled into a JSON Schema. Function parameters are merged into a single input object (named {functionName}Input ).
The JSON Schema types are wrapped in a full OpenAPI spec. Each annotated function becomes an endpoint (e.g., /rpc/stageLiveopGroups ) with its input, output, and HTTP method defined.
The OpenAPI spec is fed into , which generates fully typed React Query hooks. The resolverType (query vs. mutation) determines whether the generated hook uses useQuery (with caching) or useMutation (with callbacks).
A generated imports file collects all resolver functions. At server startup, these imports trigger the @OptimusResolver decorator logic, which registers each function into a central map. The createAutoRoutes function then iterates this map and creates the corresponding Express endpoints.
The entire pipeline runs as part of the existing codegen stage and completes in seconds - compared to the minute-plus cycle of building, starting the server, and running GraphQL codegen.
Using the generated hooks
On the frontend, using an endpoint looks like this:
1 const { mutate: stageLiveop } = useStageLiveopGroup(); 2 3 stageLiveop({ 4 configId: "abc-123", 5 params: { name: "Holiday Event", segments: [...] } 6 });Full type safety. Autocomplete for every parameter. Compile-time errors for type mismatches. No manual type wiring required.
What We Gained
Fewer files, faster development
Adding a new endpoint now means editing one file instead of many files (we even had cases of fifteen files...). The cognitive overhead of navigating scattered schema, converter, and fragment files is gone.
Types defined once
Backend TypeScript types are the single source of truth. No more manual translation between TypeScript → GraphQL → TypeScript.
No learning curve
Developers only need to know TypeScript. There are no GraphQL-specific concepts to learn - no DataLoaders, no resolver types, no fragment composition.
Faster feedback loop
Codegen runs in seconds as part of the existing build pipeline. No need to start a local server or run a separate frontend codegen step.
Caching and permissions preserved
React Query handles caching through the query/mutation distinction. Permissions are declared in the decorator and enforced at the route level, just as they were with GraphQL.
Runtime validation
Because we already had a generated OpenApi schema we were capable of adding a middleware for runtime validating the request and the response by the schema.
Trade-offs
This approach is not without trade-offs, and we want to be transparent about them:
Conclusion
GraphQL wasn't wrong for us when we adopted it. But as our codebase grew and our team's needs evolved, the schema-driven approach created friction that a schema-less solution could eliminate.
When we couldn't find an off-the-shelf tool that matched our specific requirements - no schema, types derived purely from TypeScript function signatures - we built our own. The result is a custom RPC layer that's simpler, faster, and easier to maintain than what it replaced.
The key lesson: when you own both ends of the wire and the existing tooling creates more overhead than value, don't be afraid to question it - even if it's a widely-adopted industry standard.