How to Implement Email Verification in a Node.js and React Application

17/07/2025

How to Implement Email Verification in a Node.js and React Application

Learn how to add email verification to your Node.js and React app. This guide covers sending verification emails, creating secure tokens, and verifying users through backend APIs.

Secure User Registration: Implementing Email Verification with Node.js and React

In today's digital landscape, user authentication is a cornerstone of almost every web application. While basic username/password registration is a start, implementing email verification adds a crucial layer of security and ensures the validity of user accounts. This process helps prevent spam registrations, reduces the risk of fake accounts, and provides a reliable way to recover user access.

This detailed guide will walk you through implementing email verification in a full-stack application using Node.js (with Express) for the backend and React for the frontend. We'll cover everything from sending verification emails to confirming user accounts.

Why Email Verification is Essential

Email verification isn't just a good practice; it's a vital security measure:

  • Account Security: Ensures that the email address provided by the user actually belongs to them.
  • Spam Prevention: Deters bots and malicious actors from creating numerous fake accounts.
  • Password Recovery: A verified email is essential for secure password reset flows.
  • Improved User Data: Ensures you have a valid contact point for your users.
  • Compliance: Some regulations may require email verification for certain types of applications.

Backend Setup: Node.js (Express) for Verification Logic

The backend will handle user registration, generate a unique verification token, send the email, and process the token upon user click.

1. Install Dependencies

Navigate to your `server` directory and install the necessary packages:

npm install express mongoose bcryptjs jsonwebtoken nodemailer dotenv
  • `express`: Web framework.
  • `mongoose`: ODM for MongoDB.
  • `bcryptjs`: For hashing passwords.
  • `jsonwebtoken`: For creating and verifying JWTs (our verification tokens).
  • `nodemailer`: For sending emails.
  • `dotenv`: For environment variables.

2. Environment Variables (`.env`)

Create a `.env` file in your `server` directory:

MONGO_URI=your_mongodb_connection_string
JWT_SECRET=supersecretjwtkey
EMAIL_USER=your_email@example.com
EMAIL_PASS=your_email_password # Use app-specific passwords for Gmail/Outlook
CLIENT_URL=http://localhost:3000 # Your React app's URL

3. User Model (`models/User.js`)

Create `server/models/User.js`:

// server/models/User.js

const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');

const UserSchema = new mongoose.Schema({
    username: {
        type: String,
        required: true,
        unique: true,
    },
    email: {
        type: String,
        required: true,
        unique: true,
    },
    password: {
        type: String,
        required: true,
    },
    isVerified: { // New field for email verification
        type: Boolean,
        default: false,
    },
    verificationToken: String, // Store the token (optional, can also be just for JWT payload)
    verificationTokenExpires: Date, // Expiration for the token
});

// Hash password before saving
UserSchema.pre('save', async function (next) {
    if (!this.isModified('password')) {
        next();
    }
    const salt = await bcrypt.genSalt(10);
    this.password = await bcrypt.hash(this.password, salt);
    next();
});

module.exports = mongoose.model('User', UserSchema);

4. Email Utility (`utils/sendEmail.js`)

Create `server/utils/sendEmail.js`:

// server/utils/sendEmail.js

const nodemailer = require('nodemailer');

const sendEmail = async (options) => {
    const transporter = nodemailer.createTransport({
        service: 'gmail', // or 'Outlook365', etc.
        auth: {
            user: process.env.EMAIL_USER,
            pass: process.env.EMAIL_PASS,
        },
    });

    const mailOptions = {
        from: process.env.EMAIL_USER,
        to: options.email,
        subject: options.subject,
        html: options.message,
    };

    await transporter.sendMail(mailOptions);
};

module.exports = sendEmail;

5. Server Routes (`server.js`)

Modify your `server/server.js` to include registration and verification routes:

// server/server.js

const express = require('express');
const mongoose = require('mongoose');
const cors = require('cors');
const jwt = require('jsonwebtoken');
const User = require('./models/User'); // Import User model
const sendEmail = require('./utils/sendEmail'); // Import email utility
require('dotenv').config();

const app = express();
const port = process.env.PORT || 5000;

// Middleware
app.use(cors());
app.use(express.json());

// MongoDB Connection
mongoose.connect(process.env.MONGO_URI)
    .then(() => console.log('MongoDB connected'))
    .catch(err => console.error(err));

