#Correct way to protect routes on server and client with better auth

9 messages ยท Page 1 of 1 (latest)

red breach
#

Hi all ๐Ÿ‘‹

Currently integrating a Tanstack Start + React query app with Better auth.

This is my setup at the moment:

src/features/auth/fn/get-user.ts

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.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>>;

src/middleware/route-auth.ts

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.tsx

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
  }
},

useAuth hook:

export const useAuth = () => {
  const { data: user, isPending } = useQuery(authQueryOptions());
  return { user, isPending };
};

// Usage in components
const { user } = useAuth();

I obviously still have my server.middleware set to [authMiddleware] in the Route, so if it happens on the server, it's protected.
Then the beforeLoad stuff protects on the client side.

Is this the correct/recommended way tho? Am I missing or overcomplicating something? ๐Ÿค”

mossy gulch
#

authMiddleware in the route seems redundant imo (unless if it's a server/API route). beforeLoad is isomorphic and also runs on the server during SSR

red breach
#

Gotcha. So that middleware should only really be used in server functions basically?

mossy gulch
#

Well your current setup still works since middleware also works for SSR in page routes

#

But yeah imo I'd just rely on beforeLoad for page routes since it works for both SSR and client navigations. Having authMiddleware would probably double the auth check on SSR since both middleware and beforeLoad will run

red breach
#

Makes sense! Thank you so much @mossy gulch ๐Ÿ™‚

red breach
#

Not really a Start question, but since I have you here ๐Ÿ˜„

I have a _platform folder which has a ton of routes, all protected.

On the route.tsx of that folder, I have:

    beforeLoad: async ({ context, search }) => {
        const user = await context.queryClient.ensureQueryData({
            ...authQueryOptions(),
            revalidateIfStale: true,
        });
        if (!user) {
            // Preserve error param when redirecting to login (e.g., from failed email verification)
            throw redirect({
                to: "/login",
                search: search.error ? { error: search.error } : undefined,
            });
        }

        // Return user in context for child routes to access
        return { user };
    },

So it fetches the user, if there is no user, sends to /login

Then, down the line, a component needs the user (like a sidebar for example which is rendered in a route under the _platform route:

    const { data } = useSuspenseQuery(authQueryOptions());
    // biome-ignore lint/style/noNonNullAssertion: User existence enforced by route guard
    const user = data!;
export const authQueryOptions = () =>
    queryOptions({
        queryKey: AUTH_KEYS.user,
        queryFn: ({ signal }) => getUser({ signal }),
    });

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;
});

Is there a better way of doing this? I know that if this component renders, the user exists, otherwise we would have been redirected.

I don't like having to do const user = data!; thinkpad

lofty elm