
Help me understand if my current setup is correct and lmk what I'm doing wrong
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
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;
}),
});