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
  • Authorization header
  • Content-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:

  • ETag
  • X-RateLimit-Remaining
  • Link

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:

  1. Is the request actually cross-origin?
  2. Did the browser send preflight?
  3. Did the server answer OPTIONS with 2xx?
  4. Does Access-Control-Allow-Origin exactly match the requesting origin?
  5. Are credentials involved?
  6. Do Access-Control-Allow-Headers match what the browser requested?
  7. 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-Age to 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.