Skip to content

bug: Set-Cookie Header Not Propagating via responseMeta in App Router fetchRequestHandler with Async ProceduresΒ #2138

@its-gabo

Description

@its-gabo

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

  1. tRPC Login Mutation (server/api/routers/auth.ts): Makes an async call to auth.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 };
  }),
  1. 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
  };
};
  1. 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:

  1. Direct ctx.res.setHeader in pages router: (Works perfectly, but requires pages router and version downgrade).
  2. 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 reads trpcResponse.text(), manually parses JSONL, extracts cookie, sets new NextResponse(...).headers.set('Set-Cookie', ...);

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions