Skip to content

Instantly share code, notes, and snippets.

@ReaganS94
Last active November 20, 2025 08:58
Show Gist options
  • Select an option

  • Save ReaganS94/2252cb1a51a17e22e6609ce089b49cb3 to your computer and use it in GitHub Desktop.

Select an option

Save ReaganS94/2252cb1a51a17e22e6609ce089b49cb3 to your computer and use it in GitHub Desktop.

Better Auth + React + Vite frontend guide

This guide shows how to build a small React frontend that talks to the Better Auth + Express + Mongoose backend from the previous guides.

The frontend will:

  • Use the Better Auth React client to call the auth endpoints.
  • Support email and password sign up and sign in.
  • Optionally support social login (GitHub, Google, Facebook) if you configured them on the backend.
  • Call /api/me to check who is logged in.
  • Call a protected backend route that uses the requireAuth middleware.

The goal is to have a simple playground panel where you can click buttons and see everything work.


1. Assumptions and prerequisites

This guide assumes you already have the backend from the previous steps running with:

  • Express server on http://localhost:8080.
  • Better Auth mounted under /api/auth using toNodeHandler(auth).
  • CORS configured to allow http://localhost:5173 with credentials.
  • trustedOrigins in auth.ts contains http://localhost:5173.
  • /api/me route that uses auth.api.getSession and returns { user, session }.
  • Optionally, a protected route (for example GET /api/students) that uses requireAuth.

Make sure the backend is running and you can hit for example:

  • GET http://localhost:8080/api/auth/ok in the browser or Postman.
  • GET http://localhost:8080/api/me in Postman (it should return { user: null } when not logged in).

2. Create a React + Vite + TypeScript project

If you already have a React app, you can skip this step and adapt the paths.

In a separate folder from the backend, run:

npm create vite@latest better-auth-client -- --template react-ts
cd better-auth-client
npm install

Start the dev server to verify it works:

npm run dev

You should see the default Vite React starter on http://localhost:5173.


3. Install Better Auth in the frontend

From inside the frontend project folder (better-auth-client):

npm install better-auth

You only need this single package. It contains both the server helpers and the React client, but on the frontend you will only import from better-auth/react.


4. Create the auth client

Create a folder for shared libraries:

src/
  lib/
    auth-client.ts
  App.tsx
  main.tsx
  ...

Inside src/lib/auth-client.ts add:

// src/lib/auth-client.ts
import { createAuthClient } from "better-auth/react";

export const authClient = createAuthClient({
  // Base URL of your backend
  baseURL: "http://localhost:8080",

  fetchOptions: {
    // Important when frontend and backend are on different origins
    credentials: "include",
  },
});

Explanation:

  • baseURL is the root of your backend. Better Auth will automatically use /api/auth under this.
  • credentials: "include" tells fetch to send and receive cookies. This is required for the session cookie to work across origins.

5. Build an auth playground component

Now create a simple test component that lets you:

  • Sign up with email and password.
  • Sign in with email and password.
  • Sign out.
  • Start social sign in flows.
  • Check the current session using useSession.
  • Call /api/me and show the result.

You can either put this directly in App.tsx or in a separate component. For clarity, we will write it in App.tsx.

Replace the contents of src/App.tsx with the following:

import { useState } from "react";
import { createAuthClient } from "better-auth/react";
import { authClient } from "./lib/auth-client";

type Provider = "github" | "google" | "facebook";

const { useSession } = createAuthClient();

