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/meto check who is logged in. - Call a protected backend route that uses the
requireAuthmiddleware.
The goal is to have a simple playground panel where you can click buttons and see everything work.
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/authusingtoNodeHandler(auth). - CORS configured to allow
http://localhost:5173with credentials. trustedOriginsinauth.tscontainshttp://localhost:5173./api/meroute that usesauth.api.getSessionand returns{ user, session }.- Optionally, a protected route (for example
GET /api/students) that usesrequireAuth.
Make sure the backend is running and you can hit for example:
GET http://localhost:8080/api/auth/okin the browser or Postman.GET http://localhost:8080/api/mein Postman (it should return{ user: null }when not logged in).
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 installStart the dev server to verify it works:
npm run devYou should see the default Vite React starter on http://localhost:5173.
From inside the frontend project folder (better-auth-client):
npm install better-authYou 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.
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:
baseURLis the root of your backend. Better Auth will automatically use/api/authunder this.credentials: "include"tells fetch to send and receive cookies. This is required for the session cookie to work across origins.
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/meand 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.
Make sure both apps are running:
- Backend:
npm run devin the backend project, listening onhttp://localhost:8080. - Frontend:
npm run devin the frontend project, listening onhttp://localhost:5173.
Then:
-
Open
http://localhost:5173in your browser. -
In the email and password section, click "Sign up (email)".
- You should see
statuschange to "Signing up..." then something likeSigned up as ray@example.com. - In your browser devtools, Network tab, you should see a
POST /api/auth/sign-up/emailrequest.
- You should see
-
Click "Refetch useSession()".
useSession statusshould show "ok" and the raw session data should contain a user.
-
Click "Call /api/me".
- If
/api/meis wired correctly, it should show the user object and session info.
- If
-
Click "Sign out".
- Status should change to "Signed out".
- Refetching the session should result in
sessionbeing 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.
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.
- The frontend creates a Better Auth client with
createAuthClientand points it at the backend. - Hooks like
useSessionand functions likesignUp.emailandsignIn.emailinternally call the/api/auth/...endpoints. - The browser stores the session cookie set by the backend.
- Normal
fetchcalls to/api/meor protected routes must includecredentials: "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
useSessionhook to show different navigation for logged in users. - Build higher level helpers around your
requireAuthmiddleware so your API remains simple and consistent.