How to Build a Multi-Step Registration App with Animated Transitions Using the MERN Stack

Multi-step forms have become increasingly popular in recent years, and for good reason. By breaking down lengthy forms into smaller, more manageable steps, you can significantly improve the user experience and increase conversion rates on your registration pages.

In this article, we‘ll walk through how to build a multi-step registration app from scratch using the MERN stack (MongoDB, Express, React, Node.js). We‘ll create a responsive frontend UI with sliding page transitions, implement form validation, and integrate with a backend API to securely store user data. By the end, you‘ll have a fully-functional registration flow that you can adapt and use in your own projects.

Here are the key steps we‘ll cover:

  1. Design the database schema and models
  2. Create the backend APIs with Express and Node.js
  3. Build the frontend UI with React and Bootstrap
  4. Implement sliding page transitions with Framer Motion
  5. Integrate the frontend and backend

Backend

Database Schema

We‘ll use MongoDB for our database and Mongoose to create the database schema. Our User model will have the following fields:

  • First Name
  • Last Name
  • Email
  • Password
  • Country
  • State
  • City
const userSchema = mongoose.Schema(
  {
    first_name: {
      type: String,
      required: true,
      trim: true
    },
    last_name: {
      type: String,  
      required: true,
      trim: true
    },
    user_email: {
      type: String,
      required: true,
      trim: true,
      validate(value) {
        if (!value.match(/^[^@ ]+@[^@ ]+\.[^@ .]{2,}$/)) {
          throw new Error(‘Email is not valid.‘);
        }
      }
    },
    user_password: {
      type: String,
      required: true,  
      trim: true,
      minlength: 6
    },
    country: {
      type: String,
      required: true,
      trim: true
    },
    state: {  
      type: String,
      trim: true
    },
    city: {
      type: String,
      trim: true  
    }
  },
  {
    timestamps: true
  }
);

const User = mongoose.model(‘User‘, userSchema);

API Routes

Next, we‘ll create our Express routes and controllers for handling registration and login requests. We‘ll implement two POST endpoints:

  • /register for creating a new user
  • /login for authenticating an existing user

Here‘s the code for the /register route:

router.post(‘/register‘, async (req, res) => {
  const { user_email, user_password } = req.body;

  let user = await User.findOne({ user_email });
  if (user) {
    return res.status(400).send(‘User with the provided email already exist.‘);
  }

  try {
    user = new User(req.body);
    user.user_password = await bcrypt.hash(user_password, 8);

    await user.save();
    res.status(201).send();
  } catch (e) {
    res.status(500).send(‘Something went wrong. Try again later.‘);
  }
});

We first check if a user with the provided email already exists. If so, we return a 400 error.

If the email is unique, we create a new user instance with the request data and hash the provided password using bcrypt. We then save the user to the database and return a 201 status on success.

And here‘s the /login route:

router.post(‘/login‘, async (req, res) => {
  try {
    const user = await User.findOne({ user_email: req.body.user_email });
    if (!user) {
      return res.status(400).send(‘User with provided email does not exist.‘);
    }

    const isMatch = await bcrypt.compare(
      req.body.user_password,
      user.user_password
    );

    if (!isMatch) {
      return res.status(400).send(‘Invalid credentials.‘);
    }

    const { user_password, ...rest } = user.toObject();
    return res.send(rest);  
  } catch (error) {
    return res.status(500).send(‘Something went wrong. Try again later.‘);
  }
});

To log in an existing user, we first look up the user by their email. If no user is found, we return a 400 error.

If the user exists, we use bcrypt to compare the provided password with the hashed password stored in the database. If there‘s a match, we return a sanitized user object (omitting the password field). If the password is incorrect, we return an "Invalid credentials" error.

That covers our backend API. Now let‘s move on to the frontend.

Frontend

UI Components

We‘ll use React to build our frontend UI and Bootstrap for styling. The main components we‘ll create are:

  • Header with a progress bar indicating the current step
  • Three form pages (one for each step)
  • Button to navigate between steps
  • Success page shown after form submission

Here‘s an overview of the component hierarchy:

App
 ├── Header
 │   └── ProgressBar 
 ├── FirstStep
 ├── SecondStep  
 ├── ThirdStep
 └── SuccessPage

We‘ll use the react-hook-form library to handle form state and validation. This allows us to write cleaner code compared to manually managing state with useState and implementing all the validation logic ourselves.

Form Flow

The registration flow will be broken up into three steps:

  1. Basic Info

    • First Name
    • Last Name
  2. Account Info

    • Email
    • Password
  3. Location Info

    • Country
    • State
    • City

We‘ll collect partial data from the user on each page and pass it to the next step using component props. Once the user reaches the final step, we‘ll combine all the data and send it to the backend /register endpoint.

Validation

react-hook-form makes it easy to add validation to form fields. We can specify validation rules for each field, like so:

<Form.Control
  type="email"
  name="user_email"   
  ref={register({
    required: ‘Email is required.‘,
    pattern: {
      value: /^[^@ ]+@[^@ ]+\.[^@ .]{2,}$/,  
      message: ‘Email is not valid.‘
    }
  })} 
/>

Here the email field is marked as required. We also provide a regex pattern to validate the email format. If the validation fails, the specified error message will be displayed to the user.

Progress Bar

