Step-by-Step Guide to Creating an Authentication App in React with Typescript and Vite

Table of contents

I'll provide you with a step-by-step guide to creating a User Authentication App with the features you've specified using React, Vite, MUI, Tailwind CSS, and TypeScript. We'll set up the project and implement the required features.

  1. Project Setup

First, let's create a new Vite project with React and TypeScript:

npm create vite@latest auth-app -- --template react-ts
cd auth-app
npm install

Now, let's install the required dependencies:

npm install @mui/material @emotion/react @emotion/styled
npm install tailwindcss postcss autoprefixer @tailwindcss/nesting
npm install react-router-dom formik yup axios
  1. Configure Tailwind CSS and MUI

    First, let's set up Tailwind CSS. Create a tailwind.config.mjs file in the root directory and add the following content:

     import tailwindNesting from '@tailwindcss/nesting';
    
     export default {
       content: [
         "./index.html",
         "./src/**/*.{js,ts,jsx,tsx}",
       ],
       theme: {
         extend: {},
       },
       plugins: [
         tailwindNesting(),
       ],
     };
    

    Next, create a postcss.config.mjs file in the root directory and add the following content:

     import tailwindcss from 'tailwindcss';
     import autoprefixer from 'autoprefixer';
    
     export default {
       plugins: [
         tailwindcss,
         autoprefixer,
       ],
     };
    

    If you're using Vite, you might need to update your vite.config.ts file to use ES modules as well:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
});
  1. custom theme

Create a src/theme.ts file:

import { createTheme } from '@mui/material/styles';

const theme = createTheme({
  palette: {
    primary: {
      main: '#3f51b5',
    },
    secondary: {
      main: '#f50057',
    },
  },
  typography: {
    fontFamily: 'Roboto, Arial, sans-serif',
  },
});

export default theme;
  1. Context API

    implementing these additional features to complete your User Authentication App. We'll use Context API for state management and add API calls for registration and login.

    1. First, let's create a custom error class in src/utils/AuthError.ts

       export class AuthError extends Error {
         constructor(public code: string, message: string) {
           super(message);
           this.name = 'AuthError';
         }
       }
      
    2. Set up API calls and state management

      First, let's create an AuthContext and AuthProvider. Create a new file src/contexts/AuthContext.tsx:

import React, { createContext, useState, useContext, ReactNode } from "react";
import axios, { AxiosError } from "axios";
import { AuthError } from "../utils/AuthError";

