How do I use the rsc-rq-prefetch example with a protected procedure?

Thank you for this implementation! It looks sooo promising 😀 However, I ran into an issue when trying it out: https://github.com/trpc/trpc/blob/next/examples/.experimental/next-app-dir/src/app/rsc-rq-prefetch/page.tsx If we have a protected procedure instead of a public one, the server is not authenticated during SSR and an error is thrown. How can we solve this? Can we just ignore the error and will it still work?
GitHub
trpc/examples/.experimental/next-app-dir/src/app/rsc-rq-prefetch/pa...
🧙‍♀️ Move Fast and Break Nothing. End-to-end typesafe APIs made easy. - trpc/trpc
13 Replies
testsubject1137
testsubject11376mo ago
I think something changed between rc.403 and current RC versions. I switched from npm to pnpm today and it updated me to the latest RC and this broke. It started trying to run the client query on the server which caused my console to go nuts with a huge error output. Rolling back to 403 and it's working again.
Michael Schaufelberger
oh my god, yep. that was the issue .... sigh, I was so confused Is there a changelog of some kind so I know if the PR is even released in rc.403?
testsubject1137
testsubject11376mo ago
My guess is this is related: https://github.com/trpc/trpc/pull/5810
GitHub
fix(react-query): return same Proxy-object to avoid infinite recu...
Closes #5808 🎯 Changes This is a bit of a mindfuck. ✅ Checklist I have followed the steps listed in the Contributing guide. If necessary, I have added documentation related to the changes made. ...
Michael Schaufelberger
Hi @testsubject1137 Have you had some success with more recent versions in the meantime? Even with rc.403, I'm getting an error in my isAuthed trpc middleware.
const isAuthed = t.middleware(({ next, ctx }) => {
if (!ctx.auth.userId) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Not authenticated' });
}

return next({
ctx: {
auth: ctx.auth,
},
});
});
const isAuthed = t.middleware(({ next, ctx }) => {
if (!ctx.auth.userId) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Not authenticated' });
}

return next({
ctx: {
auth: ctx.auth,
},
});
});
since, as you've said, the server is running the client component query during ssr.
testsubject1137
testsubject11375mo ago
This is how I'm doing it, and it's working on rc.403.
export const publicProcedure = t.procedure;

export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.session || !ctx.session.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}

return next({
ctx: {
session: {
...ctx.session,
user: ctx.session.user,
},
},
});
});
export const publicProcedure = t.procedure;

export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.session || !ctx.session.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}

return next({
ctx: {
session: {
...ctx.session,
user: ctx.session.user,
},
},
});
});
Michael Schaufelberger
Hmm, does your trpc client component client run during SSR? I'm experiencing strange behaviour... My next.js receives 3 trpc requests for the following page: foo/page.tsx
import { HydrateClient, trpc } from '@/app/api/trpc/client/rsc';
import { Foo } from '../../foo';

const FooPage = async () => {
void trpc.foo.prefetch();

return (
<HydrateClient>
<Foo />
</HydrateClient>
);
};

export default FooPage;
import { HydrateClient, trpc } from '@/app/api/trpc/client/rsc';
import { Foo } from '../../foo';

const FooPage = async () => {
void trpc.foo.prefetch();

return (
<HydrateClient>
<Foo />
</HydrateClient>
);
};