// --- User Registration Route ---
app.post('/api/register', async (req, res) => {
    const { username, email, password } = req.body;

    try {
        // Check if user already exists
        let user = await User.findOne({ email });
        if (user) {
            return res.status(400).json({ message: 'User already exists' });
        }

        // Create new user
        user = new User({ username, email, password });
        await user.save();

        // Generate verification token (JWT)
        const verificationToken = jwt.sign(
            { id: user._id },
            process.env.JWT_SECRET,
            { expiresIn: '1h' } // Token expires in 1 hour
        );

        // Store token and expiry in user document (optional, for tracking/revocation)
        user.verificationToken = verificationToken;
        user.verificationTokenExpires = Date.now() + 3600000; // 1 hour
        await user.save();

        // Create verification URL
        const verificationUrl = `${process.env.CLIENT_URL}/verify-email?token=${verificationToken}`;

        // Send verification email
        const message = `
            <h1>Email Verification</h1>
            <p>Please verify your email address by clicking on the link below:</p>
            <a href="${verificationUrl}">Verify Email</a>
            <p>This link will expire in 1 hour.</p>
        `;

        await sendEmail({
            email: user.email,
            subject: 'Verify Your Email Address',
            message,
        });

        res.status(201).json({
            message: 'User registered successfully! Please check your email for verification link.',
            userId: user._id,
        });

    } catch (error) {
        console.error('Registration error:', error);
        res.status(500).json({ message: 'Server error during registration.', error: error.message });
    }
});

// --- Email Verification Route ---
app.get('/api/verify-email', async (req, res) => {
    const { token } = req.query;

    if (!token) {
        return res.status(400).json({ message: 'Verification token is missing.' });
    }

    try {
        // Verify the token
        const decoded = jwt.verify(token, process.env.JWT_SECRET);

        // Find the user by ID and check if token is valid and not expired
        const user = await User.findById(decoded.id);

        if (!user) {
            return res.status(404).json({ message: 'Invalid or expired verification link.' });
        }

        // Check if the token matches (if stored in DB) and not expired
        if (user.verificationToken !== token || user.verificationTokenExpires < Date.now()) {
            return res.status(400).json({ message: 'Invalid or expired verification link.' });
        }

        // Mark user as verified
        user.isVerified = true;
        user.verificationToken = undefined; // Clear token after verification
        user.verificationTokenExpires = undefined;
        await user.save();

        res.status(200).json({ message: 'Email verified successfully! You can now log in.' });

    } catch (error) {
        console.error('Email verification error:', error);
        if (error.name === 'TokenExpiredError') {
            return res.status(400).json({ message: 'Verification link has expired. Please register again.' });
        }
        res.status(500).json({ message: 'Server error during email verification.', error: error.message });
    }
});

// Start the server
app.listen(port, () => {
    console.log(`Server running on port ${port}`);
});

Explanation of Backend Code:

  • `UserSchema`: Includes `isVerified`, `verificationToken`, and `verificationTokenExpires` fields.
  • `/api/register` (POST):
    • Creates a new user with `isVerified: false`.
    • Generates a JWT (`jsonwebtoken`) containing the user's ID. This token acts as the verification link.
    • Constructs a `verificationUrl` that includes the generated token.
    • Uses `nodemailer` to send an email to the user with this verification link.
  • `/api/verify-email` (GET):
    • Receives the `token` from the URL query parameters.
    • Verifies the JWT using `jwt.verify()`.
    • Finds the user by the ID embedded in the token.
    • If valid, sets `user.isVerified` to `true` and saves the user.

Frontend Setup: React for User Registration and Verification

Your React frontend will provide the registration form and a dedicated page to handle the email verification link.

1. Install Dependencies (if not already)

Navigate to your `client` directory:

npm install react-router-dom
  • `react-router-dom`: For handling client-side routing to the verification page.

2. Registration Component (`Register.js`)

Create `client/src/components/Register.js`:

// client/src/components/Register.js

import React, { useState } from 'react';