To help guide users through the registration flow, we‘ll add a progress bar that highlights the currently active step:

<ul className="progressbar">
  <li className={isFirstStep ? ‘active‘ : ‘‘}> 
    {isSecondStep || isThirdStep ? (
      <Link to="/">Step 1</Link>
    ) : (
      ‘Step 1‘  
    )}
  </li>
  <li className={isSecondStep ? ‘active‘ : ‘‘}>
    {isThirdStep ? <Link to="/step2">Step 2</Link> : ‘Step 2‘}
  </li>
  <li className={isThirdStep ? ‘active‘ : ‘‘}>Step 3</li>  
</ul>

We use the current URL pathname to determine which step is active. The previous steps are turned into links so the user can go back and edit their responses.

Sliding Transitions

To add some visual flair and make the transitions between form pages more engaging, we‘ll use the Framer Motion library to create sliding animations.

First we wrap the components we want to animate with a motion component:

<motion.div
  initial={{ opacity: 0, x: ‘-100%‘ }} 
  animate={{ opacity: 1, x: 0 }}
>
  {/* Form fields */}
</motion.div>

The initial prop specifies the starting point of the animation – in this case the form will start off-screen to the left.

The animate prop indicates the end point. So the form will slide in from the left edge of the screen to its final position.

We can adjust the speed and easing of the animation by adding a transition prop:

<motion.div
  initial={{ opacity: 0, x: ‘-100%‘ }}
  animate={{ opacity: 1, x: 0 }} 
  transition={{ duration: 0.5, ease: ‘easeOut‘ }}  
>
  {/* Form fields */}
</motion.div>

Now our form animates smoothly between pages, adding a nice touch of polish to the UX.

Connecting Frontend & Backend

Once the user completes all three steps and submits the form, we‘ll send a POST request with the combined data to our backend /register endpoint.

To do this, we use Axios to make the HTTP request:

const { user } = props;

const updatedData = {
  country: countries.find(c => c.isoCode === selectedCountry)?.name,
  state: states.find(s => s.isoCode === selectedState)?.name,
  city: selectedCity
};

await axios.post(`${BASE_API_URL}/register`, {
  ...user,
  ...updatedData 
});

Note that since our frontend and backend are running on different ports, we need to enable CORS (Cross-Origin Resource Sharing) on the backend to allow requests from the frontend domain. We can do this easily by adding the cors middleware to our Express app.

If the registration is successful, we display a success message to the user. If there‘s an error (e.g. an existing user with the same email), we show the corresponding error message.

Deployment

To deploy our app, we first need to create a production build of our React frontend using yarn build or npm run build. This will generate an optimized bundle of our app in the build folder.

Then in our backend code, we can configure Express to serve this static build folder:

const express = require(‘express‘);
const path = require(‘path‘);

const app = express();

app.use(express.static(path.join(__dirname, ‘..‘, ‘build‘))); 

// Add API routes

app.get(‘*‘, (req, res) => {
  res.sendFile(path.join(__dirname, ‘..‘, ‘build‘, ‘index.html‘));
});

We add a catch-all route that sends back index.html for any unmatched routes. This is necessary for client-side routing to work properly.

Now we can run just our backend server and it will serve both the React frontend and API endpoints. No need for CORS!

Handling User Login

As a bonus feature, let‘s add the ability for users to log into their account after registering. We‘ll create a simple login form that takes an email and password:

<Form onSubmit={handleSubmit(onSubmit)}>
  <Form.Control
    type="email"
    name="user_email"  
    placeholder="Email" 
    ref={register({
      required: ‘Email is required.‘
    })}
  />

  <Form.Control
    type="password" 
    name="user_password"
    placeholder="Password"
    ref={register({
      required: ‘Password is required.‘,
      minLength: {
        value: 6,
        message: ‘Password should have at least 6 characters.‘
      }  
    })} 
  />

  <Button type="submit">Log In</Button>
</Form>

When the form is submitted, we‘ll send the provided credentials to our backend /login API.

If the login is successful, we‘ll display a personalized welcome message along with the user‘s full name and location info. If the credentials are invalid, we‘ll show an error message.

Here‘s the code for the login API call:

const onSubmit = async (data) => {
  try {
    const response = await axios.post(`${BASE_API_URL}/login`, data);
    setUserDetails(response.data);
    setErrorMsg(‘‘);
  } catch (err) {
    setErrorMsg(err.response.data);
  }
};

Conclusion

Congratulations, you now have a fully-functional multi-step registration app built with the MERN stack!

In this article, we covered how to:

  • Create a schema and model for storing user data in MongoDB
  • Implement registration and login APIs with Express and Node.js
  • Design a responsive, multi-step form UI with React and Bootstrap
  • Add smooth sliding transitions between form pages using Framer Motion
  • Integrate the React frontend with the backend APIs
  • Deploy a MERN app to production
  • Handle user authentication and display user-specific data

With these core concepts under your belt, you‘re well on your way to developing more complex and feature-rich web applications.

Some potential improvements to our app could be:

  • Adding email verification after registration
  • Implementing password reset functionality
  • Storing user auth state (e.g. JWT) on successful login
  • Letting users edit their account details
  • Writing unit and integration tests

Feel free to use this code as a starting point and expand on it to fit your own needs. Thanks for reading, and happy coding!

Similar Posts