MERN Stack Authentication: Building a Secure System from Scratch (Part 3)
Designing a frontend for the auth system using Redux Toolkit, RTK Query, Material UI, and React Router
Introduction
Welcome back to part 3 of our engaging series on creating an authentication system using the MERN stack. So far, we've built a backend that lets users log in, log out, and register, as well as implemented refreshing access tokens when they expire. Additionally, we've set up role-based authorization and ensured that only authenticated users can access the data. π
If you haven't checked out the previous parts of this series yet, I highly recommend giving it a read for a clearer understanding of what we're working on here.
Now, let's have some fun and move on to the frontend to make our application truly fullstack! In this part, we'll explore setting up the React application with Vite, using Redux to manage authenticated user data on the frontend, and sending requests to our backend with Redux Toolkit Query. And to top it all off, we'll style our application with Material UI. So, let's dive right in. π
Initializing the React App
To set up our React app with TypeScript, we'll use Vite, a super quick frontend tool. To make the app, run this command in the project's main folder:
npm create vite@latest
Follow the instructions and don't forget to select that you want to create a project with TypeScript. However, if you're unfamiliar with TypeScript, feel free to use a JavaScript project, as it doesn't make a significant difference. It's recommended to name the project frontend
to avoid confusion.
Next, we need to install Material UI to use it as the UI foundation in our application. It offers a wide range of ready-to-use UI components, which significantly speeds up the process of managing the styling for applications like this.
npm install @mui/material @emotion/react @emotion/styled
Additionally, we will use the MUI icon set, so be sure to install that as well:
npm install @mui/icons-material
Alright, we're all set to kick off our frontend journey! π
Building Register and Login Pages
Let's finally write some real frontend code! We'll begin by developing pages that allow users to log in and/or register. To do this, we'll create a folder called pages
and two files inside: Login.tsx
and Register.tsx
Register page
First, let's examine the page for registration. Although I've modified some parts of the code, I must give credit to Material UI for their collection of templates, as I used their template for the Login/Register page. Since this project is backend-focused, I find it unnecessary to overwhelm you with a ton of styling and prefer to use pre-defined components. That's the main reason I opted for Material-UI π
If you want to check out the templates provided by MUI yourself, refer to this page.
Moreover, I should note that I won't delve deeply into Material UI, discussing every single component I used throughout the application development.
The reason is that I don't see much value in doing so. π€·ββοΈ Don't be discouraged if you're unfamiliar with MUI, as all the components are quite intuitive. Simply open the documentation alongside the blog, and search to read about any component that raises questions for you. And if you still have questions, don't hesitate to ask me in the comments section!
Now, here is the code for the registration page:
// pages/Register.tsx
import { useState } from "react";
const Register = () => {
const [formData, setFormData] = useState<IForm>({
email: "",
password: "",
firstName: "",
lastName: ""
});
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
// Yet to handle...
};
return (
<Container component="main" maxWidth="xs">
<Box
sx={{
marginTop: 8,
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
<LockOutlinedIcon />
</Avatar>
<Typography component="h1" variant="h5">
Sign up
</Typography>
<Box
component="form"
noValidate
onSubmit={handleSubmit}
sx={{ mt: 3 }}
>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<TextField
autoComplete="given-name"
name="firstName"
required
fullWidth
id="firstName"
label="First Name"
autoFocus
value={formData.firstName}
onChange={(e) => setFormData(prevData => ({...prevData, [e.target.name]: e.target.value}))}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
required
fullWidth
id="lastName"
label="Last Name"
name="lastName"
autoComplete="family-name"
value={formData.lastName}
onChange={(e) => setFormData(prevData => ({...prevData, [e.target.name]: e.target.value}))}
/>
</Grid>
<Grid item xs={12}>
<TextField
required
fullWidth
label="Email"
name="email"
autoComplete="email"
value={formData.email}
onChange={(e) => setFormData(prevData => ({...prevData, [e.target.name]: e.target.value}))}
/>
</Grid>
<Grid item xs={12}>
<TextField
required
fullWidth
name="password"
label="Password"
type="password"
id="password"
autoComplete="new-password"
value={formData.password}
onChange={(e) => setFormData(prevData => ({...prevData, [e.target.name]: e.target.value}))}
/>
</Grid>
</Grid>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
>
Sign Up
</Button>
<Grid container justifyContent="flex-end">
<Grid item>
<Link href="/login">
Already have an account? Sign in
</Link>
</Grid>
</Grid>
</Box>
</Box>
</Container>
);
};
export default Register;
Here, we are creating a simple form and managing input values using controlled input. Afterward, we handle the form submission within the handleSubmit
function. However, as you may have noticed, we haven't done anything with the data obtained within the function. This is because we haven't set up any tools to make requests. Later on, we will send the entered data to the backend, where it will be processed by the appropriate controller, which will then return access and refresh tokens.
Note that I have also created a separate file outside of the "src" directory called types.d.ts
to store the custom-defined types that will be used throughout the application. This file currently contains the type definition for the IForm
type, which you have just encountered on the registration page:
// types.d.ts
interface IForm {
email?: string;
password?: string;
firstName?: string;
lastName?: string;
}
Don't forget to include the types.d.ts
file in tsconfig.json
:
// tsconfig.json
"include": ["src", "types.d.ts"]
Login page
Now, let's look at the login page:
// pages/Login.tsx
import { useState } from "react";
const Login = () => {
const [formData, setFormData] = useState<IForm>({
email: "",
password: ""
});
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
// Yet to handle...
};
return (
<Container component="main" maxWidth="xs">
<Box
sx={{
marginTop: 8,
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
<LockOutlinedIcon />
</Avatar>
<Typography component="h1" variant="h5">
Login
</Typography>
<Box
component="form"
onSubmit={handleSubmit}
noValidate
sx={{ mt: 1 }}
>
<TextField
margin="normal"
required
fullWidth
label="Email"
name="email"
autoComplete="email"
autoFocus
value={formData.email}
onChange={(e) => setFormData(prevData => ({...prevData, [e.target.name]: e.target.value}))}
/>
<TextField
margin="normal"
required
fullWidth
name="password"
label="Password"
type="password"
autoComplete="current-password"
value={formData.password}
onChange={(e) => setFormData(prevData => ({...prevData, [e.target.name]: e.target.value}))}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
>
Login
</Button>
<Grid container>
<Grid item xs>
<Link href="/forgot-password">
Forgot password?
</Link>
</Grid>
<Grid item>
<Link href="/register">
Don't have an account? Sign Up
</Link>
</Grid>
</Grid>
</Box>
</Box>
</Container>
);
};
export default Login;
The login page appears quite similar to the registration page, with the primary difference being the number of input values required, which are only email and password. Apart from that, both components essentially represent slightly modified versions of the MUI template.
Designing the Logout page
Now, I want to quickly create a page to which the user will be redirected upon logging out. This small page will display a message indicating the successful completion of the logout process.
// pages/Logout.tsx
const Logout = () => {
return (
<Container maxWidth="sm">
<Paper elevation={3} sx={{ padding: "20px", marginTop: "50px", textAlign: "center" }}>
<Typography variant="h5" gutterBottom>
You've successfully logged out
</Typography>
<Link href="/login">Back to Login page</Link>
</Paper>
</Container>
);
};
export default Logout;
I hope there are no further questions regarding this page, as everything is quite straightforward. Now, we have encountered two issues that need to be addressed:
We currently have no way to access these pages, meaning no routing has been set up
We are not using any data fetching tools to make queries and mutations to our backend.
Let's begin with the first issue π₯
Integrating React Router
For routing, we're going to use the popular library, React Router, which enables client-side routing. You can read their docs to learn more, but I'll guide you through the process of basic setup.
To install the library, simply run the command:
npm install react-router-dom
After that, within main.tsx
, we need to wrap our App.tsx
component with the BrowserRouter
:
// main.tsx
import App from "./App"
import { BrowserRouter } from "react-router-dom"
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
)
Now, we can easily use the React Router! We will configure all the routing within the App.tsx
component:
// App.tsx
import { Route, Routes } from "react-router-dom"
import Login from "./pages/Login"
import Register from "./pages/Register"
const App = () => {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/logout" element={<Logout />} />
</Routes>
)
}
Awesome! Now, just type in the path according to our routes, and you'll see our pages displayed π
Configuring Redux Store
Next up, we're going to handle sending requests to the server. We'll be using Redux Toolkit for this, as it provides not only global storage but also a handy tool for fetching data called Redux Toolkit (RTK) Query. Let's get started on setting it up!
Oh, and just in case you're new to Redux Toolkit, I recently wrote a fun blog post where I built a movie application using Redux Toolkit. It's a great way to learn all about how Redux works and how to use it. So, feel free to give it a read before diving in here π
Alright, first things first, let's install Redux and Redux Toolkit with this command:
npm install @reduxjs/toolkit react-redux
Next, we need to initialize our Redux store, where we will store the access token.
Inside the src
directory, create a folder called app
and a file named store.ts
. I assume you are familiar with setting up basic Redux storage.
// app/store.ts
import { configureStore } from "@reduxjs/toolkit";
export const store = configureStore({
reducer: {
}
});
// Infer the type for state and dispatch from the store itself
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
Next, we must wrap our application with the Provider
component, which allows the Redux store to be accessible to any nested components requiring access to the store.
// main.tsx
import { Provider } from "react-redux";
import { store } from "./app/store";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<BrowserRouter>
<Provider store={store} >
<App />
</Provider>
</BrowserRouter>
</React.StrictMode>
);
Before handling the logic of sending requests, we need to create a slice to store details about the user and their access token. Currently, we have no reducers, so let's create some by setting up the folder features/auth
with authSlice.ts
inside:
// features/auth/authSlice.ts
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
import { RootState } from "../../app/store";
// State type
interface IState {
user: IUser | null;
accessToken: string;
}
// Initial state values
const initialState: IState = {
user: null,
accessToken: ""
};
export const authSlice = createSlice({
name: "auth",
initialState,
reducers: {
setCredentials: (state, action: PayloadAction<IUserStore>) => {
const { user, accessToken } = action.payload;
state.user = user;
state.accessToken = accessToken;
},
logout: (state) => {
state.user = null;
state.accessToken = "";
}
}
});
export const selectUser = (state: RootState) => state.auth.user;
export const selectToken = (state: RootState) => state.auth.accessToken;
export const { setCredentials, logout } = authSlice.actions;
export default authSlice.reducer;
As you can see, I am creating the slice using createSlice()
, where we pass the slice name, initial state, and an object of reducer functions. We will have two reducer functions:
The
setCredentials
reducer handles the login logic for the user. When a user logs in, their access token and information are stored in the Redux storage.The
logout
reducer is used when a user logs out. It simply clears the user's information from the storage.
Moreover, I am once again adding a new type IUser
to the types.d.ts
file, which represents the data type associated with a user:
// types.d.ts
interface IUser {
_id: string;
email: string;
password: string;
firstName: string;
lastName: string;
refreshToken: string;
}
Now, letβs add the newly developed slice to the store:
// app/store.ts
import { configureStore } from "@reduxjs/toolkit";
import authReducer from "../features/auth/authSlice";
export const store = configureStore({
reducer: {
auth: authReducer
}
});
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
Awesome job setting up the slice in Redux! π Next, we'll dive into configuring the queries and mutations with RTK Query. It might be a bit long, so let's save that for the next part since we've already covered a lot today. Keep up the good work!
Wrapping Up
In this part of the series, we laid the foundation for our frontend, which we will continue to develop in future parts of this series. We created a React application with Vite, implemented routing using React Router, and employed Material UI for styling. Additionally, we began integrating Redux Toolkit for managing the state.
In the upcoming parts, we will dive into RTK Query to handle API requests, create queries and mutations, and further enhance the frontend user experience. You'll learn even more about working with Redux, creating toastified messages, and styling with Material UI! So, stay tuned! It's going to be a fun ride π€
As usual, if you encounter any difficulties along the way, you can refer to the complete source code available on my GitHub repository. If you have any questions or need assistance, don't hesitate to reach out to me. Feel free to contact me, even if it's not related to this project.
If you enjoyed this article, remember to follow me on Twitter, where I share daily updates. And feel free to connect with me on LinkedIn.