export default FooPage;
Server Logs
[MIDDLEWARE]: http://localhost:3000/foo null
createContextCached []
isAuthed { 'x-trpc-source': 'react-query-ssr', 'ctx.auth.userId': 'missing' }
a [TRPCClientError]: Not authenticated
at ...
(error info of the previous posted error)
}
isAuthed { 'x-trpc-source': 'rsc', 'ctx.auth.userId': 'exists' }
isAuthed { 'x-trpc-source': 'react-query', 'ctx.auth.userId': 'exists' }
[MIDDLEWARE]: http://localhost:3000/foo null
createContextCached []
isAuthed { 'x-trpc-source': 'react-query-ssr', 'ctx.auth.userId': 'missing' }
a [TRPCClientError]: Not authenticated
at ...
(error info of the previous posted error)
}
isAuthed { 'x-trpc-source': 'rsc', 'ctx.auth.userId': 'exists' }
isAuthed { 'x-trpc-source': 'react-query', 'ctx.auth.userId': 'exists' }
rsc.ts (the RSC query client)
const createContextCached = cache(
async (...args: unknown[]): Promise<Context> => {
console.log('createContextCached', args);
const _headers = new Headers(headers());
_headers.set('x-trpc-source', 'rsc');

const authObj = auth();

return {
req: undefined,
headers: Object.fromEntries(_headers),
auth: authObj,
identity: await getIdentity(authObj),
};
},
);

/**
* Create a stable getter for the query client that
* will return the same client during the same request.
*/
const getQueryClient = cache(createQueryClient);
const caller = createCallerFactory(appRouter)(createContextCached);

export const { trpc, HydrateClient } = createHydrationHelpers<typeof appRouter>(
caller,
getQueryClient,
);
const createContextCached = cache(
async (...args: unknown[]): Promise<Context> => {
console.log('createContextCached', args);
const _headers = new Headers(headers());
_headers.set('x-trpc-source', 'rsc');

const authObj = auth();

return {
req: undefined,
headers: Object.fromEntries(_headers),
auth: authObj,
identity: await getIdentity(authObj),
};
},
);

/**
* Create a stable getter for the query client that
* will return the same client during the same request.
*/
const getQueryClient = cache(createQueryClient);
const caller = createCallerFactory(appRouter)(createContextCached);

export const { trpc, HydrateClient } = createHydrationHelpers<typeof appRouter>(
caller,
getQueryClient,
);
rcc.tsx
export const trpc = createTRPCReact<AppRouter>();

let clientQueryClientSingleton: QueryClient | undefined = undefined;
const getQueryClient = () => {
if (typeof window === 'undefined') {
// Server: always make a new query client
return createQueryClient();
} else {
// Browser: use singleton pattern to keep the same query client
return (clientQueryClientSingleton ??= createQueryClient());
}
};

