I am currently learning how the technologies mentioned below work together. My current setup is Turborepo, Next.JS, Supabase, Supabase Auth, drizzle, tRPC v11, tanstack-query.
I think I'm missing something and I have a few questions.
From what I understand I only need to create a client if my api would be in the apps folder (used by hono or express). In that case I should use supabase.auth.getSession() in apps/web, get the access token, add it in headers and send it to the apps/api, correct? By doing this, I would verify that the client making the request is the same with the one that is on the apps/api.
Am I using supabase.auth.getClaims() correctly? Am I calling this function too many times? I'm suppose it's ok to use getClaims() since I am not using a separate app for the API.
In total, I am using supabase.auth.getClaims() in 3 places: server.tsx, trpc/[trpc]/route.ts, auth/callback/route.ts, and a 4th place would be in proxy.ts once I write it. Is this setup correct?
If someone can take the time to look over the code and clarify when getClaims() should be used and why and why in a separate app it is important to send an "access token" in headers, I would much appreciate it. This is all a bit confusing to me.
monorepo/
├── apps/
│ └── web/ # Next.js app
│ ├── app/
│ │ ├── api/
│ │ │ └── trpc/
│ │ │ └── route.ts # tRPC HTTP handler
│ │ ├── auth/
│ │ │ └── callback/
│ │ │ └── route.ts # Fixed auth callback
│ │ └── layout.tsx # Wraps TRPCReactProvider
│ ├── lib/
│ │ └── trpc/
│ │ ├── client.tsx # TRPCReactProvider
│ │ ├── server.tsx # trpc proxy for RSC
│ │ └── query-client.ts
│ └── utils/
│ └── constants.ts
├── packages/
│ ├── db/ # Drizzle + postgres
│ │ ├── client.ts # db
│ │ └── instrument.ts
│ ├── trpc/
│ │ ├── init.ts # t, createTRPCContext, middleware
│ │ └── routers/
│ │ ├── _app.ts # appRouter
│ │ ├── users.ts
│ │ └── ...
│ ├── supabase/
│ │ └── server.ts # createClient
│ └── utils/
│ └── sanitize-redirect.ts
└── package.json
The user is trying to understand the correct usage of Supabase Auth and tRPC in their monorepo setup, which includes technologies like Turborepo, Next.js, and Drizzle. They have specific questions about using supabase.auth.getSession() and supabase.auth.getClaims(), and how to handle access tokens and claims in their application. They are seeking guidance on whether their current implementation is correct and how to properly integrate these technologies.
Here are my files:
packages\trpc\src\init.ts:
import type { Database } from "@monorepo/db/client";
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
export type AuthClaims = {
sub: string;
email?: string;
role?: string;
app_metadata?: Record<string, unknown>;
user_metadata?: Record<string, unknown>;
[key: string]: unknown;
};
export type TRPCContext = {
headers: Headers;
claims: AuthClaims | null;
db: Database;
};
/**
* This context creator accepts `headers` so it can be reused in both
* the RSC server caller (where you pass `next/headers`) and the
* API route handler (where you pass the request headers).
*/
export const createTRPCContext = async (opts: {
headers: Headers;
claims: AuthClaims | null;
db: Database;
}): Promise<TRPCContext> => {
// const user = await auth(opts.headers);
return {
headers: opts.headers,
claims: opts.claims,
db: opts.db,
};
};
// Avoid exporting the entire t-object
// since it's not very descriptive.
// For instance, the use of a t variable
// is common in i18n libraries.
const t = initTRPC.context<Awaited<ReturnType<typeof createTRPCContext>>>().create({
/**
* u/see https://trpc.io/docs/server/data-transformers
*/
transformer: superjson,
});
const enforceAuth = t.middleware(async ({ ctx, next }) => {
if (!ctx.claims) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You must be signed in to perform this action",
});
}
return next({
ctx: {
...ctx,
claims: ctx.claims,
},
});
});
// Base router and procedure helpers
export const createTRPCRouter = t.router;
export const createCallerFactory = t.createCallerFactory;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(enforceAuth);
apps\web\src\trpc\server.tsx:
// tRPC caller for Server Components
// To prefetch queries from server components, we create a proxy from our router.
// You can also pass in a client if your router is on a separate server.
import "server-only";
// <-- ensure this file cannot be imported from the client
import { cache } from "react";
import { headers } from "next/headers";
import { db } from "@monorepo/db/client";
import { createClient } from "@monorepo/supabase/server";
import { createTRPCContext } from "@monorepo/trpc/init";
import type { AppRouter } from "@monorepo/trpc/routers/_app";
import { appRouter } from "@monorepo/trpc/routers/_app";
import { createTRPCOptionsProxy, type TRPCQueryOptions } from "@trpc/tanstack-react-query";
import { makeQueryClient } from "./query-client";
// IMPORTANT: Create a stable getter for the query client that
// will return the same client during the same request.
export const getQueryClient = cache(makeQueryClient);
export const trpc = createTRPCOptionsProxy<AppRouter>({
ctx: async () => {
const supabase = await createClient();
const { data } = await supabase.auth.getClaims();
return createTRPCContext({
headers: await headers(),
claims: data?.claims ?? null,
db,
});
},
router: appRouter,
queryClient: getQueryClient,
});
// If your router is on a separate server, pass a client instead:
// createTRPCOptionsProxy({
// client: createTRPCClient({ links: [httpLink({ url: '...' })] }),
// queryClient: getQueryClient,
// });
export function prefetch<T extends ReturnType<TRPCQueryOptions<any>>>(queryOptions: T) {
const queryClient = getQueryClient();
if (queryOptions.queryKey[1]?.type === "infinite") {
void queryClient.prefetchInfiniteQuery(queryOptions as any).catch(() => {
// Avoid unhandled promise rejections from fire-and-forget prefetches.
});
} else {
void queryClient.prefetchQuery(queryOptions).catch(() => {
// Avoid unhandled promise rejections from fire-and-forget prefetches.
});
}
}
export function batchPrefetch<T extends ReturnType<TRPCQueryOptions<any>>>(queryOptionsArray: T[]) {
const queryClient = getQueryClient();
for (const queryOptions of queryOptionsArray) {
if (queryOptions.queryKey[1]?.type === "infinite") {
void queryClient.prefetchInfiniteQuery(queryOptions as any).catch(() => {
// Avoid unhandled promise rejections from fire-and-forget prefetches.
});
} else {
void queryClient.prefetchQuery(queryOptions).catch(() => {
// Avoid unhandled promise rejections from fire-and-forget prefetches.
});
}
}
}
apps\web\src\app\api\trpc\[trpc]\route.ts:
import { db } from "@monorepo/db/client";
import { createClient } from "@monorepo/supabase/server";
import { createTRPCContext } from "@monorepo/trpc/init";
import { appRouter } from "@monorepo/trpc/routers/_app";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: async () => {
const supabase = await createClient();
const { data } = await supabase.auth.getClaims();
return createTRPCContext({
headers: req.headers,
claims: data?.claims ?? null,
db,
});
},
});
export { handler as GET, handler as POST };
Now here is the auth, which I am not sure is correct. Here I am trying to verify the user role which is just a column in public.users table. Should I use tRPC here or just query the database directly?
apps\web\src\app\api\auth\callback\route.ts:
import { cookies } from "next/headers";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { db } from "@monorepo/db/client";
import { getUserById, getUserInvitesByEmail } from "@monorepo/db/queries";
import { createClient } from "@monorepo/supabase/server";
import { sanitizeRedirectPath } from "@monorepo/utils/sanitize-redirect";
import { addYears } from "date-fns";
import { Cookies } from "@/lib/utils/constants";
export async function GET(req: NextRequest) {
const cookieStore = await cookies();
const requestUrl = new URL(req.url);
const code = requestUrl.searchParams.get("code");
const returnTo = requestUrl.searchParams.get("return_to");
const provider = requestUrl.searchParams.get("provider");
if (provider) {
cookieStore.set(Cookies.PreferredSignInProvider, provider, {
expires: addYears(new Date(), 1),
});
}
if (code) {
const supabase = await createClient();
await supabase.auth.exchangeCodeForSession(code);
const { data } = await supabase.auth.getClaims();
const user = data?.claims;
if (user) {
const userId = user.sub;
const userEmail = user.email;
const userData = await getUserById(db, userId);
const userRole = userData?.role;
// Check if user has pending invitations when role is null
if (userRole === null) {
const inviteData = await getUserInvitesByEmail(db, userEmail!);
// If there's an invitation, send them to teams page
if (inviteData) {
return NextResponse.redirect(`${requestUrl.origin}/team`);
}
// Otherwise proceed with setup
return NextResponse.redirect(`${requestUrl.origin}/setup`);
}
if (userRole === "admin" || userRole === "teacher") {
return NextResponse.redirect(`${requestUrl.origin}/dashboard`);
}
if (userRole === "student" || userRole === "parent") {
return NextResponse.redirect(`${requestUrl.origin}/profile`);
}
}
}
if (returnTo) {
// The middleware strips the leading "/" (e.g. "settings/accounts"),
// but sanitizeRedirectPath requires a root-relative path starting with "/".
const normalized = returnTo.startsWith("/") ? returnTo : `/${returnTo}`;
const safePath = sanitizeRedirectPath(normalized);
return NextResponse.redirect(`${origin}${safePath}`);
}
return NextResponse.redirect(requestUrl.origin);
}
This is the tRPC client:
"use client";
// ^-- to make sure we can mount the Provider from a server component
import { useState } from "react";
import type { AppRouter } from "@monorepo/trpc/routers/_app";
import type { QueryClient } from "@tanstack/react-query";
import { QueryClientProvider } from "@tanstack/react-query";
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import { createTRPCContext } from "@trpc/tanstack-react-query";
import superjson from "superjson";
import { makeQueryClient } from "./query-client";
export const { TRPCProvider, useTRPC } = createTRPCContext<AppRouter>();
let browserQueryClient: QueryClient;
function getQueryClient() {
if (typeof window === "undefined") {
// Server: always make a new query client
return makeQueryClient();
}
// Browser: make a new query client if we don't already have one
// This is very important, so we don't re-make a new client if React
// suspends during the initial render. This may not be needed if we
// have a suspense boundary BELOW the creation of the query client
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
function getUrl() {
const base = (() => {
if (typeof window !== "undefined") return "";
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return "http://localhost:3000";
})();
return `${base}/api/trpc`;
}
export function TRPCReactProvider({ children }: { children: React.ReactNode }) {
// NOTE: Avoid useState when initializing the query client if you don't
// have a suspense boundary between this and the code that may
// suspend because React will throw away the client on the initial
// render if it suspends and there is no boundary
const queryClient = getQueryClient();
const [trpcClient] = useState(() =>
createTRPCClient<AppRouter>({
links: [
httpBatchLink({
transformer: superjson,
// <-- if you use a data transformer
url: getUrl(),
}),
],
}),
);
return (
<QueryClientProvider
client
={queryClient}>
<TRPCProvider
trpcClient
={trpcClient}
queryClient
={queryClient}>
{children}
</TRPCProvider>
</QueryClientProvider>
);
}
packages\trpc\src\server\routers\users.ts:
import { getUserById } from "@monorepo/db/queries";
import { createTRPCRouter, protectedProcedure } from "../../init";
export const usersRouter = createTRPCRouter({
me: protectedProcedure.query(async ({ ctx: { db, claims } }) => {
const user = await getUserById(db, claims.sub);
return user;
}),
});