Better-Auth: Headless Authentication for Your TanStack Start App

Jack HerringtonAbout 8 min readApr 22, 2025Watch original
THE SUMMARYAI-generated

Key Concepts

  • Authentication: Verifying the identity of a user.
  • Tanstack Start: A framework for building web applications.
  • Better Off: An authentication library.
  • Social Login: Authentication using third-party providers like GitHub.
  • OAUTH: An open standard for access delegation, commonly used for social login.
  • TRPC: A library for building end-to-end typesafe APIs.
  • Shadcn UI: A collection of accessible and reusable components.
  • Server Functions: Functions that run on the server.
  • API Routes: Server endpoints that handle requests.
  • Middleware: Functions that intercept and modify requests.
  • Loaders: Functions that fetch data before a route is rendered.
  • Tanstack Query: A library for fetching, caching, and updating asynchronous data.

Setting Up the Project

  1. Create a Tanstack Start App: Use create-tanzack-start-app with TRPC and Shadcn UI add-ons. The command used was create-tanzack-start-app better-off-setup --add trpc,shadcn-ui.
  2. Install Better Off: Add the Better Off library using pmppm add better-off.
  3. Environment Variables: Create a .env file and add the OAUTH_SECRET variable. Generate a secure secret for this variable.
  4. OAUTH URL: Set the OAUTH_URL environment variable to the base URL of the application (e.g., localhost:3000 in development).

Configuring GitHub OAUTH

  1. Create a GitHub OAUTH App: Go to GitHub Developer Settings and create a new OAUTH app.
  2. Homepage URL: Set the homepage URL to the application's base URL.
  3. Authorization Callback URL: Set the redirect URI to /api/auth/callback/github. Note that GitHub only allows one callback URL per app, so you'll need separate apps for development and production.
  4. Client ID and Secret: Obtain the client ID and client secret from the GitHub OAUTH app settings.
  5. Environment Variables: Add GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET to the .env file.

Better Off Instance

  1. Create o.ts: Create a file (e.g., lib/o.ts) to instantiate the Better Off instance.

  2. Import Better Off: Import the betterOff function from the better-off library.

  3. Instantiate Better Off: Use the betterOff function to create an instance of Better Off, exporting it as o.

  4. Define Providers: Configure the authentication providers (e.g., GitHub) with their respective client IDs and secrets.

    import { betterOff } from 'better-off';
    import { gitHub } from 'better-off/providers';
    
    export const o = betterOff({
      providers: [
        gitHub({
          clientId: process.env.GITHUB_CLIENT_ID!,
          clientSecret: process.env.GITHUB_CLIENT_SECRET!,
        }),
      ],
    });
    

API Route for OAUTH Callback

  1. Create API Route: Create a file at routes/api/auth/[$] (e.g., routes/api/auth/[$].tsx). The [$] syntax in Tanstack Start creates a dynamic route segment.

  2. Import O: Import the o instance from lib/o.ts.

  3. Create API Endpoint: Create a handler function that handles both GET and POST requests and passes them to the o.handler.

    import { o } from '~/lib/o';
    
    export async function GET({ request, params }: any) {
      return o.handler({ request, params });
    }
    
    export async function POST({ request, params }: any) {
      return o.handler({ request, params });
    }
    

