GraphQL vs tRPC for Vibe Projects: Picking the Right API Layer
AI writes REST fast and sloppy — one renamed field crashes frontend and backend together. This guide gives solo TypeScript teams a clear stance: default to tRPC for free end-to-end type safety, switch to GraphQL only on three signals. Includes a runnable tRPC + Next.js setup, schema-first GraphQL with codegen, the DataLoader fix for AI-generated N+1 resolvers, depth-limit and complexity guardrails, auth via middleware vs directives, the decision tree, and a 5-step REST-to-tRPC migration SOP.

Here's a failure mode you've probably already lived through: you ask AI to build you a set of REST endpoints. Half an hour later it hands you 20 routes, Postman says everything passes, and you're happy. Two weeks later you rename title to headline on the Post table. You update the backend, miss one page on the frontend, and production goes white-screen — while TypeScript stays completely silent, because HTTP JSON severed the type chain the moment it serialized. The worst part is the debugging: first you suspect the cache, then you suspect the AI wrote a broken component, and only at the end do you discover it was that field rename from two weeks ago. That loop — change one thing, break another, spend half a day finding it — is the daily tax of the REST + AI combo.
That's the real tension of the API layer in the vibe coding era: AI writes REST fast and sloppy, and one type change crashes frontend and backend together. What you're missing isn't more endpoints — it's an API design that catches "frontend and backend disagree" at compile time. This guide takes a clear stance: for a solo TypeScript full-stack team, tRPC is the default. It also covers when GraphQL is genuinely worth it, and the two AI-specific pitfalls of generating code for either (N+1 and over-fetching), each with copy-paste-ready fixes.
Reading guide: Section 1 has the comparison table and the default recommendation. If you're already on tRPC, jump to Sections 4–5 for the AI-specific pitfalls. If you're debating GraphQL, the decision tree in Section 7 was drawn for you. If you've got a pile of REST to migrate, Section 8 is a 5-step SOP. Every code sample is written in runnable shape — paste it in and rename the fields to match your tables.
Three protocols, one table: REST vs tRPC vs GraphQL
Let's lay out the four dimensions before anyone picks a stack because "I heard GraphQL is hot":
| Dimension | REST | tRPC | GraphQL |
|---|---|---|---|
| Type safety | Hand-written types or OpenAPI codegen — drifts easily: the backend changes a field and nobody remembers to regenerate the frontend types | End-to-end TypeScript inference: the router type is exported straight to the client via typeof; rename one field and the compiler catches the whole chain | Strongly-typed schema + codegen: type safety lives at the schema layer, and both sides answer to the schema |
| Mental overhead | Lowest — everyone knows it, most debugging tools | Low (TS full-stack only): no schema files to maintain, no codegen step | High: schema design, resolvers, DataLoader, query guards — each a new concept |
| Debuggability | curl / Postman directly, CDN and HTTP caching work out of the box | HTTP layer is batched POSTs, less readable; relies on tRPC Panel or client devtools | GraphiQL is self-documenting WYSIWYG; but errors hide inside data.errors and need separate monitoring |
| Ecosystem maturity | Highest: gateways, caching, rate limiting are all off-the-shelf | Mature inside the TS ecosystem (Next.js / React / Express adapters all exist), weak multi-language clients | Most mature multi-client ecosystem: iOS / Android / Web clients, gateways, persisted queries |
| AI-generation friendliness | AI writes it most fluently — but the type-drift pit is deepest | AI writes routers smoothly, and the compiler backstops field renames | AI-generated resolvers almost always hit N+1 (see Section 4) |
The default recommendation for a one-person team is one sentence: TS full-stack + the same person (you) consuming the API → default to tRPC. Reserve REST for two cases: third-party webhook callbacks, and public read endpoints that benefit from strong HTTP caching (for the versioning and deprecation workflow of those, see "API 别翻车:vibe 项目的版本管理与废弃 SOP" — not repeated here). GraphQL is the heavy weapon you deploy only when specific signals appear — the signal list is in Section 7.
Why default to tRPC instead of the more "popular" GraphQL? Do the math: GraphQL's schema design + codegen pipeline + DataLoader + query guards take one person 2–3 days to set up, and every future feature starts with writing schema before resolvers. tRPC's type safety is free — you get the types while writing the router functions. For a solo team, what you save isn't lines of code, it's "one fewer mental model to maintain".
One more AI-era angle: type drift with AI-written REST is practically guaranteed. Its workflow is "write the handlers first, then add a types.ts", and there is no machine-verifiable link between the two — the backend changes limit from number to string, the frontend's types.ts doesn't change on its own, and the AI won't proactively edit a file that "looks fine". tRPC welds "types are the implementation" shut: types are inferred from the router, so when the implementation changes the types must change, and no amount of local AI edits can dodge the compiler. That's a hard boundary for AI work: let machines check machines instead of humans checking machines.
And to be fair to REST — no "REST funeral" here: when your API must serve Python scripts, Go services, or hardware devices, tRPC's TypeScript lock-in is a liability; when your read endpoints have high QPS and suit CDN caching, REST's cacheable GETs beat both GraphQL and tRPC (batched POST by default). There's no hierarchy of coolness in API choice, only "who pays the cost" — and a solo team shouldn't pick costs it can't afford.
tRPC in practice: router definition and Next.js App Router integration
tRPC boils down to three things: initialization, routers, procedures. Here's a minimal setup that actually runs:
// server/trpc.ts —— initialize once, reuse across the project
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.context<{ db: Db; session: Session | null }>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
// server/routers/post.ts —— business router: functions are the API
import { router, publicProcedure } from '../trpc';
import { z } from 'zod';
export const postRouter = router({
list: publicProcedure
.input(z.object({ limit: z.number().min(1).max(50).default(20) }))
.query(async ({ input, ctx }) => {
return ctx.db.post.findMany({
take: input.limit,
orderBy: { createdAt: 'desc' },
});
}),
create: publicProcedure
.input(z.object({
title: z.string().min(1).max(120),
body: z.string().min(1),
}))
.mutation(async ({ input, ctx }) => {
return ctx.db.post.create({ data: { ...input, authorId: ctx.session!.user.id } });
}),
});
// server/routers/_app.ts —— compose the root router and export its type
import { router } from '../trpc';
import { postRouter } from './post';
export const appRouter = router({ post: postRouter });
// The key line: the type is inferred straight from the implementation. No codegen.
export type AppRouter = typeof appRouter;
Integrating with Next.js App Router takes a single catch-all route:
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers/_app';
import { db } from '@/server/db';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => ({ db, session: null }), // plug your auth logic in here
});
export { handler as GET, handler as POST };
On the client (React Query style, with @trpc/react-query):
// Call it directly in components — inputs and return values fully typed
import { trpc } from '@/lib/trpc';
function PostList() {
const { data, isLoading } = trpc.post.list.useQuery({ limit: 10 });
// data is inferred as Post[]; passing a string as limit is a compile error
const create = trpc.post.create.useMutation();
// ...
}
The end-to-end type safety demo is where tRPC really earns its keep. Try this: rename title to headline in the router's create input, hit save, and every call site passing { title } to trpc.post.create.useMutation() lights up red instantly — the compiler just ran your full-chain regression for you. Compare with REST: you rename a backend field and grep for the "title" string hoping you catch every frontend usage; miss one and it's a production incident. The AI era amplifies this difference: AI renames things aggressively when refactoring, and only a tRPC backstop lets you let it refactor freely.
One practical convention: split routers by business domain (post.ts, user.ts, billing.ts), but keep exactly one AppRouter. Splitting too finely makes client import paths confusing; for a solo project, splitting by page aggregation is enough.
Three tRPC details the AI won't volunteer:
- httpBatchLink merges requests. Multiple
useQuerycalls within ~100ms get packed into one HTTP POST. Fewer requests, but a fatter payload and a network panel full of batched blobs. Use plainhttpLink(no batching) during development for readability, switch back to batch for production. - Don't go through HTTP in Server Components. In Next.js App Router RSC, call the router directly with
createCallerinstead of an HTTP round trip:const caller = appRouter.createCaller(ctx); const posts = await caller.post.list({ limit: 10 });. Same full types, faster. - Two common AI mistakes with tRPC: zod schemas drifting from the Prisma model — e.g. Prisma says
titleis optional but zod saysz.string()required, so users get a 400 for skipping the title and the AI insists "stricter validation is good"; and vague procedure names (getData,updateThing) that you won't understand in three months. Set the naming rule now —resource.action(post.list,post.create) — and bake it into the prompt you give the AI.
And tRPC is no silver bullet — know its two hard limits upfront. First, TypeScript lock-in: when you need to expose endpoints to Python scripts or Go services, there are no first-party tRPC clients (the community trpc-openapi plugin can export your router as REST + OpenAPI docs — an escape hatch, but you lose the type-safety advantage). Second, POST by default: you don't get REST's free lunch of HTTP and CDN edge caching. That's exactly why Section 1 says: keep REST for public read endpoints and high-QPS cacheable scenarios — don't unify stacks for the sake of unifying.
GraphQL in practice: schema-first and gradual adoption
If you've hit one of the Section 7 switch signals, the correct GraphQL posture is schema-first: write the schema, then the resolvers, then codegen the TS types. Reversing the order (resolvers first, schema as an afterthought) is AI's favorite pattern, and it produces a schema and an implementation that are two different skins.
# schema.graphql —— contract first, implementation second
type Post {
id: ID!
title: String!
body: String!
author: User!
createdAt: String!
}
type User {
id: ID!
name: String!
}
type Query {
posts(limit: Int = 20): [Post!]!
post(id: ID!): Post
}
type Mutation {
createPost(title: String!, body: String!): Post!
}
// resolvers.ts —— resolvers just fill in the schema fields
export const resolvers = {
Query: {
posts: (_: unknown, { limit }: { limit: number }, { db }: Ctx) =>
db.post.findMany({ take: limit, orderBy: { createdAt: 'desc' } }),
},
Mutation: {
createPost: (_: unknown, { title, body }: { title: string; body: string }, { db, user }: Ctx) =>
db.post.create({ data: { title, body, authorId: user.id } }),
},
};
Frontend types come from codegen, never hand-written:
// codegen.ts —— @graphql-codegen/cli config
import type { CodegenConfig } from '@graphql-codegen/cli';
const config: CodegenConfig = {
schema: './schema.graphql',
documents: 'app/**/*.{ts,tsx}', // scans graphql`` template strings in the frontend
generates: {
'lib/gql/': { preset: 'client' }, // generates the type-safe document helper
},
};
export default config;
// Frontend: the query itself is type-checked; a wrong field is a compile error
import { graphql } from '@/lib/gql';
const PostsQuery = graphql(`
query Posts($limit: Int) {
posts(limit: $limit) {
id
title
author { name }
}
}
`);
// useQuery(PostsQuery, { variables: { limit: 10 } }) —— variables and result both typed
Coexisting with your existing REST is the only realistic path for a solo team: don't rewrite. Mount the GraphQL service at /api/graphql (or its own port) and have resolvers call your existing service functions directly — GraphQL is just an extra "query orchestration" layer; the data logic doesn't move. Migrate 1–2 queries with the biggest cross-client field differences first (e.g. the slimmed-down feed for mobile), let them run stable for two weeks, then expand. Leave the REST routes untouched until GraphQL coverage is complete, then retire them per the deprecation SOP ("API 别翻车:vibe 项目的版本管理与废弃 SOP").
Don't skip two codegen ergonomics items: add "gql:watch": "graphql-codegen --watch" to package.json so frontend types regenerate whenever the schema changes — otherwise you'll eventually hit the haunted "schema changed but types are stale" bug. And use fragment colocation — each component declares its own field fragment in its file, and page-level queries compose them with ...Component_fragment:
// components/PostCard.tsx —— the component declares its own data needs
export const PostCardFragment = graphql(`
fragment PostCard_post on Post {
id
title
author { name }
}
`);
// app/feed/page.tsx —— the page query only composes
const FeedQuery = graphql(`
query Feed($limit: Int) {
posts(limit: $limit) {
...PostCard_post
}
}
`);
When PostCard needs one more field, you only touch its own fragment, and codegen tells you the composed query type changed — the component-data binding becomes machine-verifiable for the first time. This is the data colocation idea the GraphQL homepage itself promotes; feeding this pattern to the AI saves a lot of "field mismatch" rework when it writes components.
Which server framework? For a solo project the priority is simple: GraphQL Yoga > Apollo Server. Yoga is built on graphql-js with no legacy baggage, and plugins like the Section 5 depth limit and complexity guards are a few lines each; Apollo Server has the biggest ecosystem and most tutorials, but its v4+ configuration got verbose — for one maintainer, "less code" beats "more tutorials". There's a middle path, Pothos (code-first: write the schema in TS, types inferred automatically), for people who want GraphQL's flexibility without hand-writing schema files — but code-first schemas read worse than schema-first ones, so skip it for public APIs, where third-party developers read the schema file, not your TS code.
One-line cost summary: tRPC's type safety is free while you write code; GraphQL's type safety is bought with the discipline of "schema-first + codegen pipeline". Discipline needs someone to enforce it — and with one person, fewer disciplines are better. That's the underlying logic of defaulting to tRPC.
AI pitfall #1: N+1 — why AI-generated resolvers always hit it
This is AI's most reliably reproducible GraphQL bug. Here's the resolver AI will most likely generate for you:
// AI's "intuitive" style: each field queries the DB on its own. Looks perfectly correct.
export const resolvers = {
Query: {
posts: () => db.post.findMany({ take: 20 }),
},
Post: {
author: (post: Post) =>
db.user.findUnique({ where: { id: post.authorId } }), // ← the pit is here
},
};
A frontend query for 20 posts with author names — posts { title author { name } } — executes as: 1 query for the post list + 20 individual user queries = 21 database round trips. That's N+1. Why does AI always fall in? Because "each field resolver queries on its own" is the most common example style in its training data, while batch loading requires sharing state across resolvers (the loader in context) — beyond the local "see function, write function" pattern. The nastier part: with small dev datasets N+1 is invisible; in production the first paginated list page melts the database.
The standard fix is DataLoader (graphql/dataloader, MIT licensed, the open-source implementation of the "Loader" idea from Facebook's 2010 internals): it merges multiple load(id) calls within one event-loop tick into a single batched query.
import DataLoader from 'dataloader';
// Create one loader per request (critical: per-request, never shared across
// requests — otherwise user A can read user B's cached data, which the
// official README explicitly warns about)
export function createLoaders(db: Db) {
return {
userById: new DataLoader(async (ids: readonly number[]) => {
const users = await db.user.findMany({ where: { id: { in: [...ids] } } });
const byId = new Map(users.map((u) => [u.id, u]));
// The contract you must honor: the returned array must be the same length
// as keys, with positions matching one-to-one
return ids.map((id) => byId.get(id) ?? new Error(`User ${id} not found`));
}),
};
}
// Resolvers go through the loader: one line changed, 21 queries become 2
export const resolvers = {
Query: {
posts: (_: unknown, __: unknown, { db }: Ctx) => db.post.findMany({ take: 20 }),
},
Post: {
author: (post: Post, _: unknown, { loaders }: Ctx) =>
loaders.userById.load(post.authorId),
},
};
The mechanism in one sentence: DataLoader collects the 20 load() calls from the same tick, invokes the batch function once (a single WHERE id IN (...)), then splits the results back by key. You also get within-request dedup caching for free.
Self-review checklist for AI-generated code (run through it after the AI writes resolvers, or paste it to the AI as a review prompt):
- Do all nested field resolvers (
Post.author,User.posts) go through a DataLoader? Any directdb.xxx.findUniquegets flagged. - Is the loader created fresh in each request's context? A global singleton loader = cross-user cache pollution.
- Does the batch function return an array the same length as keys, in matching order? Misaligned order is the hardest bug to find — symptom: "occasionally swapped data".
- After a mutation, did you
loader.clear(key)? DataLoader's cache is only valid within a single request; read-then-write-then-read in the same request serves stale data. - Load test locally: when a list endpoint returns 50 items, count the actual SQL statements in the DB log. More than 3 means go back and fix it.
Two advanced DataLoader uses the AI will almost never reach for, but production will eventually demand:
primefor warm-up: if your top-level resolver already fetched the association in one shot (e.g.findMany({ include: { author: true } })), useloader.prime(post.authorId, post.author)to seed the cache — later nestedload()calls hit it with zero extra queries. Only prime when you're sure the data is fresh, andclearafter writes.- The REST variant of N+1: this pit isn't GraphQL-only. AI-written tRPC/REST code has the same disease:
for (const post of posts) { post.author = await db.user.findUnique(...) }— await inside a loop, serial N+1, even slower than the GraphQL version (not even concurrent). When reviewing AI code, "loop + await + DB query" is an instant red flag; rewrite asPromise.allor a singleINquery.
AI pitfall #2: over-fetching — guardrails for public APIs
GraphQL hands "what to fetch" to the client. That's why it's flexible, and why it's dangerous. If AI's generated schema lets User.posts nest Post.author which nests User.posts again… a malicious query 20 levels deep can take down your database in one request. In the REST era, fixed endpoints at least bounded the blast radius; GraphQL hands the radius to the caller — for a public API you must take the radius back yourself.
Two layers of guardrails, both required:
Layer 1: query depth limiting. Kill nesting depth outright, one line of config:
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(7)], // deeper than 7 gets rejected before execution
});
Where does 7 come from? Count your deepest legitimate query (feed → post → author → avatar is about 4–5 levels), add 2 levels of headroom. Once set, document it — clients that exceed it split their queries themselves.
Layer 2: complexity scoring (cost analysis). Depth limits don't stop "wide" queries: posts(limit: 10000) { ... 30 fields } is only 2 levels deep but can read the database dry. Price each field with graphql-query-complexity:
import { getComplexity, simpleEstimator } from 'graphql-query-complexity';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(7)],
plugins: [
{
async requestDidStart() {
return {
async didResolveOperation({ request, document }) {
const complexity = getComplexity({
schema,
query: document,
variables: request.variables,
estimators: [simpleEstimator({ defaultComplexity: 1 })],
});
if (complexity > 500) {
throw new GraphQLError(`Query too complex: ${complexity} (max 500)`);
}
},
};
},
},
],
});
Take it further by pricing "expensive fields" individually: score posts as a multiplier of its limit argument, price a full-text search field at a flat 50 points. Tune the budget (500 in the example) against your own DB load tests: find the single-instance QPS knee first, then derive the per-query complexity allowance from it.
How expensive-field pricing looks in practice (via fieldExtensions, cleaner than ifs in the plugin):
import { makeExecutableSchema } from '@graphql-tools/schema';
const typeDefs = /* GraphQL */ `
type Query {
posts(limit: Int = 20): [Post!]!
search(keyword: String!): [Post!]!
}
`;
const resolvers = {
Query: {
posts: {
// complexity = limit × 2: 10 items cost 20 points, 500 items blow the budget
extensions: { complexity: ({ args }: any) => (args.limit ?? 20) * 2 },
resolve: (_: unknown, { limit }: { limit: number }, { db }: Ctx) =>
db.post.findMany({ take: limit }),
},
search: {
extensions: { complexity: 50 }, // full-text search costs a flat 50, honestly priced
resolve: (_: unknown, { keyword }: { keyword: string }, { db }: Ctx) =>
db.post.search(keyword),
},
},
};
For a public API (third-party callers), add one more tier: persisted queries allowlist — production only accepts pre-registered query hashes and rejects arbitrary hand-written queries outright. This is the standard practice of public GraphQL APIs like Stripe and GitHub: flexibility is confined to a reviewed set of queries, and the attack surface drops to zero. For a solo project, @graphql-tools/persisted-query and similar packages get you there in half a day.
Remember the order: depth limit first (5 minutes), then complexity scoring (half a day), then persisted queries (only for public APIs). Don't start with the allowlist — it'll lock your own iteration speed too.
One more layer people miss: rate-limit by complexity, not by request count. The classic REST gateway rule "100 requests/minute per IP" breaks on GraphQL — one complex query can equal a hundred simple ones. The right model is a complexity budget per token: 10,000 complexity points per minute, cheap queries costing a few points, expensive ones a few hundred. That way attackers can't dodge the limit with "oversized queries within the legal request count". Implement it in the same layer-2 plugin: compute the complexity, deduct from the token's remaining budget in Redis, return 429 when it's spent. AI-generated rate limiting defaults to per-request counting — you have to ask for this explicitly.
Auth: tRPC middleware vs GraphQL directives
The two stacks hook auth in different places, but the principle is identical: intercept centrally at the framework layer — never hand-write if (!user) throw scattered across every resolver/procedure. Scattered hand-written auth is AI's favorite move: copy-paste the check into each function, miss one spot, and you have a privilege escalation.
tRPC: middleware + procedure composition.
// server/trpc.ts —— appended after t
import { TRPCError } from '@trpc/server';
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Please sign in first' });
}
// Write user into ctx — downstream procedures use it directly, no more null checks
return next({ ctx: { ...ctx, user: ctx.session.user } });
});
export const protectedProcedure = t.procedure.use(isAuthed);
// Business side: one declaration, auth done
export const postRouter = router({
create: protectedProcedure
.input(z.object({ title: z.string().min(1).max(120) }))
.mutation(async ({ input, ctx }) => {
return ctx.db.post.create({ data: { ...input, authorId: ctx.user.id } });
}),
});
GraphQL: declarative directive auth.
# Declare intent in the schema
directive @auth(requires: Role = USER) on FIELD_DEFINITION
enum Role { USER ADMIN }
type Mutation {
createPost(title: String!, body: String!): Post! @auth
deletePost(id: ID!): Boolean! @auth(requires: ADMIN)
}
// Directive implementation: role checks centralized in one place
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
function authDirective(directiveName: string) {
return {
authDirectiveTypeDefs: `directive @${directiveName}(requires: Role = USER) on FIELD_DEFINITION`,
authDirectiveTransformer: (schema: GraphQLSchema) =>
mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const dir = getDirective(schema, fieldConfig, directiveName)?.[0];
if (!dir) return fieldConfig;
const { resolve = defaultFieldResolver } = fieldConfig;
fieldConfig.resolve = async (source, args, ctx, info) => {
if (!ctx.user) throw new GraphQLError('Please sign in first');
if (dir.requires === 'ADMIN' && ctx.user.role !== 'ADMIN') {
throw new GraphQLError('Admin permission required');
}
return resolve(source, args, ctx, info);
};
return fieldConfig;
},
}),
};
}
Scope note: the permission model itself (how roles are split, how permissions are stored) is out of scope here — it reuses the conclusions of "从一个人到一个团队:vibe 项目的 RBAC 权限模型落地实战", whose minimal four-role model (owner / admin / member / viewer) plus Prisma table structures you can copy directly. This guide only answers "which hook of the API layer does the auth code hang on": middleware for tRPC, directives for GraphQL — both "declare once, enforced everywhere", no scattered hand-written ifs.
One easily missed detail: GraphQL field-level auth with @auth only protects fields when they're queried directly. If Post.author expansion pulls in sensitive fields (like User.email), split sensitive fields into a higher-permission type at the schema level, or re-check in the resolver. AI-generated schemas almost never consider this — focus your review there.
A composition trick on the tRPC side: middlewares chain, so role auth never needs rewriting per procedure:
// adminProcedure = login check + role check, one new procedure in two lines
const isAdmin = t.middleware(({ ctx, next }) => {
if (ctx.user.role !== 'ADMIN') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin permission required' });
}
return next();
});
export const adminProcedure = protectedProcedure.use(isAdmin);
// Business side: adminRouter = router({ deletePost: adminProcedure.mutation(...) })
Finally, testing advice that applies to both stacks: auth logic needs negative tests — unauthenticated call to protectedProcedure expects UNAUTHORIZED, a normal user hitting an admin endpoint expects FORBIDDEN, a GraphQL @auth field with a bad token expects an error. AI writes tests with only the happy path ("a signed-in user gets through"); you must explicitly ask it to add the negative cases. Cover those three and 90% of privilege-escalation holes are closed.
One more shared foundation: where does session in ctx come from? The standard approach parses the token from the request header (or cookie) once in createContext, looks up the user, and puts { user, loaders } into context — note the DataLoader instances are also created per-request here (for the Section 4 reason). AI habitually parses the token inside every middleware and makes the loader a global singleton; both are wrong: parse the token once into context, create loaders per request. Context is the foundation of the API layer — if the foundation tilts, everything above it tilts.
Decision tree: default tRPC — switch to GraphQL only on 3 signals
Everything above converges into one diagram. The rule is simple: default to tRPC; only pay GraphQL's extra cost (schema maintenance + DataLoader + query guardrails, all from Sections 4–5) when any of the three signals below appears.
The three signals, explained:
- Signal ①: multi-client consumption. Web, iOS, and Android need such different fields that one REST/tRPC endpoint either over-fetches or spawns N versions. GraphQL's "client orders à la carte" genuinely saves work here: mobile gets slim fields, web gets full fields, the backend maintains one set of resolvers.
- Signal ②: public API. You're building an open platform for third-party callers. GraphQL's self-describing schema (introspection) plus strong types are a natural SDK replacement — a whole tier better in developer experience. But keep every Section 5 guardrail; public means attack surface.
- Signal ③: aggregation gateway. You have multiple REST services / data sources (main business DB + third-party SaaS + legacy systems) and the frontend doesn't want to call 5 endpoints to assemble data. GraphQL as a BFF (Backend For Frontend) aggregation layer — one query fanning out to multiple downstreams — is its classic architectural slot.
None of the three signals? Then don't churn. tRPC with well-designed routers handles tens of thousands of DAU without breaking a sweat. The biggest cost of a stack choice isn't the technology — it's the learning and maintenance cost you prepay for "might need it someday". A solo team can't afford that prepayment.
Each signal gets a counter-example, so the signals don't trick you into GraphQL:
- You have web + a mini-program, but the field difference is just "the mini-program skips two fields" — that's not multi-client consumption, that's a slim tRPC procedure for the mini-program, half an hour of work.
- Your "public API" is really just your own other side project calling it — that's not an open platform, that's internal traffic; tRPC + a shared
AppRoutertype solves it. - Your "multiple data sources" are really one Postgres plus one Redis — that's not an aggregation gateway, that's assembling data in the service layer. Talk BFF when you actually have 3+ heterogeneous sources.
The test in one sentence: a signal counts only if "staying on tRPC/REST hurts", not if "GraphQL would feel nice". Pain is cost; nice-feeling is illusion.
And REST's place, for completeness: non-TS clients (scripts in Python calling your API), read-heavy endpoints where HTTP caching matters, or an existing OpenAPI ecosystem — REST remains the right call there. No shame in not being trendy.
Migration SOP: REST → tRPC in 5 gradual steps
Finally, a 5-step SOP for those sitting on a pile of REST who want tRPC. The core principle: new before old, run both and verify, then delete the old routes — any "rewrite over the weekend" is suicide in a solo project.
Step 1: all new features go to tRPC only. From today, every new endpoint goes through app/api/trpc; no new REST routes. Get tRPC's pipe flowing first (the Section 2 integration code runs in half an hour); don't touch a line of old code.
Step 2: migrate high-traffic routes one by one, reusing the original service functions. Pick the 3–5 most-called REST routes (usually list, detail, create) and move their handler business logic into tRPC procedures verbatim:
// Before: app/api/posts/route.ts (REST)
export async function GET(req: Request) {
const posts = await postService.list(parseLimit(req));
return Response.json(posts);
}
// After: server/routers/post.ts (tRPC), service function reused untouched
list: publicProcedure
.input(z.object({ limit: z.number().min(1).max(50).default(20) }))
.query(({ input }) => postService.list(input.limit),
// postService.list is the same function the REST handler called — not a line changed
Note that zod's .input() conveniently replaces hand-written parameter validation — all those if (!limit) return 400 checks scattered across handlers can go. That's a side benefit of migrating.
Step 3: run both for 1–2 weeks and compare. Old and new endpoints live side by side; switch the frontend to tRPC and compare three things: response data diffs (sampled log comparison), error rates (instrument tRPC's onError callback), and P99 latency (tRPC's httpBatchLink merges multiple calls into one HTTP request — usually faster, but verify with measurements).
Step 4: mark the old REST routes deprecated. Once verified, add a Deprecation: true response header to the old routes (with a clear sunset date), and enter the observation period after confirming no callers remain on the frontend. For the deprecation notice format and observation-period metrics, follow the templates in "API 别翻车:vibe 项目的版本管理与废弃 SOP" — no reinvented wheels here.
Step 5: when the observation period ends, delete the old routes. Before deleting, do the final checks: grep the codebase for the old path strings, check the gateway/monitoring for remaining traffic, confirm mobile releases have rolled out. After deleting, run a full type check (tsc --noEmit) — tRPC pays off here once more: if any frontend code still calls a deleted route, the compiler errors out; no human grep needed.
The whole SOP takes about 2–3 weeks for a medium vibe project (30–50 REST routes), with zero user-visible impact throughout. Never scramble the order: get water flowing through the new pipe before demolishing the old one. Everyone who tried "rewrite it over the weekend, cut over on Monday" ended up rolling back at 3 AM on Monday — don't ask me how I know.
Rollback plan in case Step 3's dual-run goes sideways, in this order:
- All frontend tRPC calls go through one shared
trpcclient instance — rolling back means switching the broken page back to the old REST call, one import change. So don't delete the old REST handlers in Step 2; keep them as the escape hatch. - Tag tRPC responses during dual-run (header or log field
x-api-layer: trpc) so monitoring shows both pipes' error rates at a glance — no guessing when triaging. - Migrate reads before writes: a broken read is at worst a white screen; a broken write is dirty data. AI often orders migration alphabetically by file — you set this priority by hand.
One more thing you can use immediately: a prompt template condensing this guide's conventions. Paste it before the AI when it writes API code and it blocks ~80% of rework.
API conventions (tRPC project, strictly enforced):
1. All endpoints go through tRPC routers under server/routers/, named
resource.action (post.list / post.create). No new REST routes.
2. Every procedure must declare zod .input() validation, aligned field by
field with the Prisma model: Prisma-optional means zod .optional().
No "stricter is better" freelancing.
3. Use protectedProcedure for authenticated endpoints, adminProcedure for
admin ones. No hand-written if (!user) auth inside function bodies.
4. Never await DB queries inside loops; fetch associations in one shot or
use a DataLoader. Count SQL statements on list endpoints before shipping.
5. When renaming a field, rename it in the router first — everywhere the
compiler errors is frontend to update. Done only when tsc --noEmit passes.
Closing thought. API choice has a new weight in the vibe coding era: it used to be "what is the team familiar with"; now it's "when AI writes this kind of code, can a machine backstop me?" tRPC's backstop is the compiler; GraphQL's backstop is the trio of schema discipline + DataLoader + query guardrails; REST mostly relies on humans. For a team of one person plus one AI, human review is the scarcest resource — so default to tRPC, switch to GraphQL when the signals arrive, and leave REST the territory it's actually good at. That ordering isn't about technical superiority. It's accounting.
Comments (0)
Related articles

AI can write a desktop app in one evening, but macOS notarization, Windows signing certificates, and auto-updates are all process grunt work. This guide lays out the complete runway from build to user: an Electron vs. Tauri decision table, the full Developer ID signing + notarization flow, OV/EV vs. Azure cloud signing, auto-updates on both platforms, four distribution channels, a CI packaging pipeline, and a 12-item pre-release checklist.

DMs are not just sending messages. This hands-on guide walks through a complete DM system for vibe projects: three conversation shapes, a three-table data model with a stranger-request state machine, WebSocket delivery with idempotency, read watermarks, the anti-abuse quartet, signed attachment URLs, silent push aggregation, delete semantics, and 12 launch test cases.

Questionnaires, sign-ups, applications… once your vibe project grows its third form, hand-written maintenance starts to spiral. This hands-on guide builds a form builder from zero: JSON Schema as the data contract, UI schema for layout, a type-to-component registry for rendering, a rule engine for conditional logic, plus the drag-and-drop canvas and schema versioning — with an AJV/zod decision guide and a launch checklist.