interface User {
  username: string;
  firstname: string;
  lastname: string;
  emailId: string;
}
interface AuthContextType {
  user: User | null;
  login: (username: string, password: string) => Promise<void>;
  register: (userData: User & { password: string }) => Promise<void>;
  logout: () => void;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export const AuthProvider: React.FC<{ children: ReactNode }> = ({
  children,
}) => {
  const [user, setUser] = useState<User | null>(null);

  const handleApiError = (error: unknown): never => {
    if (axios.isAxiosError(error)) {
      const axiosError = error as AxiosError<{ message: string }>;
      if (axiosError.response) {
        throw new AuthError(
          axiosError.response.status.toString(),
          axiosError.response.data.message ||
            "An error occurred during authentication"
        );
      }
    }
    throw new AuthError("UNKNOWN", "An unknown error occurred");
  };

  const login = async (username: string, password: string) => {
    try {
      const response = await axios.post("https://reqres.in/api/login", {
        email: username,
        password: password,
      });
      const userData = response.data;
      setUser(userData);
      localStorage.setItem("token", userData.token);
    } catch (error) {
      handleApiError(error);
    }
  };

  const register = async (userData: User & { password: string }) => {
    try {
      const response = await axios.post("https://api.example.com/register", {
        email: userData.emailId,
        password: userData.password,
      });
      const newUser = response.data;
      setUser(newUser);
      localStorage.setItem("token", newUser.token);
    } catch (error) {
      handleApiError(error);
    }
  };

  const logout = () => {
    setUser(null);
    localStorage.removeItem("token");
  };

  return (
    <AuthContext.Provider value={{ user, login, register, logout }}>
      {children}
    </AuthContext.Provider>
  );
};

export const useAuth = () => {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error("useAuth must be used within an AuthProvider");
  }
  return context;
};
  1. First, let's create an AuthContext and AuthProvider. Create a new file src/contexts/AuthContext.tsx:

  2. Let's create the necessary components for our app.

    1. src/components/Register.tsx:
    import React, { useState } from "react";
    import { useFormik } from "formik";
    import * as Yup from "yup";
    import { TextField, Button, Box, Typography, Alert } from "@mui/material";
    import { useAuth } from "../../contexts/AuthContext";
    import { useNavigate } from "react-router-dom";
    import { AuthError } from "../../utils/AuthError";

    const Register: React.FC = () => {
      const { register } = useAuth();
      const navigate = useNavigate();
      const [error, setError] = useState<string | null>(null);

      const formik = useFormik({
        initialValues: {
          username: "",
          password: "",
          firstname: "",
          lastname: "",
          emailId: "",
        },
        validationSchema: Yup.object({
          username: Yup.string().required("Required"),
          password: Yup.string()
            .min(6, "Must be at least 6 characters")
            .required("Required"),
          firstname: Yup.string().required("Required"),
          lastname: Yup.string().required("Required"),
          emailId: Yup.string().email("Invalid email address").required("Required"),
        }),
        onSubmit: async (values, { setSubmitting }) => {
          try {
            await register(values);
            navigate("/profile");
          } catch (error) {
            if (error instanceof AuthError) {
              switch (error.code) {
                case "409":
                  setError("Username or email already exists");
                  break;
                case "400":
                  setError("Invalid input. Please check your information");
                  break;
                default:
                  setError(error.message);
              }
            } else {
              setError("An unexpected error occurred. Please try again");
            }
          } finally {
            setSubmitting(false);
          }
        },
      });

      return (
        <Box className="max-w-md mx-auto mt-8 p-6 bg-white rounded shadow-md">
          <Typography variant="h4" component="h1" gutterBottom>
            Register
          </Typography>
          {error && (
            <Alert severity="error" className="mb-4">
              {error}
            </Alert>
          )}
          <form onSubmit={formik.handleSubmit}>
            <TextField
              fullWidth
              id="username"
              name="username"
              label="Username"
              value={formik.values.username}
              onChange={formik.handleChange}
              error={formik.touched.username && Boolean(formik.errors.username)}
              helperText={formik.touched.username && formik.errors.username}
              className="mb-4"
            />
            <TextField
              fullWidth
              id="password"
              name="password"
              label="Password"
              type="password"
              value={formik.values.password}
              onChange={formik.handleChange}
              error={formik.touched.password && Boolean(formik.errors.password)}
              helperText={formik.touched.password && formik.errors.password}
              className="mb-4"
            />
            <TextField
              fullWidth
              id="firstname"
              name="firstname"
              label="First Name"
              value={formik.values.firstname}
              onChange={formik.handleChange}
              error={formik.touched.firstname && Boolean(formik.errors.firstname)}
              helperText={formik.touched.firstname && formik.errors.firstname}
              className="mb-4"
            />
            <TextField
              fullWidth
              id="lastname"
              name="lastname"
              label="Last Name"
              value={formik.values.lastname}
              onChange={formik.handleChange}
              error={formik.touched.lastname && Boolean(formik.errors.lastname)}
              helperText={formik.touched.lastname && formik.errors.lastname}
              className="mb-4"
            />
            <TextField
              fullWidth
              id="emailId"
              name="emailId"
              label="Email"
              value={formik.values.emailId}
              onChange={formik.handleChange}
              error={formik.touched.emailId && Boolean(formik.errors.emailId)}
              helperText={formik.touched.emailId && formik.errors.emailId}
              className="mb-4"
            />
            <Button
              color="primary"
              variant="contained"
              fullWidth
              type="submit"
              disabled={formik.isSubmitting}
            >
              {formik.isSubmitting ? "Registering..." : "Register"}
            </Button>
          </form>
        </Box>
      );
    };

    export default Register;
  1. src/components/Login.tsx:
    import React, { useState } from "react";
    import { useFormik } from "formik";
    import * as Yup from "yup";
    import { TextField, Button, Box, Typography, Alert } from "@mui/material";
    import { useAuth } from "../../contexts/AuthContext";
    import { useNavigate } from "react-router-dom";
    import { AuthError } from "../../utils/AuthError";

    const Login: React.FC = () => {
      const { login } = useAuth();
      const navigate = useNavigate();
      const [error, setError] = useState<string | null>(null);

      const formik = useFormik({
        initialValues: {
          username: "",
          password: "",
        },
        validationSchema: Yup.object({
          username: Yup.string().required("Required"),
          password: Yup.string().required("Required"),
        }),
        onSubmit: async (values, { setSubmitting }) => {
          try {
            await login(values.username, values.password);
            navigate("/profile");
          } catch (error) {
             if (error instanceof AuthError) {
               switch (error.code) {
                 case "401":
                   setError("Invalid username or password");
                   break;
                 case "429":
                   setError("Too many login attempts. Please try again later");
                   break;
                 default:
                   setError(error.message);
               }
             } else {
               setError("An unexpected error occurred. Please try again");
             }
          } finally {
            setSubmitting(false);
          }
        },
      });

      return (
        <Box className="max-w-md mx-auto mt-8 p-6 bg-white rounded shadow-md">
          <Typography variant="h4" component="h1" gutterBottom>
            Login
          </Typography>
          {error && (
            <Alert severity="error" className="mb-4">
              {error}
            </Alert>
          )}
          <form onSubmit={formik.handleSubmit}>
            <TextField
              fullWidth
              id="username"
              name="username"
              label="Username"
              value={formik.values.username}
              onChange={formik.handleChange}
              error={formik.touched.username && Boolean(formik.errors.username)}
              helperText={formik.touched.username && formik.errors.username}
              className="mb-4"
            />
            <TextField
              fullWidth
              id="password"
              name="password"
              label="Password"
              type="password"
              value={formik.values.password}
              onChange={formik.handleChange}
              error={formik.touched.password && Boolean(formik.errors.password)}
              helperText={formik.touched.password && formik.errors.password}
              className="mb-4"
            />
            <Button
              color="primary"
              variant="contained"
              fullWidth
              type="submit"
              disabled={formik.isSubmitting}
            >
              {formik.isSubmitting ? "Logging in..." : "Login"}
            </Button>
          </form>
        </Box>
      );
    };

    export default Login;
  1. src/components/Profile.tsx:

     import React from "react";
     import { Box, Typography, Button } from "@mui/material";
     import { useAuth } from "../../contexts/AuthContext";
     import { useNavigate } from "react-router-dom";
    
     const Profile: React.FC = () => {
       const { user, logout } = useAuth();
       const navigate = useNavigate();
    
       const handleLogout = () => {
         logout();
         navigate("/login");
       };
    
       if (!user) {
         return <Typography>Loading...</Typography>;
       }
    
       return (
         <Box className="max-w-md mx-auto mt-8 p-6 bg-white rounded shadow-md">
           <Typography variant="h4" component="h1" gutterBottom>
             User Profile
           </Typography>
           <Typography variant="body1">Username: {user.username}</Typography>
           <Typography variant="body1">First Name: {user.firstname}</Typography>
           <Typography variant="body1">Last Name: {user.lastname}</Typography>
           <Typography variant="body1">Email: {user.emailId}</Typography>
           <Button
             color="secondary"
             variant="contained"
             onClick={handleLogout}
             className="mt-4"
           >
             Logout
           </Button>
         </Box>
       );
     };
    
     export default Profile;
    
  1. Set up Routing and Protected Routes

