Correct way to protect routes on server and client with better auth
Hi all
Currently integrating a Tanstack Start + React query app with Better auth.
This is my setup at the moment:
Guest routes protection:
I obviously still have my
Then the
Is this the correct/recommended way tho? Am I missing or overcomplicating something?
Currently integrating a Tanstack Start + React query app with Better auth.
This is my setup at the moment:
src/features/auth/fn/get-user.tssrc/features/auth/fn/get-user.tsimport { createServerFn } from "@tanstack/react-start";
import { getRequest, setResponseHeader } from "@tanstack/react-start/server";
import { auth } from "@/lib/auth";
export const getUser = createServerFn({ method: "GET" }).handler(async () => {
const session = await auth.api.getSession({
headers: getRequest().headers,
returnHeaders: true,
});
// Forward any Set-Cookie headers to the client, e.g. for session/cache refresh
const cookies = session.headers?.getSetCookie();
if (cookies?.length) {
setResponseHeader("Set-Cookie", cookies);
}
return session.response?.user || null;
});import { createServerFn } from "@tanstack/react-start";
import { getRequest, setResponseHeader } from "@tanstack/react-start/server";
import { auth } from "@/lib/auth";
export const getUser = createServerFn({ method: "GET" }).handler(async () => {
const session = await auth.api.getSession({
headers: getRequest().headers,
returnHeaders: true,
});
// Forward any Set-Cookie headers to the client, e.g. for session/cache refresh
const cookies = session.headers?.getSetCookie();
if (cookies?.length) {
setResponseHeader("Set-Cookie", cookies);
}
return session.response?.user || null;
});query-options.tsquery-options.ts:import { queryOptions } from "@tanstack/react-query";
import { getUser } from "../fn/get-user";
import { AUTH_KEYS } from "./query-keys";
export const authQueryOptions = () =>
queryOptions({
queryKey: AUTH_KEYS.user,
queryFn: ({ signal }) => getUser({ signal }),
staleTime: 1000 * 60 * 5, // 5 minutes - auth data doesn't change often
gcTime: 1000 * 60 * 10, // 10 minutes in cache
});
export type AuthQueryResult = Awaited<ReturnType<typeof getUser>>;import { queryOptions } from "@tanstack/react-query";
import { getUser } from "../fn/get-user";
import { AUTH_KEYS } from "./query-keys";
export const authQueryOptions = () =>
queryOptions({
queryKey: AUTH_KEYS.user,
queryFn: ({ signal }) => getUser({ signal }),
staleTime: 1000 * 60 * 5, // 5 minutes - auth data doesn't change often
gcTime: 1000 * 60 * 10, // 10 minutes in cache
});
export type AuthQueryResult = Awaited<ReturnType<typeof getUser>>;src/middleware/route-auth.tssrc/middleware/route-auth.tsexport const authMiddleware = createMiddleware().server(async ({ next }) => {
const headers = getRequestHeaders();
const session = await auth.api.getSession({ headers });
if (!session) {
throw redirect({ to: "/login" });
}
return await next({
context: {
headers,
session,
user: session.user,
},
});
});export const authMiddleware = createMiddleware().server(async ({ next }) => {
const headers = getRequestHeaders();
const session = await auth.api.getSession({ headers });
if (!session) {
throw redirect({ to: "/login" });
}
return await next({
context: {
headers,
session,
user: session.user,
},
});
});src/routes/_protected/route.tsxsrc/routes/_protected/route.tsxexport const Route = createFileRoute("/_protected")({
component: PlatformLayout,
server: {
middleware: [authMiddleware],
},
beforeLoad: async ({ context }) => {
const user = await context.queryClient.ensureQueryData({
...authQueryOptions(),
revalidateIfStale: true,
});
if (!user) {
throw redirect({ to: "/login" });
}
// re-return to update type as non-null for child routes
return { user };
},
});export const Route = createFileRoute("/_protected")({
component: PlatformLayout,
server: {
middleware: [authMiddleware],
},
beforeLoad: async ({ context }) => {
const user = await context.queryClient.ensureQueryData({
...authQueryOptions(),
revalidateIfStale: true,
});
if (!user) {
throw redirect({ to: "/login" });
}
// re-return to update type as non-null for child routes
return { user };
},
});Guest routes protection:
// For routes like /login, /signup
beforeLoad: async ({ context }) => {
const user = await context.queryClient.ensureQueryData({
...authQueryOptions(),
revalidateIfStale: true,
});
if (user) {
throw redirect({ to: "/dashboard" }); // redirect authenticated users away
}
},// For routes like /login, /signup
beforeLoad: async ({ context }) => {
const user = await context.queryClient.ensureQueryData({
...authQueryOptions(),
revalidateIfStale: true,
});
if (user) {
throw redirect({ to: "/dashboard" }); // redirect authenticated users away
}
},useAuthuseAuth hook:export const useAuth = () => {
const { data: user, isPending } = useQuery(authQueryOptions());
return { user, isPending };
};
// Usage in components
const { user } = useAuth();export const useAuth = () => {
const { data: user, isPending } = useQuery(authQueryOptions());
return { user, isPending };
};
// Usage in components
const { user } = useAuth();I obviously still have my
server.middlewareserver.middleware set to [authMiddleware][authMiddleware] in the Route, so if it happens on the server, it's protected.Then the
beforeLoadbeforeLoad stuff protects on the client side.Is this the correct/recommended way tho? Am I missing or overcomplicating something?