DEV Community

Cover image for Day 47 of #100DayOfCode — Creating Frontend of Auth System
M Saad Ahmad
M Saad Ahmad

Posted on

Day 47 of #100DayOfCode — Creating Frontend of Auth System

For day 47, the goal was to create the frontend UI of the Authentication System using (TSX) and TailwindCSS, and connect it to its backend.

Link to creating the Auth System Backend on Day 40


TL;DR

A walkthrough of building a React TypeScript frontend for a Node.js auth system, covering why interfaces come before components, how 8 states in a single App component drive the entire UI without a router or context, and how three sequential Axios calls to POST /auth/register, POST /auth/login, and GET /users/:id connect the frontend to a JWT-protected backend.


Table of Contents

  1. Why React with TypeScript instead of JavaScript
  2. Why TypeScript Interfaces Before the Components?
  3. The Register Component
  4. The Login Component
  5. The Dashboard Component
  6. The App Component and Its States
  7. The Handler Functions and API Calls
  8. Putting It All Together
  9. Final Result

Enabling Backend ↔ Frontend Communication With CORS

To make the frontend talk with the backend, we need to install cors and mention it in the server.ts

First, install cors:

npm install cors
Enter fullscreen mode Exit fullscreen mode

Then, mention it in the server.ts:

// app.js
import cors from "cors";
app.use(cors()); // add this before your routes
Enter fullscreen mode Exit fullscreen mode

This ensures the browser doesn’t block API requests.


Why React with TypeScript instead of JavaScript

Using TypeScript with React was a better choice for this project because when your frontend is tightly coupled to a backend API, you need to know exactly what shape the data coming from that API looks like. Defining interfaces like User, Form, and Status means TypeScript catches mismatches between what the API returns and what the components expect at compile time rather than silently breaking the UI at runtime. On top of that, with props flowing from App down into three separate components, TypeScript ensures you never pass the wrong value or forget a required prop — mistakes that JavaScript would only surface as bugs in the browser.


Why TypeScript Interfaces Before the Components?

Before writing a single component, I defined four sets of interfaces at the top of the file. Here's why that matters.

Data Interfaces

interface Form {
  name: string;
  email: string;
  password: string;
}

interface User {
  id: number;
  name: string;
  email: string;
}

interface Status {
  message: string;
}
Enter fullscreen mode Exit fullscreen mode

These three interfaces describe the shape of the data flowing through the app.

  • Form represents what the user types into input fields. It is shared across Register and Login. Both forms use the same state object in App, so having one interface for both keeps things consistent.
  • User represents what comes back from the backend API when you fetch a user. The backend returns an object with id, name, and email. This interface mirrors that exactly. If the backend ever adds a field, you update it here, and TypeScript immediately tells you every place that needs updating.
  • Status is the simplest one — it just holds a message string used to show success or error feedback in the UI.

Prop Interfaces

interface RegisterProps {
  form: Form;
  setForm: (form: Form) => void;
  onRegister: () => void;
  loading: boolean;
  status: Status | null;
  setCurrentView: (view: string) => void;
}
Enter fullscreen mode Exit fullscreen mode

This is where TypeScript pays off the most. Every component in this app receives data from App via props. Without prop interfaces, you could pass the wrong type and only find out at runtime. With prop interfaces, TypeScript will tell you at compile time if you forget to pass a prop, pass the wrong type, or misname something.

The LoginProps and DashboardProps interfaces follow the same pattern; each one declares exactly what that component expects to receive, nothing more, nothing less.


The Register Component

