bug: `useSuspenseQuery` will get "UNAUTHORIZED" tRPC error
Provide environment information
System: OS: macOS 14.2.1 CPU: (8) arm64 Apple M1 Pro Memory: 196.13 MB / 16.00 GB Shell: 5.9 - /bin/zsh Binaries: Node: 20.9.0 - ~/.nvm/versions/node/v20.9.0/bin/node Yarn: 1.22.21 - ~/.nvm/versions/node/v20.9.0/bin/yarn npm: 10.1.0 - ~/.nvm/versions/node/v20.9.0/bin/npm pnpm: 8.14.0 - ~/Library/pnpm/pnpm
Describe the bug
If we have a protected api like:
hello: protectedProcedure
.input(z.object({ text: z.string() }))
.query(async ({ input }) => {
// wait for 1 second
await new Promise((resolve) => setTimeout(resolve, 1000));
return {
greeting: `Hello ${input.text}`,
};
}),
Then we use useSuspenseQuery
"use client";
import { api } from "@/trpc/react";
import { Suspense } from "react";
export default function TestSuspense() {
return (
<div>
<h1>Test Suspense</h1>
<Suspense fallback={<div>Loading...</div>}>
<SayHi />
</Suspense>
</div>
);
}
function SayHi() {
const [greet, getGreet] = api.post.hello.useSuspenseQuery({
text: "Suspense user",
});
return <div>{greet.greeting}</div>;
}
We will get the error UNAUTHORIZED from
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.session || !ctx.session.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
// infers the `session` as non-nullable
session: { ...ctx.session, user: ctx.session.user },
},
});
});
https://github.com/t3-oss/create-t3-app/assets/38809606/ee22bdae-d241-49cd-ac41-f002433b9d6e
You can check the detail of my code in this commit: https://github.com/Crayon-ShinChan/t3-trpc-suspense-bug/commit/b747bbac672c541a62925f35d2ddc9636cb1e316
Reproduction repo
https://github.com/Crayon-ShinChan/t3-trpc-suspense-bug
To reproduce
- clone the repo
- Go to Discord Portal to create an oauth app for this repo and copy paste the
DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRETto .env file pnpm dev
Additional information
No response
Yup this is a limitation in Next since they don't provide any primitive to access headers from a client component during the SSR prepass phase, so queries made on the server wont be authed...
You can fix this by prefetching the data in an RSC and hydrating the query client or pass the initial data as props.
Unfortunately not much we can do here, there is a community package https://github.com/moshest/next-client-cookies that "hacks" around it although I've never tried it
Any update or ways around this? Would be great to use useSuspenseQuery
Any update or ways around this? Would be great to use useSuspenseQuery
I use isPending to determine the UI
Are you solved this problem?
Are you solved this problem?
I didn't use useSuspenseQuery and used isPending to determine if I need render skeleton instead
Using this package phryneas/ssr-only-secrets seems to be working great to pass the cookie to the headers on the SSR piece
Add a new env variable:
// .env.local
SECRET_CLIENT_COOKIE_VAR={"key_ops":["encrypt","decrypt"],"ext":true,"kty":"oct","k":"asdas....","alg":"A256CBC"}
I used the code provided to create the key above:
crypto.subtle
.generateKey(
{
name: "AES-CBC",
length: 256,
},
true,
["encrypt", "decrypt"]
)
.then((key) => crypto.subtle.exportKey("jwk", key))
.then(JSON.stringify)
.then(console.log);
Then in the layout file, access the cookie, encrypt it and pass it to the TRPCReactProvider
// app/layout.tsx
import { headers } from "next/headers";
import { TRPCReactProvider } from "@/trpc/react";
export default async function Layout(props: {
children: React.ReactNode;
}) {
const cookie = new Headers(headers()).get("cookie");
const encryptedCookie = await cloakSSROnlySecret(cookie ?? "", "SECRET_CLIENT_COOKIE_VAR")
return <html>
<Head/>
<body>
...
<TRPCReactProvider ssrOnlySecret={encryptedCookie}>
{props.children}
</TRPCReactProvider>
</body>
</.html>
}
Then decrypt the value and pass it to the headers on the client side. Reading the value on the browser always returns undefined so you won't be able to see it there.
// trcp/react.ts
"use client";
import { readSSROnlySecret } from "ssr-only-secrets";
import type { AppRouter } from "@beebook/api";
import * as React from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { loggerLink, unstable_httpBatchStreamLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import SuperJSON from "superjson";
import { getBaseUrl, getQueryClient } from "./utils";
export const api = createTRPCReact<AppRouter>();
export function TRPCReactProvider(props: { ssrOnlySecret: string, children: React.ReactNode }) {
const queryClient = getQueryClient();
const [trpcClient] = React.useState(() =>
api.createClient({
links: [
loggerLink({
enabled: (op) =>
process.env.NODE_ENV === "development" ||
(op.direction === "down" && op.result instanceof Error),
}),
unstable_httpBatchStreamLink({
transformer: SuperJSON,
url: getBaseUrl() + "/api/trpc",
async headers() {
const headers = new Headers();
const secret = props.ssrOnlySecret;
const value = await readSSROnlySecret(secret,"SECRET_CLIENT_COOKIE_VAR")
headers.set("x-trpc-source", "nextjs-react");
if(value) {
headers.set("cookie", value);
}
return headers;
},
}),
],
}),
);
return (
<QueryClientProvider client={queryClient}>
<api.Provider client={trpcClient} queryClient={queryClient}>
{props.children}
</api.Provider>
</QueryClientProvider>
);
}
Using this package phryneas/ssr-only-secrets seems to be working great to pass the cookie to the headers on the SSR piece
Add a new env variable:
// .env.local SECRET_CLIENT_COOKIE_VAR={"key_ops":["encrypt","decrypt"],"ext":true,"kty":"oct","k":"asdas....","alg":"A256CBC"}I used the code provided to create the key above:
crypto.subtle .generateKey( { name: "AES-CBC", length: 256, }, true, ["encrypt", "decrypt"] ) .then((key) => crypto.subtle.exportKey("jwk", key)) .then(JSON.stringify) .then(console.log);Then in the layout file, access the cookie, encrypt it and pass it to the TRPCReactProvider
// app/layout.tsx import { headers } from "next/headers"; import { TRPCReactProvider } from "@/trpc/react"; export default async function Layout(props: { children: React.ReactNode; }) { const cookie = new Headers(headers()).get("cookie"); const encryptedCookie = await cloakSSROnlySecret(cookie ?? "", "SECRET_CLIENT_COOKIE_VAR") return <html> <Head/> <body> ... <TRPCReactProvider ssrOnlySecret={encryptedCookie}> {props.children} </TRPCReactProvider> </body> </.html> }Then decrypt the value and pass it to the headers on the client side. Reading the value on the browser always returns
undefinedso you won't be able to see it there.// trcp/react.ts "use client"; import { readSSROnlySecret } from "ssr-only-secrets"; import type { AppRouter } from "@beebook/api"; import * as React from "react"; import { QueryClientProvider } from "@tanstack/react-query"; import { loggerLink, unstable_httpBatchStreamLink } from "@trpc/client"; import { createTRPCReact } from "@trpc/react-query"; import SuperJSON from "superjson"; import { getBaseUrl, getQueryClient } from "./utils"; export const api = createTRPCReact<AppRouter>(); export function TRPCReactProvider(props: { ssrOnlySecret: string, children: React.ReactNode }) { const queryClient = getQueryClient(); const [trpcClient] = React.useState(() => api.createClient({ links: [ loggerLink({ enabled: (op) => process.env.NODE_ENV === "development" || (op.direction === "down" && op.result instanceof Error), }), unstable_httpBatchStreamLink({ transformer: SuperJSON, url: getBaseUrl() + "/api/trpc", async headers() { const headers = new Headers(); const secret = props.ssrOnlySecret; const value = await readSSROnlySecret(secret,"SECRET_CLIENT_COOKIE_VAR") headers.set("x-trpc-source", "nextjs-react"); if(value) { headers.set("cookie", value); } return headers; }, }), ], }), ); return ( <QueryClientProvider client={queryClient}> <api.Provider client={trpcClient} queryClient={queryClient}> {props.children} </api.Provider> </QueryClientProvider> ); }
you're a legend for this — thanks
I ended up using useQuery instead of useSuspenseQuery and setting throwOnError to true in the defaultOptions of QueryClient. It was too much work for me to prefetch every suspense query.
Is this issue tracked upstream somewhere (either tRPC or Next.js)? It's a critical issue, but this is basically the only thread I could find about it.