function Register() {
  const [username, setUsername] = useState('');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [message, setMessage] = useState('');
  const [loading, setLoading] = useState(false);

  const handleSubmit = async (e) => {
    e.preventDefault();
    setLoading(true);
    setMessage('');

    try {
      const response = await fetch('/api/register', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ username, email, password }),
      });

      const data = await response.json();

      if (response.ok) {
        setMessage(data.message);
        setUsername('');
        setEmail('');
        setPassword('');
      } else {
        setMessage(data.message || 'Registration failed.');
      }
    } catch (error) {
      console.error('Registration error:', error);
      setMessage('Network error: Could not connect to server.');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div style={{
      backgroundColor: '#fff',
      padding: '30px',
      borderRadius: '10px',
      boxShadow: '0 4px 10px rgba(0,0,0,0.1)',
      width: '100%',
      maxWidth: '500px',
      margin: '20px auto',
      boxSizing: 'border-box',
      textAlign: 'left'
    }}>
      <h2 style={{
        fontFamily: 'Merriweather, serif',
        color: '#222',
        fontSize: '1.8em',
        marginBottom: '20px',
        borderBottom: '2px solid #eee',
        paddingBottom: '10px',
        boxSizing: 'border-box'
      }}>Register Account</h2>
      <form onSubmit={handleSubmit} style={{display: 'flex', flexDirection: 'column', gap: '15px', boxSizing: 'border-box'}}>
        <div style={{boxSizing: 'border-box'{{>
          <label htmlFor="username" style={{display: 'block', marginBottom: '5px', fontWeight: 'bold', color: '#555', boxSizing: 'border-box'}}>Username:</label>
          <input
            type="text"
            id="username"
            value={username}
            onChange={(e) => setUsername(e.target.value)}
            required
            style={{width: '100%', padding: '10px', border: '1px solid #ddd', borderRadius: '5px', boxSizing: 'border-box'}}
          />
        </div>
        <div style={{boxSizing: 'border-box'{{>
          <label htmlFor="email" style={{display: 'block', marginBottom: '5px', fontWeight: 'bold', color: '#555', boxSizing: 'border-box'}}>Email:</label>
          <input
            type="email"
            id="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            required
            style={{width: '100%', padding: '10px', border: '1px solid #ddd', borderRadius: '5px', boxSizing: 'border-box'}}
          />
        </div>
        <div style={{boxSizing: 'border-box'{{>
          <label htmlFor="password" style={{display: 'block', marginBottom: '5px', fontWeight: 'bold', color: '#555', boxSizing: 'border-box'}}>Password:</label>
          <input
            type="password"
            id="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            required
            style={{width: '100%', padding: '10px', border: '1px solid #ddd', borderRadius: '5px', boxSizing: 'border-box'}}
          />
        </div>
        <button type="submit" disabled={loading} style={{
          backgroundColor: loading ? '#ccc' : '#c00',
          color: '#fff',
          padding: '12px 25px',
          border: 'none',
          borderRadius: '8px',
          cursor: loading ? 'not-allowed' : 'pointer',
          fontSize: '1em',
          fontWeight: 'bold',
          transition: 'background-color 0.3s ease',
          boxShadow: '0 2px 5px rgba(0,0,0,0.2)',
          boxSizing: 'border-box'
        }}>
          {loading ? 'Registering...' : 'Register'}
        </button>
      </form>
      {message && (
        <p style={{
          marginTop: '20px',
          color: message.includes('successfully') ? 'green' : 'red',
          fontSize: '1em',
          boxSizing: 'border-box'
        }}>
          {message}
        </p>
      )}
    </div>
  );
}

export default Register;

Explanation of `Register.js`:

  • `useState`: Manages form input fields and UI feedback.
  • `handleSubmit`: Sends user registration data to the `/api/register` endpoint.
  • Feedback: Displays success or error messages to the user based on the backend response.

3. Email Verification Page (`VerifyEmail.js`)

Create `client/src/components/VerifyEmail.js`:

// client/src/components/VerifyEmail.js

import React, { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';

function VerifyEmail() {
  const location = useLocation();
  const [verificationStatus, setVerificationStatus] = useState('Verifying your email...');
  const [isSuccess, setIsSuccess] = useState(null);

  useEffect(() => {
    const verifyToken = async () => {
      const params = new URLSearchParams(location.search);
      const token = params.get('token');

      if (!token) {
        setVerificationStatus('Error: Verification token not found.');
        setIsSuccess(false);
        return;
      }

      try {
        const response = await fetch(`/api/verify-email?token=${token}`);
        const data = await response.json();

        if (response.ok) {
          setVerificationStatus(data.message);
          setIsSuccess(true);
        } else {
          setVerificationStatus(data.message || 'Email verification failed.');
          setIsSuccess(false);
        }
      } catch (error) {
        console.error('Verification network error:', error);
        setVerificationStatus('Network error: Could not verify email.');
        setIsSuccess(false);
      }
    };

    verifyToken();
  }, [location.search]); // Re-run if URL query params change

  return (
    <div style={{
      fontFamily: 'Open Sans, sans-serif',
      textAlign: 'center',
      padding: '40px',
      backgroundColor: '#f0f2f5',
      minHeight: '100vh',
      display: 'flex',
      flexDirection: 'column',
      justifyContent: 'center',
      alignItems: 'center',
      boxSizing: 'border-box'
    }}>
      <div style={{
        backgroundColor: '#fff',
        padding: '30px',
        borderRadius: '10px',
        boxShadow: '0 4px 10px rgba(0,0,0,0.1)',
        width: '100%',
        maxWidth: '500px',
        margin: '20px auto',
        boxSizing: 'border-box'
      }}>
        <h2 style={{
          fontFamily: 'Merriweather, serif',
          color: '#222',
          fontSize: '1.8em',
          marginBottom: '20px',
          borderBottom: '2px solid #eee',
          paddingBottom: '10px',
          boxSizing: 'border-box'
        }}>Email Verification</h2>
        <p style={{
          color: isSuccess === true ? 'green' : (isSuccess === false ? 'red' : '#007bff'),
          fontSize: '1.1em',
          boxSizing: 'border-box'
        }}>
          {verificationStatus}
        </p>
        {isSuccess === true && (
          <a href="/login" style={{
            display: 'inline-block',
            marginTop: '20px',
            backgroundColor: '#28a745',
            color: '#fff',
            padding: '10px 20px',
            borderRadius: '8px',
            textDecoration: 'none',
            fontSize: '1em',
            boxSizing: 'border-box'
          }}>Go to Login</a>
        )}
        {isSuccess === false && (
          <p style={{marginTop: '15px', fontSize: '0.9em', color: '#777', boxSizing: 'border-box'}}>
            Please check your email or try registering again.
          </p>
        )}
      </div>
    </div>
  );
}

export default VerifyEmail;

Explanation of `VerifyEmail.js`:

  • `useLocation`: Hook from `react-router-dom` to access the current URL's query parameters.
  • `useEffect`: Runs on component mount to extract the `token` from the URL and send it to the `/api/verify-email` endpoint.
  • Status Display: Updates the UI to show "Verifying...", "Email verified successfully!", or an error message.

4. Update `App.js` for Routing

Modify `client/src/App.js` to set up basic routing for your components:

// client/src/App.js

import React from 'react';
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
import Register from './components/Register';
import VerifyEmail from './components/VerifyEmail';

function App() {
  return (
    <Router>
      <div style={{
        fontFamily: 'Open Sans, sans-serif',
        textAlign: 'center',
        padding: '40px',
        backgroundColor: '#f0f2f5',
        minHeight: '100vh',
        display: 'flex',
        flexDirection: 'column',
        justifyContent: 'center',
        alignItems: 'center',
        boxSizing: 'border-box'
      }}>
        <h1 style={{
          fontFamily: 'Merriweather, serif',
          color: '#c00',
          fontSize: '3em',
          marginBottom: '20px',
          boxSizing: 'border-box'
        }}>User Authentication</h1>

        <nav style={{marginBottom: '30px', boxSizing: 'border-box'}}>
          <ul style={{listStyle: 'none', padding: '0', margin: '0', display: 'flex', gap: '20px', boxSizing: 'border-box'}}>
            <li style={{boxSizing: 'border-box'}}><Link to="/register" style={{
              color: '#007bff', textDecoration: 'none', fontSize: '1.1em', fontWeight: 'bold', boxSizing: 'border-box'
            }}>Register</Link></li>
            <li style={{boxSizing: 'border-box'}}><Link to="/verify-email" style={{
              color: '#007bff', textDecoration: 'none', fontSize: '1.1em', fontWeight: 'bold', boxSizing: 'border-box'
            }}>Verify Email (Test)</Link></li>
          </ul>
        </nav>

        <Routes>
          <Route path="/register" element={<Register />} />
          <Route path="/verify-email" element={<VerifyEmail />} />
          <Route path="/" element={<Register />} /> {/* Default route */}
        </Routes>
      </div>
    </Router>
  );
}

export default App;

Integration Flow: Node.js and React Email Verification

Here's a summary of the email verification flow:

  1. User Registers (React): The user fills out the registration form in the React frontend and submits it.
  2. Registration Request (React to Node.js): React sends a POST request with user data (username, email, password) to the `/api/register` endpoint on your Node.js/Express backend.
  3. User Creation & Token Generation (Node.js):
    • The backend hashes the password and saves the new user to MongoDB with `isVerified: false`.
    • A unique JWT (JSON Web Token) is generated, containing the user's ID. This token serves as the verification key.
  4. Verification Email Sent (Node.js with Nodemailer): The backend constructs a verification URL (e.g., `http://localhost:3000/verify-email?token=YOUR_JWT`) and sends it to the user's registered email address using Nodemailer.
  5. User Clicks Link (Email to React): The user receives the email and clicks the verification link. This navigates their browser to your React frontend's `/verify-email` route with the token in the URL.
  6. Token Verification Request (React to Node.js): The `VerifyEmail` component in React extracts the token from the URL and sends a GET request to your backend's `/api/verify-email` endpoint, passing the token.
  7. Backend Verifies Token & Updates User (Node.js): The backend verifies the JWT. If valid and not expired, it finds the user by the ID within the token and updates their `isVerified` status to `true` in MongoDB.
  8. Verification Confirmation (Node.js to React): The backend sends a success or error response back to the React frontend.
  9. Frontend Displays Status (React): The `VerifyEmail` component updates its UI to show the verification status (success or failure) to the user.

Conclusion

Implementing email verification is a crucial step towards building secure and reliable web applications. By combining Node.js and Express for robust backend logic (including token generation, email sending, and database updates) with React for a dynamic and user-friendly frontend, you can create a seamless and secure registration process. This not only enhances the security of your application but also improves the overall quality of your user base.