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

Aug 17, 2023Β·

11 min read

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:

  1. 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.

  2. 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.

Β