DEV Community

Cover image for Starting the Frontend for a Full-Stack E-commerce Store (Auth Store, Axios, and Public vs. Protected Routing)
Chinwuba
Chinwuba

Posted on

Starting the Frontend for a Full-Stack E-commerce Store (Auth Store, Axios, and Public vs. Protected Routing)

With the backend for Task 1 (CodeAlpha's Full Stack Internship) done — Prisma schema, seed data, JWT auth with role-based access control, product routes, cart routes, and transactional order processing — it's time to build the frontend. This post covers the first few pieces: the Zustand auth store, the Axios instance, and the routing decisions that determine which pages are public and which require login.

Why Start With the Auth Store

Before touching a single page component, you need a place to hold the logged-in user's state that survives page refreshes and is accessible from anywhere in the component tree. That's what the Zustand store is for.

Here's the store, after going through a couple of iterations:

import { create } from "zustand";

export const useAuthStore = create((set, get) => ({
  user: JSON.parse(localStorage.getItem("user")) || null,
  token: localStorage.getItem("token") || null,

  login: (user, token) => {
    localStorage.setItem("user", JSON.stringify(user));
    localStorage.setItem("token", token);
    set({ user, token });
  },

  logout: () => {
    localStorage.removeItem("user");
    localStorage.removeItem("token");
    set({ user: null, token: null });
  },
}));
Enter fullscreen mode Exit fullscreen mode

A couple of things worth calling out, because they're easy to get subtly wrong:

1. set() takes one object, not positional arguments.

An earlier draft of this store called set(user, token, role) — three separate arguments. Zustand's set function only accepts a single object (or a function returning one) that gets shallow-merged into state. Passing multiple arguments means everything after the first one is silently ignored. If you're new to Zustand coming from useState, this is a natural mistake to make, since useState's setter takes a single value directly, not an object — so the instinct to just "pass the values" carries over incorrectly.

2. Don't duplicate data that's already nested.

The Task 1 schema adds a role field to User (USER or ADMIN) that Task 3 didn't have. The first instinct might be to store it as its own top-level field in the store, separate from user:

// Don't do this
role: JSON.parse(localStorage.getItem("role")) || null,
Enter fullscreen mode Exit fullscreen mode

But role already lives inside user.role. Storing it twice means you now have two sources of truth for the same value. If you ever update the user object elsewhere (say, a future profile edit) and forget to sync the standalone role field, they drift apart, and now some part of your UI is reading stale data. The fix is just not creating the duplicate in the first place — read user.role wherever you need it, and keep the store lean.

The corrected version above stores user and token only. user.role is accessed via useAuthStore((state) => state.user.role) anywhere it's needed.

The Axios Instance

Same pattern from Task 3, carried over with just a port change:

import axios from "axios";

const API = axios.create({
  baseURL: "http://localhost:3000/api",
});

API.interceptors.request.use((req) => {
  const token = localStorage.getItem("token");

  if (token) {
    req.headers.Authorization = `Bearer ${token}`;
  }

  return req;
});

export default API;
Enter fullscreen mode Exit fullscreen mode

The interceptor runs before every outgoing request, checks localStorage for a token, and attaches it as a Bearer header if present. The conditional check matters — if you skip it and just do req.headers.Authorization = \Bearer ${token}`unconditionally, you'll send the literal string"Bearer null"or"Bearer undefined"` on every request from a logged-out user, which is harmless in this case since there's no route guard on the backend for those requests, but it's sloppy and will bite you the moment you add a route where a malformed header causes a different error path than a missing one.

One gotcha here worth flagging explicitly: the backend port has to match. If your Task 1 server's .env still has PORT=5000 (the default from the setup doc) but your Axios baseURL points at 3000, every request fails with a connection error that has nothing to do with your code — it just looks like your fetch calls are broken. Always confirm the .env and the Axios baseURL agree before debugging anything else.

The Routing Decision: What's Public vs. Protected

This is the part that's genuinely different from Task 3, and worth thinking through rather than copy-pasting.

In Task 3 (the project management tool), everything behind /dashboard required a login — that's expected for a private collaboration tool. But an e-commerce store is different. Real stores let you browse without an account and only ask you to log in when you're actually trying to buy something. Gating the whole app behind auth here would just be bad UX and doesn't match how e-commerce actually works.

So the routing split is:

  • Public, no guard: /login, /register, /products, /products/:id
  • Protected: /cart, /orders

The ProtectedRoute wrapper itself is unchanged from Task 3's pattern:

`javascript
const ProtectedRoute = ({ children }) => {
const token = useAuthStore((state) => state.token);

if (!token) {
return ;
}
return children;
};
`

One subtle bug that came up while wiring this: the wrapper initially pulled token from useCartStore instead of useAuthStore. Since cartStore doesn't have a token field at all, state.token silently evaluated to undefined every time — which meant !token was always true, and /cart and /orders redirected to /login even for a logged-in user. No error was thrown; it just quietly did the wrong thing. This is a good reminder that when a protected route "isn't working," check which store you're actually reading from before assuming the logic itself is wrong — a wrong import can produce exactly the same symptom as a logic bug, but the fix is completely different.

`jsx
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/" element={<Products />} />
<Route path="/products" element={<Products />} />
<Route path="/products/:id" element={<ProductDetail />} />
<Route
path="/cart"
element={
<ProtectedRoute>
<Cart />
</ProtectedRoute>
}
/>
<Route
path="/orders"
element={
<ProtectedRoute>
<Orders />
</ProtectedRoute>
}
/>
</Routes>
`

The Reverse Guard: Redirecting Away From /login

ProtectedRoute solves "you need a token to see this page." But there's a mirror-image problem: a logged-in user shouldn't be able to navigate to /login or /register and see the form again. That doesn't belong in a route wrapper, though — it's page-specific logic, so it lives inside Login.jsx and Register.jsx themselves. On mount, each page checks useAuthStore((state) => state.token), and if a token exists, redirects to /products immediately instead of rendering the form.

The "Add to Cart" Consequence of Public Browsing

Making /products public has a knock-on effect worth planning for before writing ProductCard.jsx: a logged-out visitor can now reach a product page and click "Add to Cart" with no token in the store at all. There are two ways to handle that:

  1. Let the click fire the API call anyway. It fails with a 401 from the backend's protect middleware, and you catch that in a .catch() block and redirect to /login.
  2. Check useAuthStore((state) => state.token) inside the component before firing the request, and redirect to /login immediately if there's no token.

Option 2 is the better call. There's no wasted network round-trip, and you don't have to inspect an error response just to figure out why the request failed — you already know, because you checked first. It also means the "why can't I add this to my cart" experience for a logged-out user is instant instead of waiting on a failed request.

What's Next

With the auth store, Axios instance, and routing structure in place, the next steps are Login.jsx and Register.jsx — controlled forms that call the auth endpoints, store the result via authStore.login(), and handle the already-logged-in redirect. After that: the product listing page with debounced search, product detail with quantity selection, the cart interface, and order history.

Top comments (0)