export function Provider(props: { children: React.ReactNode }) {
const queryClient = getQueryClient();

const [trpcClient] = useState(() =>
trpc.createClient({
links: [
unstable_httpBatchStreamLink({
transformer,
url: getBaseUrl() + '/api/trpc',
headers: {
'x-trpc-source':
typeof window !== 'undefined' ? 'react-query' : 'react-query-ssr',
},
}),
],
}),
);

return ...
export const trpc = createTRPCReact<AppRouter>();

let clientQueryClientSingleton: QueryClient | undefined = undefined;
const getQueryClient = () => {
if (typeof window === 'undefined') {
// Server: always make a new query client
return createQueryClient();
} else {
// Browser: use singleton pattern to keep the same query client
return (clientQueryClientSingleton ??= createQueryClient());
}
};

export function Provider(props: { children: React.ReactNode }) {
const queryClient = getQueryClient();

const [trpcClient] = useState(() =>
trpc.createClient({
links: [
unstable_httpBatchStreamLink({
transformer,
url: getBaseUrl() + '/api/trpc',
headers: {
'x-trpc-source':
typeof window !== 'undefined' ? 'react-query' : 'react-query-ssr',
},
}),
],
}),
);

return ...
foo.tsx (Component with useSuspenseQuery)
'use client';

import { trpc } from '../api/trpc/client/rcc';

export function Foo() {
const [foo] = trpc.foo.useSuspenseQuery();

return <div>{JSON.stringify(foo)}</div>;
}
'use client';

import { trpc } from '../api/trpc/client/rcc';

export function Foo() {
const [foo] = trpc.foo.useSuspenseQuery();

return <div>{JSON.stringify(foo)}</div>;
}
So what I would expect is: The trpc handler should be hit 1-2 times instead of 3 times. - Once for the void trpc.foo.prefetch(); in page.tsx ('x-trpc-source': 'rsc') - Maybe once for the trpc.foo.useSuspenseQuery(); in foo.tsx ('x-trpc-source': 'react-query') in case the server prefetch failed. But it should never be called during SSR with the trpc client component in rcc.tsx ('x-trpc-source': 'react-query-ssr'). Are those assumptions correct?
testsubject1137
testsubject11375mo ago
Do you have all of the packages locked to 403?
"@trpc/client": "11.0.0-rc.403",
"@trpc/next": "11.0.0-rc.403",
"@trpc/react-query": "11.0.0-rc.403",
"@trpc/server": "11.0.0-rc.403",
"@trpc/client": "11.0.0-rc.403",
"@trpc/next": "11.0.0-rc.403",
"@trpc/react-query": "11.0.0-rc.403",
"@trpc/server": "11.0.0-rc.403",
I know I had some unexpected behavior when I didn't. I have not tested to see how many/which calls tRPC is making, but my auth does work just fine. However, I don't know if it makes any difference, but mine is set up a little differently. Maybe this will help a little. /server/api/trpc.ts
export const createTRPCContext = async (opts: { headers: Headers }) => {
const db = getDatabase();
const user = await getUser();
const { ctx, env, cf } = getRequestContext();

return {
db,
user,
cf: {
...ctx,
env,
...cf,
},
...opts,
};
};

const t = initTRPC.context<typeof createTRPCContext>().create({
transformer,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});

export const createCallerFactory = t.createCallerFactory;

export const createTRPCRouter = t.router;

export const publicProcedure = t.procedure;

export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}

return next({
ctx: {
...ctx,
// infers the `session` as non-nullable
user: ctx.user,
},
});
});
export const createTRPCContext = async (opts: { headers: Headers }) => {
const db = getDatabase();
const user = await getUser();
const { ctx, env, cf } = getRequestContext();

return {
db,
user,
cf: {
...ctx,
env,
...cf,
},
...opts,
};
};

const t = initTRPC.context<typeof createTRPCContext>().create({
transformer,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});

export const createCallerFactory = t.createCallerFactory;

export const createTRPCRouter = t.router;

export const publicProcedure = t.procedure;

export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}

return next({
ctx: {
...ctx,
// infers the `session` as non-nullable
user: ctx.user,
},
});
});
/trpc/server.ts
const createContext = cache(() => {
const heads = new Headers(headers());
heads.set("x-trpc-source", "rsc");

return createTRPCContext({
headers: heads,
});
});

const caller = createCaller(createContext);

export const getQueryClient = cache(createQueryClient);

export const { HydrateClient, trpc: api } = createHydrationHelpers<AppRouter>(
caller,
getQueryClient,
);
const createContext = cache(() => {
const heads = new Headers(headers());
heads.set("x-trpc-source", "rsc");

return createTRPCContext({
headers: heads,
});
});

const caller = createCaller(createContext);

export const getQueryClient = cache(createQueryClient);

export const { HydrateClient, trpc: api } = createHydrationHelpers<AppRouter>(
caller,
getQueryClient,
);
/trpc/react.tsx
let clientQueryClientSingleton: QueryClient | undefined = undefined;

const getQueryClient = () => {
if (typeof window === "undefined") {
// Server: always make a new query client
return createQueryClient();
}
// Browser: use singleton pattern to keep the same query client
return (clientQueryClientSingleton ??= createQueryClient());
};

export const api = createTRPCReact<AppRouter>();

