Skip to content

Instantly share code, notes, and snippets.

@ReaganS94
Last active October 30, 2025 15:03
Show Gist options
  • Select an option

  • Save ReaganS94/08d0734ffea6ac91988263753c2ab478 to your computer and use it in GitHub Desktop.

Select an option

Save ReaganS94/08d0734ffea6ac91988263753c2ab478 to your computer and use it in GitHub Desktop.

Frontend auth integration guide

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.

Step 1. Add the necessary .env vars

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:8080

Notes:

  • 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`;

Step 2. Create global auth state with AuthContext

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/me once on mount to hydrate user from cookies
  • exposes setUser so 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/me with 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.

Step 3. Wrap your RootLayout with the AuthProvider

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().

Step 4. Make the Navbar dynamic

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.

Step 5. Add route guards (RequireAuth and RequireGuest)

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 /login or /register anymore. 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, RequireGuest immediately redirects you out. No more "I can still access register".
  • If you're logged out and try /create, RequireAuth pushes you to /login.
  • The replace prop on <Navigate /> prevents the guarded page from staying in browser history. That kills "Back → flash → redirect → back" loops.

Step 6. Update Register.tsx to set user in context on success

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.user from 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;

Step 7. Add Login.tsx logic

Now we make the Login page actually call the auth server, store the user in context, and navigate away.

What login needs to do:

  1. POST to /auth/login with email + password.
  2. Include credentials: "include" so the browser accepts the cookies (accessToken, refreshToken).
  3. If successful, read data.user from the response and call setUser(data.user).
  4. toast.success(...), then navigate("/").

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 user state.
  • Navbar re-renders without Login/Register.
  • You navigate to / already "logged in".
  • If you hit Back, the /register route is guarded by <RequireGuest>, so you get bounced away.

Final recap / mental model

  1. Add AUTH URL to .env so frontend knows how to reach the auth server.

  2. Create AuthContext that:

    • loads /auth/me on mount
    • stores user + logout()
    • exposes setUser for after register/login
  3. Wrap <AuthProvider> around your layout so state is global.

  4. Make <Navbar> read user, loading, and logout, and render different links.

  5. Add <RequireAuth> and <RequireGuest> route guards so you can't visit pages you shouldn't.

  6. Update Register and Login to:

    • send request with credentials: "include"
    • call setUser(data.user) on success
    • navigate to /

Afflicted files:

  1. AuthContext (global auth state)
  2. RootLayout (wraps children in AuthProvider)
  3. Navbar (reads user + logout)
  4. routeGuards.tsx (RequireAuth / RequireGuest)
  5. App.tsx (applies those guards on routes)
  6. Register.tsx (posts to /auth/register, sets user, navigates)
  7. 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment