T
TanStack2mo ago
foreign-sapphire

Better Auth, Start and protected routes

Hey 👋 I'm setting up authentication with Better Auth and TanStack Start. I’ve got it working, but the solution feels messy, lots of duplicated code. Does anyone have a clean example or template to share? Ideally with good practices like global middleware for protected routes and user object available via context, etc.
24 Replies
absent-sapphire
absent-sapphire2mo ago
GitHub
GitHub - notKamui/miniverso: Self-hostable grouping of mini web app...
Self-hostable grouping of mini web applications for everyday use - notKamui/miniverso
absent-sapphire
absent-sapphire2mo ago
mind you, i also utilize daveyplate's libs to help "@daveyplate/better-auth-tanstack": "^1.3.6", "@daveyplate/better-auth-ui": "^2.0.12", don’t hesitate if you need help
extended-salmon
extended-salmon2mo ago
This is excellent 👌🏽
absent-sapphire
absent-sapphire2mo ago
thank you very much ! just curious, what did you find particularly good ? the auth implementation ? or the codebase in general ?
extended-salmon
extended-salmon2mo ago
The code base in general. I am putting together my own base and we follow similar patterns. better auth, drizzle, shad etc. The organisation looks really clean to me and has given me some ideas on cleaning up my own structure. One deviation is that I want to be authed by default. So every server action should be explicitly marked as anonymous. Too many times in the past I've come across an api endpoint that should be authed but wasn't because it was forgotten about. Which is why I am struggling a bit with global middleware not working as I want. I posted a question on your repo about how you got global middleware working. You seem to import it in multiple places but this would be an issue with me going auth first. Ie if global middleware doesnt get registered properly then poitentiall every endpoint with be not protected 😮
absent-sapphire
absent-sapphire2mo ago
thank you so much for the great compliments, i’ll answer your question here: yes for now we have to import it somewhere however, people seem to have made it work by importing in router.tsx and server.tsx only i have not tried it yet, however this seems to be the way in any case, it is NOT how they want this to work it’s not a priority, but i know they want the global middleware to just work « they » being the tanstack team and you know what, i also contemplated going authed by default, and this actually was the reason i didn’t go for it for now
extended-salmon
extended-salmon2mo ago
I'll do a bit more digging and let you know if I find anything out but I think you are way ahead of me here lol
foreign-sapphire
foreign-sapphireOP2mo ago
sorry to ask, but just trying to understand "high level" approach, for basic setup, so I only want for user to be able to navigate to some routes (pages) if it's authenticated, otherwise redirect him to e.g. /login? btw if I have all me authenticated pages under /app/... routes, is there a best practice to easily achieve it?
absent-sapphire
absent-sapphire2mo ago
have a src/routes/app.tsx file, and in the beforeLoad you check if the user is authenticated, and if not -> redirect to login, and the rendered components is just <Outlet/> with that, every subroute of /app/* will have that behavior if you then want an actual route for /app, you can create src/routes/app/index.tsx app.tsx effectively becomes a layout route @marko
foreign-sapphire
foreign-sapphireOP2mo ago
File: src/routes/app/index.tsx
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
import { getUser } from '@/common/auth/getUser';

type User = Awaited<ReturnType<typeof getUser>>;

export const Route = createFileRoute('/app/')({
beforeLoad: async () => {
const user = await getUser();
return { user };
},
loader: ({ context, location }: { context: { user: User | null }; location: { href: string } }) => {
if (!context.user) {
throw redirect({
to: '/sign-in',
search: {
// Store the current location to redirect back after login
redirect: location.href,
},
});
}
return { user: context.user };
},
component: RouteComponent,
});

function RouteComponent() {
return <Outlet />;
}
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
import { getUser } from '@/common/auth/getUser';

type User = Awaited<ReturnType<typeof getUser>>;

export const Route = createFileRoute('/app/')({
beforeLoad: async () => {
const user = await getUser();
return { user };
},
loader: ({ context, location }: { context: { user: User | null }; location: { href: string } }) => {
if (!context.user) {
throw redirect({
to: '/sign-in',
search: {
// Store the current location to redirect back after login
redirect: location.href,
},
});
}
return { user: context.user };
},
component: RouteComponent,
});

function RouteComponent() {
return <Outlet />;
}
thanks I have done it like this, but still not sure what I'm missing since it doesn't work, for example I can still navigate to /app/dashboard as logged out 🤔
absent-sapphire
absent-sapphire2mo ago
because /app/dashboard is not a subroute of /app/(index) you didn't do exactly as told this should be app.tsx, not app/index.tsx also you dont need (and shouldnt) type the loader params TS Router is already typesafe
// src/routes/app.tsx

import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
import { getUser } from '@/common/auth/getUser';

type User = Awaited<ReturnType<typeof getUser>>;

export const Route = createFileRoute('/app')({
beforeLoad: async () => {
const user = await getUser();
return { user };
},
loader: ({ context, location }) => {
if (!context.user) {
throw redirect({
to: '/sign-in',
search: {
redirect: location.href,
},
});
}
return { user: context.user };
},
component: RouteComponent,
});

function RouteComponent() {
return <Outlet />;
}
// src/routes/app.tsx

import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
import { getUser } from '@/common/auth/getUser';

type User = Awaited<ReturnType<typeof getUser>>;

export const Route = createFileRoute('/app')({
beforeLoad: async () => {
const user = await getUser();
return { user };
},
loader: ({ context, location }) => {
if (!context.user) {
throw redirect({
to: '/sign-in',
search: {
redirect: location.href,
},
});
}
return { user: context.user };
},
component: RouteComponent,
});

function RouteComponent() {
return <Outlet />;
}
foreign-sapphire
foreign-sapphireOP2mo ago
this should be app.tsx, not app/index.tsx
hm but I'm having folder based structure, shouldnt this be the same 🤔
absent-sapphire
absent-sapphire2mo ago
no app/index.tsx is the same as app.index.tsx and neither of them is the same as app.tsx app.index.tsx is to explicitely opt out of subroutes in other words, app.index.tsx can never have any subroute so app.dashboard.tsx will never be a subroute of app.index.tsx however, both app.dashboard.tsx and app.index.tsx ARE subroutes of app.tsx you can replace the dots (.) with slashes (/), it's the same thing
foreign-sapphire
foreign-sapphireOP2mo ago
hm but I'm using folder
absent-sapphire
absent-sapphire2mo ago
you dont understand
foreign-sapphire
foreign-sapphireOP2mo ago
oh I named it route.tsx seems to work
absent-sapphire
absent-sapphire2mo ago
because app/route.tsx is the same as app.tsx which is different from app/index.tsx or app.index.tsx i think you should read the docs because i feel you still dont understand the nuance because this has NOTHING to do with the issue at hand but you keep repeating
foreign-sapphire
foreign-sapphireOP2mo ago
I see thanks! hm how could I get the user in this subroute 🤔
export const Route = createFileRoute('/app/dashboard/')({
component: RouteComponent,
});

function RouteComponent() {
const { user } = Route.useLoaderData();
export const Route = createFileRoute('/app/dashboard/')({
component: RouteComponent,
});

function RouteComponent() {
const { user } = Route.useLoaderData();
do I need to define a loader again 🤔
absent-sapphire
absent-sapphire2mo ago
no, just put the redirection logic in the beforeLoad of app.tsx or app/route.tsx
foreign-sapphire
foreign-sapphireOP2mo ago
export const Route = createFileRoute('/app')({
beforeLoad: async () => {
const user = await getUser();

if (!user) {
throw redirect({
to: '/sign-in',
search: {
redirect: location.href,
},
});
}

return { user };
},
loader: ({ context }) => {
return { user: context.user };
},
component: RouteComponent,
});

function RouteComponent() {
return <Outlet />;
}
export const Route = createFileRoute('/app')({
beforeLoad: async () => {
const user = await getUser();

if (!user) {
throw redirect({
to: '/sign-in',
search: {
redirect: location.href,
},
});
}

return { user };
},
loader: ({ context }) => {
return { user: context.user };
},
component: RouteComponent,
});

function RouteComponent() {
return <Outlet />;
}
hm you mean like this, its still doesnt infer user in child routes, when trying to acces it
optimistic-gold
optimistic-gold2mo ago
Are you wanting access to user in all route context? If so, you should return the user from your __root file's loader That should be using the createRootRouteWithContext function, rather than your standard createFileRoute.
// routes/__root.tsx
export const Route = createRootRouteWithContext<RouterContext>()({
beforeLoad: async ({ context }) => {
const user = await getUser();
return { user };
}
loader: async ({ context: { user } }) => {
return { user }
},
// head, component, etc..
}
// routes/__root.tsx
export const Route = createRootRouteWithContext<RouterContext>()({
beforeLoad: async ({ context }) => {
const user = await getUser();
return { user };
}
loader: async ({ context: { user } }) => {
return { user }
},
// head, component, etc..
}
This will populate context.user in your routes
foreign-sapphire
foreign-sapphireOP2mo ago
thanks, I added
export const Route = createRootRouteWithContext<RouterAppContext>()({
beforeLoad: async ({ context }) => {
const user = await context.queryClient.fetchQuery({
queryKey: ['user'],
queryFn: ({ signal }) => getUser({ signal }),
});

return { user };
},
export const Route = createRootRouteWithContext<RouterAppContext>()({
beforeLoad: async ({ context }) => {
const user = await context.queryClient.fetchQuery({
queryKey: ['user'],
queryFn: ({ signal }) => getUser({ signal }),
});

return { user };
},
but I also realized that if I want User available on all /app/* routes (user is not null), I need to add this File: src/routes/app/route.tsx
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';

export const Route = createFileRoute('/app')({
beforeLoad: async ({ context, location }) => {
if (!context.user) {
throw redirect({
to: '/sign-in',
search: {
redirect: location.href,
},
});
}

return { user: context.user };
},
component: RouteComponent,
});

function RouteComponent() {
return <Outlet />;
}
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';

export const Route = createFileRoute('/app')({
beforeLoad: async ({ context, location }) => {
if (!context.user) {
throw redirect({
to: '/sign-in',
search: {
redirect: location.href,
},
});
}

return { user: context.user };
},
component: RouteComponent,
});

function RouteComponent() {
return <Outlet />;
}
And this is how I finnaly access the user on protected route
export const Route = createFileRoute('/app/dashboard/')({
component: RouteComponent,
loader: ({ context }) => {
return { user: context.user };
},
});

function RouteComponent() {
const { user } = Route.useLoaderData();
export const Route = createFileRoute('/app/dashboard/')({
component: RouteComponent,
loader: ({ context }) => {
return { user: context.user };
},
});

function RouteComponent() {
const { user } = Route.useLoaderData();
not sure if there is a better way
optimistic-gold
optimistic-gold2mo ago
That works! I am doing it similar to this, but instead of a route.tsx file, I use pathless layout files. Both solutions are fine
foreign-sapphire
foreign-sapphireOP2mo ago
btw I tried implementation like this in _root.tsx
beforeLoad: async ({ context }) => {
const user = await context.queryClient.fetchQuery({
queryKey: ['user'],
queryFn: ({ signal }) => getUser({ signal }),
});

return { user };
},
beforeLoad: async ({ context }) => {
const user = await context.queryClient.fetchQuery({
queryKey: ['user'],
queryFn: ({ signal }) => getUser({ signal }),
});

return { user };
},
but got some strange/buggy behavior with better auth and invalid tokens, if I tried to login and logout many times consequently, so just using now same as you posted 👍
beforeLoad: async ({ context }) => {
const user = await getUser();
return { user };
}
beforeLoad: async ({ context }) => {
const user = await getUser();
return { user };
}

Did you find this page helpful?