export function TRPCReactProvider(props: { children: React.ReactNode }) {
const queryClient = getQueryClient();

const [trpcClient] = useState(() =>
api.createClient({
links: [
loggerLink({
enabled: (op) =>
process.env.NODE_ENV === "development" ||
(op.direction === "down" && op.result instanceof Error),
}),
unstable_httpBatchStreamLink({
transformer,
url: getTRPCUrl(),
headers: () => {
const headers = new Headers();
headers.set("x-trpc-source", "nextjs-react");
return headers;
},
}),
],
}),
);

return (
<api.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{props.children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</api.Provider>
);
}
let clientQueryClientSingleton: QueryClient | undefined = undefined;

const getQueryClient = () => {
if (typeof window === "undefined") {
// Server: always make a new query client
return createQueryClient();
}
// Browser: use singleton pattern to keep the same query client
return (clientQueryClientSingleton ??= createQueryClient());
};

export const api = createTRPCReact<AppRouter>();

export function TRPCReactProvider(props: { children: React.ReactNode }) {
const queryClient = getQueryClient();

const [trpcClient] = useState(() =>
api.createClient({
links: [
loggerLink({
enabled: (op) =>
process.env.NODE_ENV === "development" ||
(op.direction === "down" && op.result instanceof Error),
}),
unstable_httpBatchStreamLink({
transformer,
url: getTRPCUrl(),
headers: () => {
const headers = new Headers();
headers.set("x-trpc-source", "nextjs-react");
return headers;
},
}),
],
}),
);

return (
<api.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{props.children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</api.Provider>
);
}
Michael Schaufelberger
Yes, I have
"@tanstack/react-query": "^5.49.2",
"@tanstack/react-query-devtools": "^5.49.2",

"@trpc/client": "11.0.0-rc.403",
"@trpc/react-query": "11.0.0-rc.403",
"@trpc/server": "11.0.0-rc.403",
"@tanstack/react-query": "^5.49.2",
"@tanstack/react-query-devtools": "^5.49.2",

"@trpc/client": "11.0.0-rc.403",
"@trpc/react-query": "11.0.0-rc.403",
"@trpc/server": "11.0.0-rc.403",
What version of tanstack react-query do you use? Maybe that's the issue and not trpc itself... Since, I don't think trpc is deciding if it should run during SSR or not... If you do something like this
headers: {
'x-trpc-source':
typeof window !== 'undefined' ? 'react-query' : 'react-query-ssr',
},
headers: {
'x-trpc-source':
typeof window !== 'undefined' ? 'react-query' : 'react-query-ssr',
},
in the httpBatchStream, and also log the headers in the protected procedure middleware - does it get logged? Because I think my setup is wrongfully calling the useSuspense query on the server during SSR and, since the server during rendering is somehow not authed, it fails...
testsubject1137
testsubject11375mo ago
I can test this for you tonight. @michaelschufi I actually just get one x-trpc-source: rsc call
Michael Schaufelberger
Thank you! This helps a lot. Now I know that this is the issue 🙏
testsubject1137
testsubject11375mo ago
Let me know if there are any code samples I can share to help you narrow it down. Oh! "@tanstack/react-query": "^5.49.0",
Michael Schaufelberger
i'll try upgrading soon to the newest rc anyways. because of #Can I use the "Streaming with Server Components" strategy with tRPC? seems to be working. Note: This has fixed itself after upgrading to a new @tanstack/react-query version. Only updating the rc version of tRPC was somehow insufficient 🤷‍♂️
"@tanstack/react-query": "^5.60.6",
"@tanstack/react-query-devtools": "^5.60.6",
"@trpc/client": "11.0.0-rc.638",
"@trpc/react-query": "11.0.0-rc.638",
"@trpc/server": "11.0.0-rc.638",
"@tanstack/react-query": "^5.60.6",
"@tanstack/react-query-devtools": "^5.60.6",
"@trpc/client": "11.0.0-rc.638",
"@trpc/react-query": "11.0.0-rc.638",
"@trpc/server": "11.0.0-rc.638",
Michael Schaufelberger
Okay, this is not true. It only works if there's a prefetch going on. Probably related to https://github.com/vercel/next.js/discussions/60640
GitHub
Allow Client Components access to request headers during SSR · verc...
Goals Allow client components to make authenticated fetch requests during the SSR prepass Non-Goals Server Components, as that's already possible using the headers() function Fetch requests mad...