-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Description
Provide environment information
System:
OS: Windows 11 10.0.26100
CPU: (12) x64 12th Gen Intel(R) Core(TM) i5-12400F
Memory: 8.17 GB / 15.84 GB
Binaries:
Node: 22.6.0 - C:\Program Files\nodejs\node.EXE
npm: 10.8.2 - C:\Program Files\nodejs\npm.CMD
pnpm: 9.14.4 - C:\Program Files\nodejs\pnpm.CMD
bun: 1.1.38 - ~\.bun\bin\bun.EXE
Deps
{
"dependencies": {
"@tanstack/react-query": "5.69.0",
"@trpc/client": "11.0.0",
"@trpc/react-query": "11.0.0",
"@trpc/server": "11.0.0",
"better-auth": "1.2.12",
"next": "15.2.3",
"react": "19.0.0",
"react-dom": "19.0.0"
},
"ct3aMetadata": {
"initVersion": "7.39.3"
}
}
Obviously there's more deps, but these are the relevant ones.
Describe the bug
Summary
When using @trpc/server/adapters/fetch
in a Next.js App Router API route (app/api/trpc/[trpc]/route.ts
), Set-Cookie headers set asynchronously within a tRPC procedure (e.g., after an await call) are not propagated to the final HTTP response. This occurs because the responseMeta hook (or ctx.resHeaders/custom _meta
properties) is evaluated before the asynchronous procedure has completed and applied its changes, leading to an empty or outdated context for header injection.
The problem has been extensively debugged, ruling out common issues like credentials: "include"
on the client.
Workaround: Manually parse the tRPC response payload to extract the cookie and then set it directly on a new NextResponse object in route.ts.
Reproduction repo
https://github.com/its-gabo/custom-legends
To reproduce
Full Description
I'm encountering an issue where Set-Cookie headers, generated by an external authentication library (better-auth
) and attempted to be set from within a tRPC mutation, are not being sent back to the client when using the @trpc/server/adapters/fetch
adapter in a Next.js App Router API route (app/api/trpc/[trpc]/route.ts
).
Problem scenario
- tRPC Login Mutation (
server/api/routers/auth.ts
): Makes an async call toauth.api.signInEmail
which returns the Set-Cookie header.
// server/api/routers/auth.ts snippet
login: publicProcedure
.input(LoginUserSchema)
.mutation(async ({ ctx, input }) => {
// ...
const signInResponse = await auth.api.signInEmail({
body: {
email,
password,
},
asResponse: true,
returnHeaders: true,
});;
if (!signInResponse.ok) { /* ... */ }
const sessionCookieString = signInResponse.headers.get("Set-Cookie");
// Attempt 1: Using ctx.resHeaders (as per FetchCreateContextFnOptions)
// ctx.resHeaders.set("Set-Cookie", sessionCookieString);
// Attempt 2: Using a custom mutable property in ctx (after confirming ctx.resHeaders failed)
ctx._meta.loginCookie = sessionCookieString; // Assuming _meta is structured for this
console.log("LOGIN MUTATION: ctx._meta AFTER setting loginCookie:", ctx._meta);
return { success: true };
}),
- tRPC Context (
server/api/trpc.ts
- relevant part for App Router):
// server/api/trpc.ts snippet for App Router
import { type FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
export interface CustomContextMeta {
loginCookie?: string;
}
export const createTRPCContext = async (opts: FetchCreateContextFnOptions) => {
const _meta: CustomContextMeta = {}; // Create mutable object per request
return {
db,
headers: opts.req.headers,
resHeaders: opts.resHeaders, // Original Headers object from adapter
_meta, // Attach custom meta object
};
};
- Next.js App Router API Route (
app/api/trpc/[trpc]/route.ts
):
// app/api/trpc/[trpc]/route.ts snippet
export const runtime = "nodejs"; // Tried 'nodejs' and default (Edge) runtime, no difference for this issue
const handler = async (req: NextRequest) => {
return fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: (opts) => createTRPCContext(opts),
responseMeta: ({ ctx }) => {
const finalHeaders = new Headers();
// Log the state of _meta at the start of responseMeta
console.log("RESPONSE META: ctx._meta at start:", ctx._meta);
// Attempt to retrieve and set cookie from _meta
if (ctx?._meta?.loginCookie) {
finalHeaders.append("Set-Cookie", ctx._meta.loginCookie);
console.log("RESPONSE META: Set-Cookie from _meta.");
} else {
console.log("RESPONSE META: No loginCookie found in ctx._meta.");
}
return { headers: finalHeaders };
},
});
};
Observed Behavior (Logs):
When performing a login request, the server logs consistently show the following sequence:
RESPONSE META: ctx._meta at start: {}
LOGIN MUTATION: Session cookie string received: custom-legends.session_token=...; Max-Age=...; Path=/; HttpOnly; SameSite=Lax
LOGIN MUTATION: ctx._meta AFTER setting loginCookie: { loginCookie: 'custom-legends.session_token=...; Max-Age=...; Path=/; HttpOnly; SameSite=Lax' }
This clearly demonstrates that responseMeta executes before the asynchronous tRPC procedure (specifically, the await auth.api.signInEmail
and subsequent ctx._meta.loginCookie = ...
line) has completed its execution and updated the context.
Additional information
Workarounds Implemented/Attempted:
- Direct
ctx.res.setHeader
in pages router: (Works perfectly, but requires pages router and version downgrade). - Manual Response Body Parsing in app router route.ts: (Works, but cumbersome for multiple cookie-setting mutations).
- Mutation returns cookie string in payload.
route.ts
readstrpcResponse.text()
, manually parses JSONL, extracts cookie, setsnew NextResponse(...).headers.set('Set-Cookie', ...)
;