const Register = ({ form, setForm, onRegister, loading, status, setCurrentView }: RegisterProps) => {
Enter fullscreen mode Exit fullscreen mode

Register is a purely presentational component; it does not fetch any data or manage any state of its own. It receives everything it needs from App via props and just renders a form.

What it receives:

  • form — the current values of the input fields (name, email, password)
  • setForm — the function to update those values as the user types
  • onRegister — the function to call when the Register button is clicked (this is handleRegister from App)
  • loading — a boolean that disables the button and shows "Registering..." while the API call is in progress
  • status — the success or error message to display after the API responds
  • setCurrentView — used by the "Already have an account? Login" link to switch to the Login view

What it does to the UI:
When the user fills in the fields and clicks Register, it calls onRegister, which triggers an API call in App. While waiting, loading becomes true and the button text changes to "Registering..." and becomes disabled — preventing double submissions. Once the API responds, status gets populated, and a message appears below the button.


The Login Component

const Login = ({ form, setForm, onLogin, loading, status, setCurrentView }: LoginProps) => {
Enter fullscreen mode Exit fullscreen mode

Login is structurally identical to Register, but with one important difference — it only has email and password fields. There is no name field because the login endpoint (POST /api/auth/login) only accepts email and password. Sending the name to it would be unnecessary.

What it receives:

  • form — same shared form state from App
  • setForm — updates the form state as the user types
  • onLogin — calls handleLogin from App when the Login button is clicked
  • loading — disables the button and shows "Logging in..." during the API call
  • status — shows the error message if login fails (wrong password, user not found, etc.)
  • setCurrentView — used by the "Don't have an account? Register" link

What it does to the UI:
On a successful login, the user never sees a status message; they are immediately redirected to the Dashboard because handleLogin calls setCurrentView("dashboard") on success. The status message only appears when something goes wrong, like an incorrect password or a network error.


The Dashboard Component

const Dashboard = ({ users, loggedInUser, selectedUser, onSelectUser, onLogout }: DashboardProps) => {
Enter fullscreen mode Exit fullscreen mode

Dashboard is the most complex component visually, but like Register and Login, it does not manage any state or make any API calls itself. Everything is passed down from App.

What it receives:

  • users — the array of all users fetched from GET /api/users right after login
  • loggedInUser — the currently authenticated user object, used to show "Welcome back, [name]"
  • selectedUser — a single user object fetched from GET /api/users/:id when the View button is clicked
  • onSelectUser — calls handleSelectUser in App with the clicked user's ID
  • onLogout — calls handleLogout in App to clear all state and return to Login

What it renders:
It shows two panels side by side. The left panel is the users list — every user from the API is rendered as a row with a View button. The right panel only appears when selectedUser is not null — it shows the details of whichever user was last clicked. This is controlled purely by the state in App, not by any local state in Dashboard.


The App Component and Its States

App is the brain of the entire frontend. Every piece of data and every handler function lives here and gets passed down to the components.

const [currentView, setCurrentView] = useState<string>("login");
Enter fullscreen mode Exit fullscreen mode

This is the single state that controls which component is visible. It starts as "login", so the user sees the Login form first. It switches to "register" or "dashboard" based on user actions and API responses. This replaces the need for a routing library entirely.

const [token, setToken] = useState<string | null>(null);
Enter fullscreen mode Exit fullscreen mode

This holds the JWT token returned by the login API. When it is null, the user is unauthenticated. When it holds a string, the user is logged in. It is also used as a guard; the Dashboard only renders when token is not null, so even if currentView is somehow set to "dashboard", nothing shows without a token.

const [loggedInUser, setLoggedInUser] = useState<User | null>(null);
Enter fullscreen mode Exit fullscreen mode

This holds the user object returned by the login API. It is used in the Dashboard to show "Welcome back, [name]". It is separate from token because the token is just a string used for auth headers, while this holds the actual user data for display.

const [users, setUsers] = useState<User[]>([]);
Enter fullscreen mode Exit fullscreen mode

This holds the array of all users fetched immediately after login. It starts as an empty array and gets populated by the GET /api/users call inside handleLogin. It feeds the users' list in the Dashboard.

const [selectedUser, setSelectedUser] = useState<User | null>(null);
Enter fullscreen mode Exit fullscreen mode

This holds the single user object fetched when someone clicks View in the Dashboard. It starts as null — the detail panel is hidden. Once a user is clicked, it gets populated with that user's data, and the detail panel appears.

const [form, setForm] = useState<Form>({ name: "", email: "", password: "" });
Enter fullscreen mode Exit fullscreen mode

This is the single form state shared between Register and Login. Both components read from and write to this same object. When a view switch happens or a submission succeeds, the form is reset to empty strings to clear the input fields.

const [loading, setLoading] = useState<boolean>(false);
Enter fullscreen mode Exit fullscreen mode

This becomes true at the start of any API call and false when it finishes, whether it succeeded or failed. It is passed to Register and Login to disable their buttons during the call, preventing the user from submitting twice.

const [status, setStatus] = useState<Status | null>(null);
Enter fullscreen mode Exit fullscreen mode

This holds the last feedback message — either a success message like "Registered! Please login." or an error message from the API like "Email already in use." It is set to null at the start of every API call to clear any previous message before showing a new one.


The Handler Functions and API Calls

handleRegister — POST /api/auth/register

const handleRegister = async () => {
  setLoading(true);
  setStatus(null);
  try {
    await axios.post(`${API_URL}/auth/register`, {
      name: form.name,
      email: form.email,
      password: form.password,
    });
    setStatus({ message: "Registered! Please login." });
    setForm({ name: "", email: "", password: "" });
    setCurrentView("login");
  } catch (error) {
    const err = error as AxiosError<{ message: string }>;
    setStatus({ message: err.response?.data?.message || "Registration failed." });
  }
  setLoading(false);
};
Enter fullscreen mode Exit fullscreen mode

What it does:
This function sends the user's name, email, and password to POST /api/auth/register. The backend validates the data, hashes the password, saves the user to MongoDB, and returns the created user object.

Why await without destructuring:
Unlike the login call, we do not need anything from the response body here. We just need to know it succeeded. So we await the promise but do not capture the return value.

Effect on the frontend:
On success, the form clears, a success message appears, and the view switches to Login. The user is not auto-logged in; they must log in manually after registering. On failure, the error message from the backend (like "Email already in use") is shown in the status paragraph below the button.


handleLogin — POST /api/auth/login + GET /api/users

const handleLogin = async () => {
  setLoading(true);
  setStatus(null);
  try {
    const { data } = await axios.post(`${API_URL}/auth/login`, {
      email: form.email,
      password: form.password,
    });
    setToken(data.token);
    setLoggedInUser(data.user);
    setForm({ name: "", email: "", password: "" });
    const { data: usersData } = await axios.get(`${API_URL}/users`, {
      headers: { Authorization: `Bearer ${data.token}` },
    });
    setUsers(usersData);
    setCurrentView("dashboard");
  } catch (error) {
    const err = error as AxiosError<{ message: string }>;
    setStatus({ message: err.response?.data?.message || "Login failed." });
  }
  setLoading(false);
};
Enter fullscreen mode Exit fullscreen mode

What it does:
This function makes two API calls sequentially — first a POST to authenticate, then a GET to load the dashboard data.

First call — POST /api/auth/login:
Sends email and password. The backend verifies the password against the hashed version in MongoDB, and if correct, generates and returns a JWT token along with the user object. The token is stored in the token state and the user object in loggedInUser.

Second call — GET /api/users:
This is a protected route — the backend's auth middleware checks the Authorization header before allowing access. So we pass Bearer ${data.token} in the header. Note we use data.token here (from the login response), not the token state — because React state updates are asynchronous and token state may not have updated yet by the time this second call fires.

Why two calls in one handler:
The dashboard needs the users' list to be ready the moment it appears. If we switched to the dashboard first and fetched users inside a useEffect there, the user would see a blank list for a moment. Fetching before switching views means the data is ready before the dashboard renders.

Effect on the frontend:
On success, the token and user are stored, the form clears, users are loaded, and the view switches to Dashboard. On failure, the error from the backend appears (wrong password, user not found, etc.), and the view stays on Login.


handleSelectUser — GET /api/users/:id

const handleSelectUser = async (id: number) => {
  try {
    const { data } = await axios.get(`${API_URL}/users/${id}`, {
      headers: { Authorization: `Bearer ${token}` },
    });
    setSelectedUser(data);
  } catch {
    console.log("Failed to fetch user");
  }
};
Enter fullscreen mode Exit fullscreen mode

What it does:
When the user clicks View on any row in the users list, this function fires with that user's id. It calls GET /api/users/:id — another protected route — with the stored token in the header.

Why fetch by ID instead of using the list data:
The users' list only contains basic info (name, email). A detailed view would typically show more fields. By fetching the individual user, you get the full document from the backend, which may contain additional fields not included in the list response.

Effect on the frontend:
On success, selectedUser is populated with the returned user object, and the detail panel appears on the right side of the Dashboard. On failure, a message is logged to the console (this could be upgraded to show a UI error in a production app).


Putting It All Together

The entire frontend flow looks like this:

App loads → currentView = "login" → Login component renders
     ↓
User submits login form → handleLogin fires.
     ↓
POST /api/auth/login → token + user received
     ↓
GET /api/users → users list received
     ↓
currentView = "dashboard" → Dashboard renders with data ready
     ↓
User clicks View → handleSelectUser fires.
     ↓
GET /api/users/:id → single user received → detail panel appears
     ↓
User clicks Logout → all state cleared → currentView = "login"
Enter fullscreen mode Exit fullscreen mode

No routing library, no global state management, no context. Just one currentView state and props flowing downward — which is exactly the right level of complexity for a project at this scale.


Final Result:

Register

Register


Login

Login


Dashboard (After successfully logging in)

Dashboard


Wrap Up

This session was all about understanding how authentication works end-to-end, from a user typing their credentials into a form, to a JWT being generated on the backend, to MongoDB storing the user, to the frontend receiving the token and using it to access protected routes. Putting the frontend and backend together and seeing them actually communicate made it feel like a proper full-stack app, even if it is a simple one.

Thanks for reading. Feel free to share your thoughts!

Top comments (0)