tRPC is great right up until your frontend and API live on different origins and the browser starts throwing CORS errors that look unrelated to your code.
I’ve seen this happen a lot with tRPC because the transport feels “magic” when everything is same-origin. Then you split your app across app.example.com and api.example.com, or you run Vite on localhost:5173 against a backend on localhost:3000, and suddenly every request is blocked before your resolver runs.
This guide is the practical version: what headers you need, when preflight happens, and copy-paste server setups for common tRPC adapters.
What CORS means for tRPC
CORS is a browser policy. Your tRPC server can be perfectly healthy and still fail in the browser because the browser refuses to expose the response to JavaScript.
For tRPC, the common triggers are:
- different origin between frontend and backend
AuthorizationheaderContent-Type: application/json- cookies or session auth
- custom headers like
x-trpc-source
Most tRPC calls are fetch() under the hood, so the browser enforces normal CORS rules.
A browser request usually looks like this:
OPTIONS /trpc/user.me HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
If your server responds correctly:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: content-type, authorization
Access-Control-Allow-Credentials: true
Vary: Origin
then the browser sends the real request.
If not, your resolver never gets called.
The minimum CORS rules you need to remember
1. Access-Control-Allow-Origin must match the requesting origin
For public APIs, * is fine.
GitHub does exactly that for many endpoints:
access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset
That works because GitHub isn’t allowing browser credentials with *.
2. You cannot use * with credentials
If your tRPC client sends cookies or credentials: 'include', you must return the exact origin:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Not this:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Browsers reject that combo.
3. Preflight is normal
If you use JSON POST requests, auth headers, or cookies, expect an OPTIONS request. Your server has to answer it.
4. If you want frontend code to read non-simple response headers, expose them
Browser JS can’t read arbitrary response headers unless you expose them.
For example, if your tRPC endpoint sets:
ETagX-RateLimit-RemainingLink
you need:
Access-Control-Expose-Headers: ETag, X-RateLimit-Remaining, Link
GitHub exposes a long list for exactly this reason.
tRPC client example that triggers CORS
A typical browser client:
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from './server';
export const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: 'https://api.example.com/trpc',
fetch(url, options) {
return fetch(url, {
...options,
credentials: 'include',
headers: {
...options?.headers,
Authorization: 'Bearer my-token',
},
});
},
}),
],
});
This will usually trigger preflight because of:
- cross-origin request
Authorization- credentials included
- JSON body on POST
So your server needs a real CORS config, not vibes.
Express + tRPC
This is the most common setup, and honestly the easiest to get right.
Install:
npm i cors express @trpc/server @trpc/server/adapters/express
Server:
import express from 'express';
import cors from 'cors';
import * as trpcExpress from '@trpc/server/adapters/express';
import { appRouter, createContext } from './trpc';
const app = express();
const allowedOrigins = [
'http://localhost:5173',
'https://app.example.com',
];
app.use(
cors({
origin(origin, callback) {
// allow non-browser tools like curl/postman with no Origin header
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
return callback(null, true);
}
return callback(new Error('Not allowed by CORS'));
},
credentials: true,
methods: ['GET', 'POST', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
exposedHeaders: ['ETag', 'X-RateLimit-Remaining', 'Link'],
maxAge: 86400,
})
);
app.use(
'/trpc',
trpcExpress.createExpressMiddleware({
router: appRouter,
createContext,
})
);
app.listen(3000);
My opinion: use an allowlist, not origin: true for production. Reflecting arbitrary origins is how people accidentally create “works in dev, sketchy in prod” setups.
Fastify + tRPC
Install:
npm i fastify @fastify/cors @trpc/server
Server:
import Fastify from 'fastify';
import cors from '@fastify/cors';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import { appRouter, createContext } from './trpc';
const server = Fastify();
await server.register(cors, {
origin: (origin, cb) => {
const allowed = [
'http://localhost:5173',
'https://app.example.com',
];
if (!origin || allowed.includes(origin)) {
cb(null, true);
return;
}
cb(new Error('Not allowed'), false);
},
credentials: true,
methods: ['GET', 'POST', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
exposedHeaders: ['ETag', 'X-RateLimit-Remaining', 'Link'],
maxAge: 86400,
});
await server.register(fastifyTRPCPlugin, {
prefix: '/trpc',
trpcOptions: {
router: appRouter,
createContext,
},
});
await server.listen({ port: 3000 });
Fastify is strict in a good way. If preflight is failing, check plugin registration order first.
Next.js API route / standalone handler
If you’re using tRPC with Next.js and a custom API handler, you may need to set CORS manually.
import type { NextApiRequest, NextApiResponse } from 'next';
import { createNextApiHandler } from '@trpc/server/adapters/next';
import { appRouter, createContext } from '../../server/trpc';
const allowedOrigins = new Set([
'http://localhost:5173',
'https://app.example.com',
]);
function setCors(req: NextApiRequest, res: NextApiResponse) {
const origin = req.headers.origin;
if (origin && allowedOrigins.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader(
'Access-Control-Allow-Headers',
'Content-Type, Authorization'
);
res.setHeader(
'Access-Control-Expose-Headers',
'ETag, X-RateLimit-Remaining, Link'
);
res.setHeader('Access-Control-Max-Age', '86400');
}
}
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
setCors(req, res);
if (req.method === 'OPTIONS') {
res.status(204).end();
return;
}
return createNextApiHandler({
router: appRouter,
createContext,
})(req, res);
};
export default handler;
If you’re on Next.js App Router with route handlers, same idea: set headers first, handle OPTIONS, then call into tRPC.
Standalone fetch adapter
If you’re using the standalone HTTP/fetch adapter, manual CORS is usually simplest.
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter, createContext } from './trpc';
const allowedOrigins = new Set([
'http://localhost:5173',
'https://app.example.com',
]);
function corsHeaders(origin: string | null) {
if (!origin || !allowedOrigins.has(origin)) {
return {};
}
return {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Expose-Headers': 'ETag, X-RateLimit-Remaining, Link',
'Access-Control-Max-Age': '86400',
Vary: 'Origin',
};
}
export default {
async fetch(req: Request) {
const origin = req.headers.get('Origin');
const headers = corsHeaders(origin);
if (req.method === 'OPTIONS') {
return new Response(null, { status: 204, headers });
}
return fetchRequestHandler({
endpoint: '/trpc',
req,
router: appRouter,
createContext: () => createContext({ req }),
responseMeta() {
return {
headers,
};
},
});
},
};
Common CORS mistakes with tRPC
Forgetting OPTIONS
If preflight fails, the browser error often points at your app code, but the real problem is your server didn’t answer OPTIONS.
Using * with cookies
This is the classic one. If your auth is cookie-based, send the exact origin.
Missing Vary: Origin
If your CDN or proxy caches responses, this header matters. Without it, one origin’s CORS response can leak into another origin’s cached response.
Allowing too few headers
If the browser asks for:
Access-Control-Request-Headers: content-type, authorization, x-trpc-source
your server must allow them or the preflight fails.
Exposing too few response headers
If your frontend needs to read rate limit or pagination headers, expose them explicitly.
Debugging checklist
When I’m debugging CORS, I check these in order:
- Is the request actually cross-origin?
- Did the browser send preflight?
- Did the server answer
OPTIONSwith 2xx? - Does
Access-Control-Allow-Originexactly match the requesting origin? - Are credentials involved?
- Do
Access-Control-Allow-Headersmatch what the browser requested? - Are proxies/CDNs stripping headers?
If you want to inspect the exact browser-visible behavior, HeaderTest is handy for quickly checking what headers are actually coming back from an endpoint.
Good default policy for most private tRPC APIs
If your tRPC backend is used by your own frontend only, this is the sane starting point:
- allowlist exact frontend origins
- allow
GET, POST, OPTIONS - allow
Content-Type, Authorization - enable credentials only if you use cookies
- expose only headers your frontend really needs
- set
Vary: Origin - set
Access-Control-Max-Ageto reduce preflight noise
Example header set:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Expose-Headers: ETag, Link, X-RateLimit-Remaining
Access-Control-Max-Age: 86400
Vary: Origin
That’s enough for most tRPC deployments.
If you’re also tightening the rest of your HTTP response headers, CORS is only one part of it. For broader browser-side header hardening, Content-Security-Policy and friends matter too, and csp-guide.com is a solid reference for that side of the stack.
The main thing with tRPC and CORS is not to overcomplicate it. Treat it like any other HTTP API. The browser does not care that your backend is type-safe. It cares about headers.