Goal: make the frontend react to login/register/logout in real time.
After doing this, you get:
- Navbar updates immediately after register or login
- Logout works and updates UI
- /register and /login are blocked when you're already logged in
- /create is blocked if you're not logged in
- App knows who the current user is (via /auth/me and cookies)
We'll do this in steps.
In the frontend project root, if you followed the setup from the repo, you should already have a .env.development.local file. All we have to do is to add the var that we'll use for auth. Make sure it's the same port where you're running the auth server. In my case it's 8080:
VITE_APP_TRAVEL_JOURNAL_AUTH_URL=http://localhost:8080Notes:
- This should match where your auth server is running.
- The auth server exposes routes like
/auth/register,/auth/login,/auth/me, etc. - Vite injects this at build time into
import.meta.env.
In your code you'll read it like this:
const AUTH_URL = import.meta.env
.VITE_APP_TRAVEL_JOURNAL_AUTH_URL as string | undefined;
if (!AUTH_URL) {
throw new Error(
"AUTH URL is required, are you missing VITE_APP_TRAVEL_JOURNAL_AUTH_URL in .env?"
);
}
const baseAuthURL = `${AUTH_URL}/auth`;Right now the frontend has no idea if the user is logged in. We fix that by creating an AuthContext that:
- stores
user(or null) - fetches
/auth/meonce on mount to hydrateuserfrom cookies - exposes
setUserso pages like Register can update the user after a successful signup - exposes
logout()that calls the auth server and clears state
Create src/context/AuthContext.tsx (you may need to create the context folder):
import React, {
createContext,
useContext,
useEffect,
useState,
useCallback,
} from "react";
import type { ReactNode } from "react";
type User = {
id: string;
email: string;
firstName?: string;
lastName?: string;
roles: string[];
};
type AuthContextValue = {
user: User | null;
loading: boolean;
setUser: (user: User | null) => void;
logout: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | null>(null);
const AUTH_URL = import.meta.env
.VITE_APP_TRAVEL_JOURNAL_AUTH_URL as string | undefined;
if (!AUTH_URL) {
throw new Error(
"AUTH URL is required, are you missing VITE_APP_TRAVEL_JOURNAL_AUTH_URL in .env?"
);
}
const baseAuthURL = `${AUTH_URL}/auth`;
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true); // true until we check /auth/me
// hydrate user from cookie on first load
useEffect(() => {
// This effect runs once on mount to fetch the current user.
// We track `alive` so we don't call setState after unmount.
// Why: fetch() and res.json() are async. The component might unmount
// before they finish. If we still call setUser/setLoading after unmount,
// React will warn ("can't update state on an unmounted component").
// Setting `alive = false` in the cleanup lets us bail out safely.
let alive = true;
(async () => {
try {
const res = await fetch(`${baseAuthURL}/me`, {
method: "GET",
credentials: "include", // send cookies
});
if (!res.ok) {
if (alive) setUser(null);
return;
}
const data = await res.json();
if (alive) {
// data looks like: { id, email, firstName, lastName, roles }
setUser(data);
}
} catch {
if (alive) setUser(null);
} finally {
if (alive) setLoading(false);
}
})();
return () => {
alive = false;
};
}, []);
// logout: tell auth server to delete refresh token + clear cookies, then clear local state
const logout = useCallback(async () => {
try {
await fetch(`${baseAuthURL}/logout`, {
method: "DELETE",
credentials: "include",
});
} catch {
// ignore network errors here
} finally {
setUser(null);
}
}, []);
const value: AuthContextValue = {
user,
loading,
setUser,
logout,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth must be used inside <AuthProvider>");
}
return ctx;
}What this does for you:
- On first render of the app, it calls
/auth/mewith cookies. - If cookies are valid, we get the user and store it in state.
- Now the rest of the app can just say
const { user } = useAuth()instead of manually calling/auth/me.
We want every route, every page, and the Navbar to see the auth state. Easiest way is to wrap RootLayout content in <AuthProvider>.
Update src/layouts/RootLayout.tsx:
import { Outlet } from "react-router";
import { ToastContainer } from "react-toastify";
import { Navbar } from "@/components";
import { AuthProvider } from "@/context/AuthContext";
import "react-toastify/dist/ReactToastify.css";
const RootLayout = () => {
return (
<AuthProvider>
<div className="container mx-auto">
<ToastContainer
position="bottom-left"
autoClose={1500}
theme="colored"
/>
<Navbar />
<Outlet />
</div>
</AuthProvider>
);
};
export default RootLayout;Now <Navbar /> and every <Outlet /> page can call useAuth().
We want:
- If logged out: show Register / Login
- If logged in: hide Register / Login, show Create Post, show Logout, maybe show "Hi, {name}".
- While we're still checking (
loading === true), don't flash the wrong state.
Update src/components/UI/Navbar.tsx:
import { Link, NavLink, useNavigate } from "react-router";
import { useAuth } from "@/context/AuthContext";
const Navbar = () => {
const { user, loading, logout } = useAuth();
const navigate = useNavigate();
const handleLogout = async () => {
await logout();
navigate("/");
};
return (
<div className="navbar bg-base-100">
<div className="flex-1">
<Link to="/" className="btn btn-ghost text-xl">
Travel journal
<span role="img" aria-labelledby="airplane">
🛫
</span>
<span role="img" aria-labelledby="heart">
❤️
</span>
</Link>
</div>
<div className="flex-none">
<ul className="menu menu-horizontal px-1">
<li>
<NavLink to="/">Home</NavLink>
</li>
{user && (
<li>
<NavLink to="/create">Create post</NavLink>
</li>
)}
{loading ? null : user ? (
<>
<li className="pointer-events-none">
<span className="opacity-70 cursor-default">
{user.firstName
? `Hi, ${user.firstName}`
: user.email.split("@")[0]}
</span>
</li>
<li>
<button onClick={handleLogout}>Logout</button>
</li>
</>
) : (
<>
<li>
<NavLink to="/register">Register</NavLink>
</li>
<li>
<NavLink to="/login">Login</NavLink>
</li>
</>
)}
</ul>
</div>
</div>
);
};
export default Navbar;Why this matters:
- After you register or log in, you'll set the user in context. Navbar re-renders and instantly hides Register/Login.
- After logout, Navbar immediately flips back.
We want two protection rules:
- If you're NOT logged in, you can't see certain pages (like
/create). - If you ARE logged in, you shouldn't be able to see
/loginor/registeranymore. Pressing back into them should bounce you out.
Create src/routeGuards.tsx:
import type { ReactNode } from "react";
import { Navigate } from "react-router";
import { useAuth } from "@/context/AuthContext";
// Pages that require you to be LOGGED IN (ex: /create)
export function RequireAuth({ children }: { children: ReactNode }) {
const { user, loading } = useAuth();
if (loading) {
return null; // could return a spinner
}
if (!user) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
// Pages that require you to be LOGGED OUT (ex: /login, /register)
export function RequireGuest({ children }: { children: ReactNode }) {
const { user, loading } = useAuth();
if (loading) {
return null;
}
if (user) {
// already logged in, you shouldn't see login/register
return <Navigate to="/" replace />;
}
return <>{children}</>;
}Now update src/App.tsx to apply those guards to the routes:
import { BrowserRouter, Routes, Route } from "react-router";
import { RootLayout } from "@/layouts";
import {
CreatePost,
Home,
Login,
NotFound,
Post,
Register,
} from "@/pages";
import { RequireAuth, RequireGuest } from "./routeGuards";
const App = () => (
<BrowserRouter>
<Routes>
<Route path="/" element={<RootLayout />}>
<Route index element={<Home />} />
<Route
path="login"
element={
<RequireGuest>
<Login />
</RequireGuest>
}
/>
<Route
path="register"
element={
<RequireGuest>
<Register />
</RequireGuest>
}
/>
<Route path="post/:id" element={<Post />} />
<Route
path="create"
element={
<RequireAuth>
<CreatePost />
</RequireAuth>
}
/>
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</BrowserRouter>
);
export default App;What this gives you:
- If you're logged in and you hit Back into
/register,RequireGuestimmediately redirects you out. No more "I can still access register". - If you're logged out and try
/create,RequireAuthpushes you to/login. - The
replaceprop on<Navigate />prevents the guarded page from staying in browser history. That kills "Back → flash → redirect → back" loops.
After successful registration, the auth server:
- creates the user
- sets httpOnly cookies (
accessToken,refreshToken) - responds with
{ message, user: {...} }
We need to:
- send the request with
credentials: "include"so the browser actually stores those cookies - grab
data.userfrom the response - call
setUser(data.user)from AuthContext so Navbar updates instantly, without a full reload - navigate away
Full Register page (without tsx):
import React, { useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import { useAuth } from "@/context/AuthContext";
type RegisterFormState = {
firstName: string;
lastName: string;
email: string;
password: string;
confirmPassword: string;
};
const AUTH_URL = import.meta.env
.VITE_APP_TRAVEL_JOURNAL_AUTH_URL as string | undefined;
if (!AUTH_URL) {
throw new Error(
"AUTH URL is required, are you missing VITE_APP_TRAVEL_JOURNAL_AUTH_URL in .env?"
);
}
const baseAuthURL = `${AUTH_URL}/auth`;
const Register = () => {
const [{ firstName, lastName, email, password, confirmPassword }, setForm] =
useState<RegisterFormState>({
firstName: "",
lastName: "",
email: "",
password: "",
confirmPassword: "",
});
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const { setUser } = useAuth();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((prev) => ({ ...prev, [e.target.name]: e.target.value }));
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
try {
if (!firstName || !lastName || !email || !password || !confirmPassword) {
throw new Error("All fields are required");
}
if (password !== confirmPassword) {
throw new Error("Passwords do not match");
}
setLoading(true);
const res = await fetch(`${baseAuthURL}/register`, {
method: "POST",
credentials: "include", // important for cookies
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
firstName,
lastName,
email,
password,
confirmPassword,
}),
});
if (!res.ok) {
let message = "Registration failed";
try {
const data = await res.json();
if (data?.error) message = data.error;
} catch {
// ignore parse errors
}
throw new Error(message);
}
const data = await res.json();
// data.user: { id, email, firstName, lastName, roles }
if (data?.user) {
setUser({
id: data.user.id,
email: data.user.email,
firstName: data.user.firstName,
lastName: data.user.lastName,
roles: data.user.roles,
});
}
toast.success("Registration successful");
navigate("/");
} catch (err) {
const message =
err instanceof Error ? err.message : "Something went wrong";
toast.error(message);
} finally {
setLoading(false);
}
};
// return stays the same
};
export default Register;Now we make the Login page actually call the auth server, store the user in context, and navigate away.
What login needs to do:
- POST to
/auth/loginwith email + password. - Include
credentials: "include"so the browser accepts the cookies (accessToken,refreshToken). - If successful, read
data.userfrom the response and callsetUser(data.user). toast.success(...), thennavigate("/").
Full src/pages/Login.tsx (JSX for the form stays the same shape — just wire onSubmit={handleSubmit} and onChange={handleChange} like Register):
import { useState } from "react";
import { useNavigate, Link } from "react-router";
import { toast } from "react-toastify";
import { useAuth } from "@/context/AuthContext";
type LoginFormState = {
email: string;
password: string;
};
const AUTH_URL = import.meta.env
.VITE_APP_TRAVEL_JOURNAL_AUTH_URL as string | undefined;
if (!AUTH_URL) {
throw new Error(
"AUTH URL is required, are you missing VITE_APP_TRAVEL_JOURNAL_AUTH_URL in .env?"
);
}
const baseAuthURL = `${AUTH_URL}/auth`;
const Login = () => {
const [{ email, password }, setForm] = useState<LoginFormState>({
email: "",
password: "",
});
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const { setUser } = useAuth();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((prev) => ({ ...prev, [e.target.name]: e.target.value }));
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
try {
if (!email || !password) {
throw new Error("All fields are required");
}
setLoading(true);
const res = await fetch(`${baseAuthURL}/login`, {
method: "POST",
credentials: "include", // IMPORTANT so cookies are set
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
let message = "Login failed";
try {
const data = await res.json();
if (data?.error) message = data.error; // "Incorrect credentials" etc.
} catch {
// ignore parse error and keep fallback message
}
throw new Error(message);
}
const data = await res.json();
// data.user is { id, email, firstName, lastName, roles }
if (data?.user) {
setUser({
id: data.user.id,
email: data.user.email,
firstName: data.user.firstName,
lastName: data.user.lastName,
roles: data.user.roles,
});
}
toast.success("Logged in successfully");
navigate("/");
} catch (err) {
const message =
err instanceof Error ? err.message : "Something went wrong";
toast.error(message);
} finally {
setLoading(false);
}
};
// return stays the same
};
export default Login;Notes:
- We used
credentials: "include"again, same reason as Register. - We call
setUser(...)so Navbar instantly reflects that we’re logged in. - We
navigate("/")so you land on Home. - We show errors from the backend (like "Incorrect credentials").
Because the Login route is already wrapped in <RequireGuest> in App.tsx, if you log in and then hit Back, you won't be allowed back to /login. You’ll get redirected away.
Now:
- Successful register instantly sets the global
userstate. - Navbar re-renders without Login/Register.
- You navigate to
/already "logged in". - If you hit Back, the
/registerroute is guarded by<RequireGuest>, so you get bounced away.
-
Add AUTH URL to
.envso frontend knows how to reach the auth server. -
Create
AuthContextthat:- loads
/auth/meon mount - stores
user+logout() - exposes
setUserfor after register/login
- loads
-
Wrap
<AuthProvider>around your layout so state is global. -
Make
<Navbar>readuser,loading, andlogout, and render different links. -
Add
<RequireAuth>and<RequireGuest>route guards so you can't visit pages you shouldn't. -
Update
RegisterandLoginto:- send request with
credentials: "include" - call
setUser(data.user)on success - navigate to
/
- send request with
AuthContext(global auth state)RootLayout(wraps children inAuthProvider)Navbar(reads user + logout)routeGuards.tsx(RequireAuth / RequireGuest)App.tsx(applies those guards on routes)Register.tsx(posts to /auth/register, sets user, navigates)Login.tsx(posts to /auth/login, sets user, navigates)
After these steps:
- Navbar updates right after login or register.
- Logout clears cookies on the server and clears user in context.
- /login and /register are blocked if you're already logged in.
- /create is blocked if you're not logged in.
- Pressing Back won't show auth pages you're not supposed to see.