React Hooks

  1. Create o-client.ts: Create a file (e.g., lib/o-client.ts) to create the React hooks.

  2. Import createOClient: Import createOClient from better-off/react.

  3. Create O Client: Use createOClient to generate the useSession, signIn, signOut, signUp, and getSession hooks.

  4. API Endpoint Configuration: Configure the apiEndpoint option to point to the correct API route (e.g., http://localhost:3000/api/auth in development).

    import { createOClient } from 'better-off/react';
    
    export const { useSession, signIn, signOut, signUp, getSession } = createOClient({
      apiEndpoint: process.env.NODE_ENV === 'development' ? 'http://localhost:3000/api/auth' : '/api/auth',
    });
    

Implementing Sign-In and Sign-Out

  1. Import Hooks and Components: Import the useSession, signIn, and signOut hooks from lib/o-client.ts, and the Button component from Shadcn UI.

  2. Use useSession Hook: Use the useSession hook to get the current session data.

  3. Conditional Rendering: Conditionally render sign-in and sign-out buttons based on the session status.

    import { Button } from '~/components/ui/button';
    import { useSession, signIn, signOut } from '~/lib/o-client';
    
    export default function Home() {
      const { session } = useSession();
    
      return (
        <div className="flex flex-col items-center justify-center h-screen">
          {session ? (
            <>
              <p>Signed in as {session.user.name}</p>
              <Button onClick={() => signOut()}>Sign Out</Button>
            </>
          ) : (
            <Button onClick={() => signIn('github')}>Sign In with GitHub</Button>
          )}
        </div>
      );
    }
    

Authenticated Routes

  1. Create Route: Create a new route (e.g., routes/dashboard.tsx).

  2. Create Server Function (getUserId.ts): Create a server function to get the user ID from the session.

  3. Create Middleware (o-middleware.ts): Create middleware to extract the session from the request headers and pass it to the server function.

    // o-middleware.ts
    import { createMiddleware } from '@tanzack/start/server';
    import { getSession } from '~/lib/o-client';
    
    export const oMiddleware = createMiddleware({
      server: async ({ fetchOptions }) => {
        const session = await getSession(fetchOptions);
        return {
          user: session?.user,
        };
      },
    });
    
    // getUserId.ts
    import { serverFn } from '@tanzack/start/server';
    import { oMiddleware } from './o-middleware';
    
    export const getUserId = serverFn({
      middleware: [oMiddleware],
    }, async ({ ctx }) => {
      return ctx.user?.id;
    });
    
  4. Implement Loader: In the route's beforeLoad callback, call the getUserId server function. If the user ID is not present, redirect to the homepage.

    import { redirect } from '@tanzack/start/server';
    import { getUserId } from './getUserId';
    
    export const beforeLoad = () => {
      return {
        userId: getUserId(),
      };
    };
    
    export async function loader({ context }:any) {
      if (!context.userId) {
        throw redirect('/');
      }
      return {
        userId: context.userId,
      };
    }
    
    export default function Dashboard() {
      return (
        <div>
          <h1>Dashboard</h1>
          <p>User ID: {context.userId}</p>
        </div>
      );
    }
    

Authenticating Server Functions

  1. Create Server Function (getAvatar.ts): Create a server function to get the user's avatar URL from the session.

  2. Use Middleware: Apply the same oMiddleware to the server function to access the session data.

    // getAvatar.ts
    import { serverFn } from '@tanzack/start/server';
    import { oMiddleware } from './o-middleware';
    
    export const getAvatar = serverFn({
      middleware: [oMiddleware],
    }, async ({ ctx }) => {
      return ctx.user?.image;
    });
    
  3. Call Server Function: Call the server function from the client using useEffect and useState to display the avatar.

    import { useState, useEffect } from 'react';
    import { getAvatar } from './getAvatar';
    
    export default function Dashboard() {
      const [avatar, setAvatar] = useState<string | null>(null);
    
      useEffect(() => {
        getAvatar().then(setAvatar);
      }, []);
    
      return (
        <div>
          <h1>Dashboard</h1>
          {avatar && <img src={avatar} alt="Avatar" />}
        </div>
      );
    }
    

Authenticating API Routes

  1. Create API Route: Create a new API route (e.g., routes/api/name.tsx).

  2. Get Session: Use o.api.getSession to get the session from the request headers.

  3. Check Session: If the session is not present, return an unauthorized response. Otherwise, return the requested data.

    import { o } from '~/lib/o';
    
    export async function GET({ request }: any) {
      const session = await o.api.getSession(request.headers);
    
      if (!session) {
        return new Response(null, { status: 401 });
      }
    
      return Response.json({ name: 'Your Name' });
    }
    
  4. Call API Route: Use Tanstack Query to call the API route and display the data.

    import { useQuery } from '@tanstack/react-query';
    
    export default function Dashboard() {
      const { data: username } = useQuery({
        queryKey: ['name'],
        queryFn: () => fetch('/api/name').then((res) => res.json()),
      });
    
      return (
        <div>
          <h1>Dashboard</h1>
          <p>Username: {JSON.stringify(username)}</p>
        </div>
      );
    }
    

Authenticating TRPC Routes

  1. Create Context (createContext.ts): Create a function to create the TRPC context, including the session data.

    // createContext.ts
    import { getSession } from '~/lib/o-client';
    
    export async function createContext({ req }: any) {
      const session = await getSession(req.headers);
      return {
        session,
      };
    }
    
  2. Update TRPC Handler: Update the TRPC API handler to use the createContext function.

    // api/trpc/[trpc].ts
    import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
    import { appRouter } from '~/server/router';
    import { createContext } from '~/server/context';
    
    const handler = async (req: Request): Promise<Response> => {
      const response = await fetchRequestHandler({
        endpoint: '/api/trpc',
        router: appRouter,
        req,
        createContext: () => createContext({ req }),
        onError:
          process.env.NODE_ENV === 'development'
            ? ({ path, error }) => {
                console.error(
                  `❌ tRPC failed on ${path ?? '<no-path>'}: ${error.message}`,
                );
              }
            : undefined,
      });
      return response;
    };
    
    export { handler as GET, handler as POST };
    
  3. Create Protected Procedure: Create a protected procedure that checks for a session and throws an error if it's not present.

    // server/router/index.ts
    import { t } from './trpc';
    import { TRPCError } from '@trpc/server';
    
    const isAuthed = t.middleware(({ next, ctx }) => {
      if (!ctx.session) {
        throw new TRPCError({ code: 'UNAUTHORIZED' });
      }
      return next({
        ctx: {
          session: ctx.session,
          user: ctx.session.user,
        },
      });
    });
    
    export const protectedProcedure = t.procedure.use(isAuthed);
    
  4. Implement Protected Route: Create a TRPC route that uses the protected procedure.

    // server/router/index.ts
    import { protectedProcedure, publicProcedure, router } from './trpc';
    
    export const appRouter = router({
      getUsername: protectedProcedure.query(({ ctx }) => {
        return { username: ctx.session.user.name };
      }),
    });
    
    export type AppRouter = typeof appRouter;
    
  5. Call TRPC Route: Use useTRPC and Tanstack Query to call the TRPC route and display the data.

    import { useQuery } from '@tanstack/react-query';
    import { useTRPC } from '~/utils/trpc';
    
    export default function Dashboard() {
      const trpc = useTRPC();
      const { data: username } = trpc.getUsername.useQuery();
    
      return (
        <div>
          <h1>Dashboard</h1>
          <p>Username from TRPC: {JSON.stringify(username)}</p>
        </div>
      );
    }
    

Conclusion

The video demonstrates how to integrate Better Off into a Tanstack Start application to handle authentication across various parts of the application, including React components, server functions, API routes, and TRPC endpoints. It covers setting up social login with GitHub, creating authenticated routes, and securing server-side logic. The key takeaway is that Better Off provides the core authentication mechanics, while the UI and specific implementation details are left to the developer.

AI summaries can miss context or contain errors. Check important details against the original video.

Go a little deeper.

Have a question about this video? Load its transcript to open the video chat.