typescript, nextjs, trpc, graphql
GraphQL solves real problems. But most TypeScript teams in 2026 are 3 to 6 engineers, not 30.
Here are 6 concrete API layer patterns that let small teams ship fast with REST, tRPC, and Server Actions, without inheriting GraphQL infrastructure they cannot afford to maintain.
1. Replace Schema + Codegen With Type Inference
GraphQL gives you strong types, but only after defining a schema and running code generation.
Before (GraphQL + codegen)
// schema.ts
builder.queryType({
fields: (t) => ({
user: t.prismaField({
type: "User",
args: { id: t.arg.id({ required: true }) },
resolve: (query, _root, args) =>
prisma.user.findUniqueOrThrow({
...query,
where: { id: Number(args.id) },
}),
}),
}),
});
# user.graphql
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}
Then run codegen. Then import the generated hook.
After (tRPC)
// server/routers/user.ts
export const userRouter = router({
getById: publicProcedure
.input(z.object({ id: z.number() }))
.query(({ input, ctx }) => {
return ctx.db.query.users.findFirst({
where: eq(users.id, input.id),
});
}),
});
// component
const { data } = trpc.user.getById.useQuery({ id: 1 });
No schema file. No generation step. Type changes propagate instantly. That removes 5 to 15 seconds from every schema edit cycle and an entire build step from your CI.
2. Replace Flexible Queries With Use Case Procedures
GraphQL encourages one flexible query per entity. Small teams do not need that flexibility.
Before (GraphQL flexible query)
query User($id: ID!) {
user(id: $id) {
id
name
avatar
posts {
id
title
}
}
}
The client decides shape. The server must support everything.
After (tRPC use case specific)
export const userRouter = router({
getProfile: publicProcedure
.input(z.object({ id: z.number() }))
.query(({ input, ctx }) =>
ctx.db.query.users.findFirst({
where: eq(users.id, input.id),
columns: { id: true, name: true, avatar: true },
with: { posts: { limit: 5 } },
})
),
});
One procedure per screen. No generic resolver tree. No N+1 surprise. The query is explicit and optimized for that use case. Less abstraction, fewer production incidents.
3. Replace N+1 Dataloader Complexity With ORM Eager Loading
Every GraphQL team eventually fights N+1 queries.
Before (GraphQL resolver chain)
// user resolver
posts: (parent) =>
prisma.post.findMany({
where: { authorId: parent.id },
});
Without dataloaders, 50 users can trigger 50 post queries.
After (tRPC + Drizzle eager loading)
getUsersWithPosts: publicProcedure.query(({ ctx }) =>
ctx.db.query.users.findMany({
with: { posts: true },
})
);
One SQL query with joins. No dataloader layer. No resolver waterfall. If you want deeper discussion on how this compounds with database choices, it ties directly into the patterns in JavaScript application architecture in 2026.
Fewer moving parts means fewer scaling surprises.
4. Replace Heavy Apollo Client With Lightweight tRPC Client
Apollo Client adds roughly 30KB gzipped. It brings caching, normalization, and complexity.
Before (Apollo)
import { useQuery, gql } from "@apollo/client";
const GET_USER = gql`
query GetUser($id: ID!) {
user(id: $id) {
id
name
}
}
`;
const { data } = useQuery(GET_USER, { variables: { id: 1 } });
After (tRPC client)
const { data } = trpc.user.getById.useQuery({ id: 1 });
tRPC client is around 5KB. No normalized cache. For most SaaS dashboards, React Query level caching is enough. You trade theoretical flexibility for a 20KB plus bundle reduction and simpler mental model.
5. Replace Separate API Layer With Server Actions for Simple Mutations
Not every mutation needs an API endpoint.
Before (REST endpoint)
// /api/users.ts
app.post("/api/users", async (req, res) => {
const user = await db.insert(users).values(req.body).returning();
res.json(user);
});
Client:
await fetch("/api/users", {
method: "POST",
body: JSON.stringify(data),
});
After (Next.js Server Action)
// app/actions/users.ts
"use server";
export async function createUser(data: FormData) {
return db.insert(users).values({
name: data.get("name"),
email: data.get("email"),
});
}
<form action={createUser}>
<input name="name" />
<input name="email" />
<button type="submit">Create</button>
</form>
No JSON serialization. No fetch. No loading state for first render. For form bound mutations used in one place, this removes an entire API route and its tests.
6. Keep REST Only Where It Actually Wins
REST is still the correct choice for external consumers and webhooks.
Stripe webhook with Hono
app.post("/api/webhooks/stripe", async (c) => {
const signature = c.req.header("stripe-signature");
const body = await c.req.text();
const event = stripe.webhooks.constructEvent(
body,
signature!,
process.env.STRIPE_SECRET!
);
if (event.type === "checkout.session.completed") {
// handle event
}
return c.json({ received: true });
});
You do not want this going through GraphQL or tRPC. It is plain HTTP. External systems expect status codes and raw bodies. Keep REST at the boundary. Use tRPC internally.
This hybrid model is what most small production teams converge on:
tRPC for internal authenticated app logic.
Server Actions for local form mutations.
REST for webhooks and public endpoints.
That stack removes schema files, codegen pipelines, resolver trees, and heavy clients while preserving type safety where it matters most.
If you are a 4 person TypeScript team building a single Next.js product, start with tRPC plus Server Actions. Add REST at the edges. Reach for GraphQL only when you truly have multiple heterogeneous clients that need flexible traversal.
The API layer is not a philosophical choice. It is a staffing decision. Optimize for the number of engineers you actually have, not the architecture a 200 person org can afford.
Top comments (0)