function App() {
  const [email, setEmail] = useState("john@example.com");
  const [password, setPassword] = useState("supersecret123");
  const [name, setName] = useState("john doe");
  const [status, setStatus] = useState<string>("");
  const [me, setMe] = useState<any | null>(null);

  // Better Auth hook - reads session via Better Auth's own /session endpoint
  const {
    data: session,
    isPending,
    error,
    refetch: refetchSession,
  } = useSession();

  async function handleSignUp() {
    setStatus("Signing up...");
    try {
      const { data, error } = await authClient.signUp.email({
        email,
        password,
        name,
      });

      if (error) {
        console.error(error);
        setStatus(`Sign up error: ${error.message}`);
        return;
      }

      setStatus(`Signed up as ${data.user.email}`);
      await refetchSession();
    } catch (err: any) {
      console.error(err);
      setStatus(`Sign up crashed: ${err.message ?? String(err)}`);
    }
  }

  async function handleSignIn() {
    setStatus("Signing in...");
    try {
      const { data, error } = await authClient.signIn.email({
        email,
        password,
      });

      if (error) {
        console.error(error);
        setStatus(`Sign in error: ${error.message}`);
        return;
      }

      setStatus(`Signed in as ${data.user.email}`);
      await refetchSession();
    } catch (err: any) {
      console.error(err);
      setStatus(`Sign in crashed: ${err.message ?? String(err)}`);
    }
  }

  async function handleSocial(provider: Provider) {
    setStatus(`Starting ${provider} sign in...`);
    try {
      await authClient.signIn.social({
        provider,
        // optional: callbackURL: "/",
      });
      // This will trigger a full redirect to the provider,
      // then back to your backend callback, then back to your app.
      // After you land back, useSession + /api/me will reflect the session.
    } catch (err: any) {
      console.error(err);
      setStatus(`Social sign in error: ${err.message ?? String(err)}`);
    }
  }

  async function handleSignOut() {
    setStatus("Signing out...");
    try {
      await authClient.signOut();
      setStatus("Signed out");
      setMe(null);
      await refetchSession();
    } catch (err: any) {
      console.error(err);
      setStatus(`Sign out error: ${err.message ?? String(err)}`);
    }
  }

  async function handleCheckMe() {
    setStatus("Calling /api/me...");
    try {
      const res = await fetch("http://localhost:8080/api/me", {
        credentials: "include",
      });

      if (!res.ok) {
        setStatus(`GET /api/me -> ${res.status}`);
        setMe(null);
        return;
      }

      const data = await res.json();
      setMe(data.user);
      setStatus("Fetched /api/me successfully");
    } catch (err: any) {
      console.error(err);
      setStatus(`/api/me error: ${err.message ?? String(err)}`);
    }
  }

  const currentUserEmail =
    session?.user?.email ?? me?.email ?? "(no user in session)";

  return (
    <div
      style={{
        fontFamily: "system-ui, sans-serif",
        maxWidth: 600,
        margin: "2rem auto",
        padding: "1.5rem",
        borderRadius: 12,
        border: "1px solid #444",
      }}
    >
      <h1 style={{ marginBottom: "0.5rem" }}>Better Auth test panel</h1>
      <p style={{ marginTop: 0, marginBottom: "1rem", fontSize: 14 }}>
        Backend: http://localhost:8080 · Auth base path: /api/auth
      </p>

      <section style={{ marginBottom: "1.5rem" }}>
        <h2 style={{ fontSize: 18 }}>Email / password</h2>
        <div
          style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}
        >
          <label>
            Name
            <input
              value={name}
              onChange={(e) => setName(e.target.value)}
              style={{ width: "100%" }}
            />
          </label>

          <label>
            Email
            <input
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              style={{ width: "100%" }}
            />
          </label>

          <label>
            Password
            <input
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              style={{ width: "100%" }}
            />
          </label>

          <div style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}>
            <button onClick={handleSignUp}>Sign up (email)</button>
            <button onClick={handleSignIn}>Sign in (email)</button>
            <button onClick={handleSignOut}>Sign out</button>
          </div>
        </div>
      </section>

      <section style={{ marginBottom: "1.5rem" }}>
        <h2 style={{ fontSize: 18 }}>Social logins</h2>
        <p style={{ fontSize: 13, marginTop: 0 }}>
          Make sure GitHub / Google / Facebook are configured in your{" "}
          <code>auth.ts</code> and provider dashboards.
        </p>
        <div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
          <button onClick={() => handleSocial("github")}>
            Continue with GitHub
          </button>
          <button onClick={() => handleSocial("google")}>
            Continue with Google
          </button>
          <button onClick={() => handleSocial("facebook")}>
            Continue with Facebook
          </button>
        </div>
      </section>

      <section style={{ marginBottom: "1.5rem" }}>
        <h2 style={{ fontSize: 18 }}>Session | /api/me</h2>
        <div style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
          <button onClick={() => refetchSession()}>Refetch useSession()</button>
          <button onClick={handleCheckMe}>Call /api/me</button>
          <button onClick={handleProtectedStudents}>
            Call /api/students/protected
          </button>
        </div>

        <div style={{ fontSize: 13 }}>
          <p>
            useSession status:{" "}
            {isPending
              ? "loading..."
              : error
              ? `error: ${error.message}`
              : "ok"}
          </p>
          <p>Current user (from session or /api/me): {currentUserEmail}</p>

          <details>
            <summary>Raw session data</summary>
            <pre style={{ fontSize: 11 }}>
              {JSON.stringify(session, null, 2)}
            </pre>
          </details>

          <details>
            <summary>Raw /api/me user</summary>
            <pre style={{ fontSize: 11 }}>{JSON.stringify(me, null, 2)}</pre>
          </details>
        </div>
      </section>

      <section>
        <h2 style={{ fontSize: 18 }}>Status</h2>
        <pre
          style={{
            fontSize: 12,
            background: "#111",
            padding: "0.5rem",
            borderRadius: 8,
            minHeight: "2rem",
            color: "#FFF",
            textWrap: "auto",
          }}
        >
          {status || "(idle)"}
        </pre>
      </section>
    </div>
  );
}