Create a src/components/ProtectedRoute.tsx file

import React from "react";
import { Navigate } from "react-router-dom";
import { useAuth } from "../../contexts/AuthContext";

interface ProtectedRouteProps {
  children: React.ReactNode;
}

const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
  const { user } = useAuth();
  return user ? <>{children}</> : <Navigate to="/login" />;
};

export default ProtectedRoute;
  1. Update App.tsx

Update your src/App.tsx file:

import React from "react";
import { BrowserRouter as Router, Route, Routes, Link } from "react-router-dom";
import { ThemeProvider } from "@mui/material/styles";
import { CssBaseline, AppBar, Toolbar, Button } from "@mui/material";
import theme from "./theme";
import Register from "./components/auth/Register";
import Login from "./components/auth/Login";
import Profile from "./components/auth/Profile";
import ProtectedRoute from "./components/auth/ProtectedRoute";
import { AuthProvider } from "./contexts/AuthContext";

const App: React.FC = () => {
  return (
    <ThemeProvider theme={theme}>
      <CssBaseline />
      <AuthProvider>
        <Router>
          <AppBar position="static">
            <Toolbar>
              <Button color="inherit" component={Link} to="/register">
                Register
              </Button>
              <Button color="inherit" component={Link} to="/login">
                Login
              </Button>
              <Button color="inherit" component={Link} to="/profile">
                Profile
              </Button>
            </Toolbar>
          </AppBar>
          <Routes>
            <Route path="/register" element={<Register />} />
            <Route path="/login" element={<Login />} />
            <Route
              path="/profile"
              element={
                <ProtectedRoute>
                  <Profile />
                </ProtectedRoute>
              }
            />
          </Routes>
        </Router>
      </AuthProvider>
    </ThemeProvider>
  );
};

export default App;
  1. Update index.css

Update your src/index.css file to include Tailwind CSS:

@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';

body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

code {
  font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
    monospace;
}

Update your src/index.css file to include Tailwind CSS:

0
Subscribe to my newsletter

Read articles from Arunkumar Gopalan directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Arunkumar Gopalan
Arunkumar Gopalan