export default App;

Save the file and your Vite dev server should hot reload.


6. Test the full flow

Make sure both apps are running:

  • Backend: npm run dev in the backend project, listening on http://localhost:8080.
  • Frontend: npm run dev in the frontend project, listening on http://localhost:5173.

Then:

  1. Open http://localhost:5173 in your browser.

  2. In the email and password section, click "Sign up (email)".

    • You should see status change to "Signing up..." then something like Signed up as ray@example.com.
    • In your browser devtools, Network tab, you should see a POST /api/auth/sign-up/email request.
  3. Click "Refetch useSession()".

    • useSession status should show "ok" and the raw session data should contain a user.
  4. Click "Call /api/me".

    • If /api/me is wired correctly, it should show the user object and session info.
  5. Click "Sign out".

    • Status should change to "Signed out".
    • Refetching the session should result in session being null again.

If you have social providers configured on the backend, try clicking the GitHub or Google buttons. The browser should redirect to the provider, then eventually back to your app. After the redirect, useSession and /api/me should show a logged in user.


7. Call a protected backend route

If you followed the middleware guide and have a protected route, you can add a small test button in App.tsx to hit it.

Add this function inside App:

async function handleProtectedStudents() {
  setStatus("Calling /api/students/protected...");
  try {
    const res = await fetch("http://localhost:8080/api/students", {
      credentials: "include",
    });

    const text = await res.text();
    setStatus(
      `GET /api/students/protected -> ${res.status}, body: ${text}`
    );
  } catch (err: any) {
    console.error(err);
    setStatus(
      `/api/students/protected error: ${err.message ?? String(err)}`
    );
  }
}

Then add a button somewhere, for example in the "Session and /api/me" section:

<button onClick={handleProtectedStudents}>
  Call /api/students/protected
</button>

Now:

  • If you are not logged in, the request should return 401 and the status box will show the error.
  • If you are logged in, it should return 200 and the JSON body.

This proves that the frontend is correctly sending cookies and the backend is enforcing the requireAuth middleware.


8. Summary of the frontend flow

  • The frontend creates a Better Auth client with createAuthClient and points it at the backend.
  • Hooks like useSession and functions like signUp.email and signIn.email internally call the /api/auth/... endpoints.
  • The browser stores the session cookie set by the backend.
  • Normal fetch calls to /api/me or protected routes must include credentials: "include" so the cookie is sent.
  • The backend checks the cookie, finds the session, and returns the user.

With this setup you have a clear, testable path from React to Better Auth to Express to MongoDB. From here you can:

  • Replace the playground UI with your actual sign in and sign up pages.
  • Use the useSession hook to show different navigation for logged in users.
  • Build higher level helpers around your requireAuth middleware so your API remains simple and consistent.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment