Connecting React Frontend to Express Backend

Building a seamless full-stack application with the MERN stack

Introduction to MERN Stack Architecture

The MERN stack is a powerful combination of technologies that enables developers to build robust, full-stack JavaScript applications:

flowchart TD subgraph Client A[React Frontend] B[React Components] C[State Management] D[Routing] end subgraph Server E[Express.js] F[Routes] G[Controllers] H[Middleware] end subgraph Database I[MongoDB] J[Collections] K[Documents] end A --- B B --- C B --- D A <--> E E --- F F --- G E --- H G <--> I I --- J J --- K style Client fill:#d4f0f0,stroke:#000 style Server fill:#ffe6cc,stroke:#000 style Database fill:#e6ccff,stroke:#000

One of the key challenges in building a MERN application is establishing a robust connection between the React frontend and the Express backend. This lecture will focus on strategies for creating this connection effectively and securely.

Development Environment Setup

Project Structure Options

There are two common approaches to organizing a MERN stack project:

1. Separate Repositories/Projects

- frontend/ (React project)
  - package.json
  - public/
  - src/
    - components/
    - pages/
    - services/
    - App.js
    - index.js

- backend/ (Express project)
  - package.json
  - server.js
  - routes/
  - controllers/
  - models/
  - middleware/

2. Monorepo Approach

- mern-project/
  - package.json
  - client/
    - package.json
    - public/
    - src/
      - components/
      - pages/
      - services/
      - App.js
      - index.js
  - server/
    - package.json
    - server.js
    - routes/
    - controllers/
    - models/
    - middleware/

Real-world analogy: Think of the monorepo approach like having multiple departments within a single office building, while the separate repositories approach is like having different branch offices that communicate with each other.

Setting Up a Monorepo Project

Let's walk through setting up a monorepo for a MERN stack application:

# Create project directory
mkdir my-mern-app
cd my-mern-app

# Initialize package.json for the root project
npm init -y

# Create client directory and initialize React app
npx create-react-app client

# Create server directory and initialize Express app
mkdir server
cd server
npm init -y
npm install express mongoose cors dotenv

# Create basic server files
touch server.js
mkdir routes controllers models middleware

Edit the root package.json to add scripts for running both client and server:

{
  "name": "my-mern-app",
  "version": "1.0.0",
  "description": "A MERN stack application",
  "main": "index.js",
  "scripts": {
    "start": "node server/server.js",
    "server": "nodemon server/server.js",
    "client": "npm start --prefix client",
    "dev": "concurrently \"npm run server\" \"npm run client\"",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "concurrently": "^7.0.0",
    "nodemon": "^2.0.15"
  }
}

Install the dev dependencies:

npm install --save-dev concurrently nodemon

Setting Up the Express Backend

Let's create a basic Express server with an API endpoint that our React frontend can communicate with.

Basic Express Server (server.js)

const express = require('express');
const cors = require('cors');
const mongoose = require('mongoose');
require('dotenv').config();

// Create Express app
const app = express();
const PORT = process.env.PORT || 5000;

// Middleware
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// Routes
app.get('/api', (req, res) => {
  res.json({ message: 'Welcome to the MERN API' });
});

// Include route files
const usersRoutes = require('./routes/users');
app.use('/api/users', usersRoutes);

// Connect to MongoDB
mongoose
  .connect(process.env.MONGODB_URI)
  .then(() => {
    console.log('Connected to MongoDB');
    // Start server after successful database connection
    app.listen(PORT, () => {
      console.log(`Server running on port ${PORT}`);
    });
  })
  .catch((err) => {
    console.error('MongoDB connection error:', err);
  });

// Error handling middleware
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ message: 'Something went wrong!' });
});

Creating a .env File

# server/.env
PORT=5000
MONGODB_URI=mongodb://localhost:27017/mern-app
JWT_SECRET=your_jwt_secret_key

Remember to add .env to your .gitignore file to prevent sensitive data from being committed to your repository.

Creating a Simple User Model (models/User.js)

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

const userSchema = new mongoose.Schema(
  {
    name: {
      type: String,
      required: true,
      trim: true,
    },
    email: {
      type: String,
      required: true,
      unique: true,
      trim: true,
      lowercase: true,
    },
    password: {
      type: String,
      required: true,
      minlength: 6,
    },
    role: {
      type: String,
      default: 'user',
      enum: ['user', 'admin'],
    },
  },
  {
    timestamps: true,
  }
);

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

// Method to check password
userSchema.methods.comparePassword = async function (candidatePassword) {
  return bcrypt.compare(candidatePassword, this.password);
};

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

module.exports = User;

Setting Up User Routes (routes/users.js)

const express = require('express');
const router = express.Router();
const User = require('../models/User');
const jwt = require('jsonwebtoken');

// Get all users (admin only route in a real app)
router.get('/', async (req, res) => {
  try {
    const users = await User.find().select('-password');
    res.json(users);
  } catch (err) {
    res.status(500).json({ message: err.message });
  }
});

// Register a new user
router.post('/register', async (req, res) => {
  try {
    const { name, email, password } = req.body;
    
    // Check if user already exists
    const existingUser = await User.findOne({ email });
    if (existingUser) {
      return res.status(400).json({ message: 'User already exists' });
    }
    
    // Create new user
    const user = new User({
      name,
      email,
      password,
    });
    
    await user.save();
    
    // Generate JWT
    const token = jwt.sign(
      { userId: user._id },
      process.env.JWT_SECRET,
      { expiresIn: '1d' }
    );
    
    res.status(201).json({
      _id: user._id,
      name: user.name,
      email: user.email,
      role: user.role,
      token,
    });
  } catch (err) {
    res.status(500).json({ message: err.message });
  }
});

// Login user
router.post('/login', async (req, res) => {
  try {
    const { email, password } = req.body;
    
    // Find user
    const user = await User.findOne({ email });
    if (!user) {
      return res.status(401).json({ message: 'Invalid credentials' });
    }
    
    // Verify password
    const isMatch = await user.comparePassword(password);
    if (!isMatch) {
      return res.status(401).json({ message: 'Invalid credentials' });
    }
    
    // Generate JWT
    const token = jwt.sign(
      { userId: user._id },
      process.env.JWT_SECRET,
      { expiresIn: '1d' }
    );
    
    res.json({
      _id: user._id,
      name: user.name,
      email: user.email,
      role: user.role,
      token,
    });
  } catch (err) {
    res.status(500).json({ message: err.message });
  }
});

module.exports = router;

Creating Authentication Middleware (middleware/auth.js)

const jwt = require('jsonwebtoken');
const User = require('../models/User');

const auth = async (req, res, next) => {
  try {
    // Get token from header
    const token = req.header('Authorization')?.replace('Bearer ', '');
    
    if (!token) {
      return res.status(401).json({ message: 'No authentication token, access denied' });
    }
    
    // Verify token
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    
    // Find user
    const user = await User.findById(decoded.userId).select('-password');
    
    if (!user) {
      return res.status(401).json({ message: 'Token is valid, but user not found' });
    }
    
    // Attach user to request object
    req.user = user;
    next();
  } catch (err) {
    res.status(401).json({ message: 'Token is not valid' });
  }
};

module.exports = auth;

Protected Route Example

// In routes/users.js, add:
const auth = require('../middleware/auth');

// Get user profile (protected route)
router.get('/profile', auth, async (req, res) => {
  // req.user is available from the auth middleware
  res.json(req.user);
});

Setting Up the React Frontend for API Communication

Now that we have our Express backend, let's configure our React frontend to communicate with it.

Configuring the Proxy

For development, we can use the proxy feature in React's package.json to forward API requests to our Express server without having to specify the full URL each time.

// client/package.json
{
  "name": "client",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    // ...dependencies
  },
  "scripts": {
    // ...scripts
  },
  "proxy": "http://localhost:5000"
}

Note: With this proxy setting, when you make a request to /api/users from your React app, it will be forwarded to http://localhost:5000/api/users during development.

API Service with Axios

Let's create a service to handle API requests using Axios, a popular HTTP client library.

// client/src/services/api.js
import axios from 'axios';

// Create an axios instance
const api = axios.create({
  baseURL: '/api',
  headers: {
    'Content-Type': 'application/json',
  },
});

// Request interceptor for adding the auth token
api.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem('token');
    if (token) {
      config.headers['Authorization'] = `Bearer ${token}`;
    }
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

// Response interceptor for handling errors
api.interceptors.response.use(
  (response) => response,
  (error) => {
    // Handle 401 - Unauthorized errors (token expired, etc.)
    if (error.response && error.response.status === 401) {
      localStorage.removeItem('token');
      // Redirect to login page or refresh the token
      window.location.href = '/login';
    }
    return Promise.reject(error);
  }
);

export default api;

Creating API Service Functions

// client/src/services/userService.js
import api from './api';

export const userService = {
  register: async (userData) => {
    const response = await api.post('/users/register', userData);
    return response.data;
  },
  
  login: async (credentials) => {
    const response = await api.post('/users/login', credentials);
    // Store token in localStorage
    if (response.data.token) {
      localStorage.setItem('token', response.data.token);
    }
    return response.data;
  },
  
  logout: () => {
    localStorage.removeItem('token');
  },
  
  getCurrentUser: async () => {
    const response = await api.get('/users/profile');
    return response.data;
  },
  
  getAllUsers: async () => {
    const response = await api.get('/users');
    return response.data;
  },
};
sequenceDiagram participant User participant React participant LocalStorage participant Express participant MongoDB User->>React: Fill login form React->>Express: POST /api/users/login Express->>MongoDB: Find user by email MongoDB-->>Express: User data Express->>Express: Verify password Express-->>React: User data + JWT React->>LocalStorage: Store JWT token React-->>User: Show success message Note over User,React: Later, accessing protected resource User->>React: Click "Profile" React->>LocalStorage: Get JWT token LocalStorage-->>React: Token React->>Express: GET /api/users/profile with token Express->>Express: Verify JWT Express->>MongoDB: Get user data MongoDB-->>Express: User data Express-->>React: User profile data React-->>User: Display profile

Building React Components to Interact with API

Registration Form Component

// client/src/components/auth/Register.js
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { userService } from '../../services/userService';

function Register() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    password: '',
    confirmPassword: '',
  });
  
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);
  const navigate = useNavigate();
  
  const { name, email, password, confirmPassword } = formData;
  
  const handleChange = (e) => {
    setFormData({ ...formData, [e.target.name]: e.target.value });
  };
  
  const handleSubmit = async (e) => {
    e.preventDefault();
    
    // Validation
    if (password !== confirmPassword) {
      setError('Passwords do not match');
      return;
    }
    
    // Clear previous errors
    setError('');
    setLoading(true);
    
    try {
      // Call the register service function
      const userData = await userService.register({
        name,
        email,
        password,
      });
      
      // Registration successful
      console.log('User registered:', userData);
      
      // Redirect to dashboard
      navigate('/dashboard');
    } catch (err) {
      setError(
        err.response?.data?.message || 
        'An error occurred during registration'
      );
    } finally {
      setLoading(false);
    }
  };
  
  return (
    <div className="auth-form-container">
      <h2>Register</h2>
      
      {error && <div className="error-message">{error}</div>}
      
      <form onSubmit={handleSubmit}>
        <div className="form-group">
          <label htmlFor="name">Name</label>
          <input
            type="text"
            id="name"
            name="name"
            value={name}
            onChange={handleChange}
            required
          />
        </div>
        
        <div className="form-group">
          <label htmlFor="email">Email</label>
          <input
            type="email"
            id="email"
            name="email"
            value={email}
            onChange={handleChange}
            required
          />
        </div>
        
        <div className="form-group">
          <label htmlFor="password">Password</label>
          <input
            type="password"
            id="password"
            name="password"
            value={password}
            onChange={handleChange}
            required
            minLength="6"
          />
        </div>
        
        <div className="form-group">
          <label htmlFor="confirmPassword">Confirm Password</label>
          <input
            type="password"
            id="confirmPassword"
            name="confirmPassword"
            value={confirmPassword}
            onChange={handleChange}
            required
            minLength="6"
          />
        </div>
        
        <button 
          type="submit" 
          className="auth-button" 
          disabled={loading}
        >
          {loading ? 'Registering...' : 'Register'}
        </button>
      </form>
      
      <p>
        Already have an account? <a href="/login">Login</a>
      </p>
    </div>
  );
}

export default Register;

Login Form Component

// client/src/components/auth/Login.js
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { userService } from '../../services/userService';

function Login() {
  const [formData, setFormData] = useState({
    email: '',
    password: '',
  });
  
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);
  const navigate = useNavigate();
  
  const { email, password } = formData;
  
  const handleChange = (e) => {
    setFormData({ ...formData, [e.target.name]: e.target.value });
  };
  
  const handleSubmit = async (e) => {
    e.preventDefault();
    
    // Clear previous errors
    setError('');
    setLoading(true);
    
    try {
      // Call the login service function
      const userData = await userService.login({
        email,
        password,
      });
      
      // Login successful
      console.log('User logged in:', userData);
      
      // Redirect to dashboard
      navigate('/dashboard');
    } catch (err) {
      setError(
        err.response?.data?.message || 
        'Invalid email or password'
      );
    } finally {
      setLoading(false);
    }
  };
  
  return (
    <div className="auth-form-container">
      <h2>Login</h2>
      
      {error && <div className="error-message">{error}</div>}
      
      <form onSubmit={handleSubmit}>
        <div className="form-group">
          <label htmlFor="email">Email</label>
          <input
            type="email"
            id="email"
            name="email"
            value={email}
            onChange={handleChange}
            required
          />
        </div>
        
        <div className="form-group">
          <label htmlFor="password">Password</label>
          <input
            type="password"
            id="password"
            name="password"
            value={password}
            onChange={handleChange}
            required
          />
        </div>
        
        <button 
          type="submit" 
          className="auth-button" 
          disabled={loading}
        >
          {loading ? 'Logging in...' : 'Login'}
        </button>
      </form>
      
      <p>
        Don't have an account? <a href="/register">Register</a>
      </p>
    </div>
  );
}

export default Login;

Protected Profile Component

// client/src/components/profile/Profile.js
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { userService } from '../../services/userService';

function Profile() {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState('');
  const navigate = useNavigate();
  
  useEffect(() => {
    // Fetch user profile
    const fetchProfile = async () => {
      try {
        const userData = await userService.getCurrentUser();
        setUser(userData);
      } catch (err) {
        console.error('Error fetching profile:', err);
        setError('Failed to load profile. Please login again.');
        
        // Redirect to login if authentication error
        if (err.response && err.response.status === 401) {
          navigate('/login');
        }
      } finally {
        setLoading(false);
      }
    };
    
    fetchProfile();
  }, [navigate]);
  
  const handleLogout = () => {
    userService.logout();
    navigate('/login');
  };
  
  if (loading) {
    return <div>Loading profile...</div>;
  }
  
  if (error) {
    return <div className="error-message">{error}</div>;
  }
  
  return (
    <div className="profile-container">
      <h2>User Profile</h2>
      
      {user && (
        <div className="profile-details">
          <p><strong>Name:</strong> {user.name}</p>
          <p><strong>Email:</strong> {user.email}</p>
          <p><strong>Role:</strong> {user.role}</p>
          <p><strong>Joined:</strong> {new Date(user.createdAt).toLocaleDateString()}</p>
        </div>
      )}
      
      <button 
        className="logout-button"
        onClick={handleLogout}
      >
        Logout
      </button>
    </div>
  );
}

export default Profile;

Authentication Context for Global State Management

To manage authentication state across the entire application, we can create a context provider.

// client/src/context/AuthContext.js
import React, { createContext, useContext, useState, useEffect } from 'react';
import { userService } from '../services/userService';

// Create context
const AuthContext = createContext();

// Create provider component
export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  // Check if user is logged in on initial load
  useEffect(() => {
    const loadUser = async () => {
      try {
        // Check if token exists
        const token = localStorage.getItem('token');
        if (!token) {
          setLoading(false);
          return;
        }
        
        // Token exists, try to get user data
        const userData = await userService.getCurrentUser();
        setUser(userData);
      } catch (err) {
        console.error('Authentication error:', err);
        // Clear any invalid tokens
        userService.logout();
      } finally {
        setLoading(false);
      }
    };
    
    loadUser();
  }, []);
  
  // Login function
  const login = async (credentials) => {
    setLoading(true);
    setError(null);
    
    try {
      const userData = await userService.login(credentials);
      setUser(userData);
      return userData;
    } catch (err) {
      setError(err.response?.data?.message || 'Login failed');
      throw err;
    } finally {
      setLoading(false);
    }
  };
  
  // Register function
  const register = async (userData) => {
    setLoading(true);
    setError(null);
    
    try {
      const newUser = await userService.register(userData);
      setUser(newUser);
      return newUser;
    } catch (err) {
      setError(err.response?.data?.message || 'Registration failed');
      throw err;
    } finally {
      setLoading(false);
    }
  };
  
  // Logout function
  const logout = () => {
    userService.logout();
    setUser(null);
  };
  
  return (
    <AuthContext.Provider
      value={{
        user,
        loading,
        error,
        login,
        register,
        logout,
        isAuthenticated: !!user,
      }}
    >
      {children}
    </AuthContext.Provider>
  );
}

// Custom hook to use auth context
export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}

Using the AuthContext in App.js

// client/src/App.js
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './context/AuthContext';

// Components
import Login from './components/auth/Login';
import Register from './components/auth/Register';
import Profile from './components/profile/Profile';
import Dashboard from './components/dashboard/Dashboard';
import NotFound from './components/layout/NotFound';
import Navbar from './components/layout/Navbar';

// Protected route component
const ProtectedRoute = ({ children }) => {
  const { isAuthenticated, loading } = useAuth();
  
  if (loading) {
    return <div>Loading...</div>;
  }
  
  if (!isAuthenticated) {
    return <Navigate to="/login" />;
  }
  
  return children;
};

function App() {
  return (
    <AuthProvider>
      <Router>
        <div className="app">
          <Navbar />
          <main className="container">
            <Routes>
              <Route path="/login" element={<Login />} />
              <Route path="/register" element={<Register />} />
              <Route 
                path="/profile" 
                element={
                  <ProtectedRoute>
                    <Profile />
                  </ProtectedRoute>
                } 
              />
              <Route 
                path="/dashboard" 
                element={
                  <ProtectedRoute>
                    <Dashboard />
                  </ProtectedRoute>
                } 
              />
              <Route path="/" element={<Navigate to="/dashboard" />} />
              <Route path="*" element={<NotFound />} />
            </Routes>
          </main>
        </div>
      </Router>
    </AuthProvider>
  );
}

export default App;

Updated Login Component Using Context

// client/src/components/auth/Login.js
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';

function Login() {
  const [formData, setFormData] = useState({
    email: '',
    password: '',
  });
  
  const { login, error, loading } = useAuth();
  const navigate = useNavigate();
  
  const { email, password } = formData;
  
  const handleChange = (e) => {
    setFormData({ ...formData, [e.target.name]: e.target.value });
  };
  
  const handleSubmit = async (e) => {
    e.preventDefault();
    
    try {
      await login({ email, password });
      navigate('/dashboard');
    } catch (err) {
      // Error is handled by the auth context
      console.error('Login error:', err);
    }
  };
  
  return (
    <div className="auth-form-container">
      <h2>Login</h2>
      
      {error && <div className="error-message">{error}</div>}
      
      <form onSubmit={handleSubmit}>
        <div className="form-group">
          <label htmlFor="email">Email</label>
          <input
            type="email"
            id="email"
            name="email"
            value={email}
            onChange={handleChange}
            required
          />
        </div>
        
        <div className="form-group">
          <label htmlFor="password">Password</label>
          <input
            type="password"
            id="password"
            name="password"
            value={password}
            onChange={handleChange}
            required
          />
        </div>
        
        <button 
          type="submit" 
          className="auth-button" 
          disabled={loading}
        >
          {loading ? 'Logging in...' : 'Login'}
        </button>
      </form>
      
      <p>
        Don't have an account? <a href="/register">Register</a>
      </p>
    </div>
  );
}

export default Login;

Handling CORS Issues

Cross-Origin Resource Sharing (CORS) is a common challenge when connecting frontend and backend services. CORS is a security feature implemented by browsers that restricts web pages from making requests to a different domain.

CORS Configuration on Express Server

// server/server.js
const cors = require('cors');

// Basic CORS setup
app.use(cors());

// Or with specific options
app.use(cors({
  origin: 'http://localhost:3000', // Allow only your frontend domain
  methods: ['GET', 'POST', 'PUT', 'DELETE'], // Allowed HTTP methods
  allowedHeaders: ['Content-Type', 'Authorization'], // Allowed headers
  credentials: true, // Allow cookies to be sent
  maxAge: 86400, // How long the results of a preflight request can be cached
}));

Real-world analogy: CORS is like a bouncer at an exclusive club. The bouncer checks if visitors (web requests) are on the guest list (allowed origins) before letting them in. If they're not on the list, they get turned away.

Common CORS Errors and Solutions

Error Solution
Access to fetch at 'http://localhost:5000/api/users' from origin 'http://localhost:3000' has been blocked by CORS policy - Ensure cors middleware is properly configured
- Check that the origin is allowed
- Verify that all required headers are included in allowedHeaders
Preflight response is not successful - Make sure your server responds to OPTIONS requests
- Check if the requested HTTP method is allowed
- Ensure all required headers are allowed
The 'Access-Control-Allow-Origin' header has a value that is not equal to the supplied origin - Update your origin configuration to include the frontend domain
- Consider using a wildcard (*) for development (not recommended for production)
sequenceDiagram participant Browser participant Frontend participant Backend Frontend->>Browser: Make API request to different origin Browser->>Backend: Send preflight OPTIONS request Backend->>Browser: Respond with CORS headers alt CORS headers allow the request Browser->>Backend: Send actual API request Backend->>Browser: Return API response Browser->>Frontend: Deliver response else CORS headers don't allow Browser->>Frontend: Error: Blocked by CORS policy end

Environment Configuration for Different Environments

Different environments (development, testing, production) require different configurations. Let's set up our application to handle these environments correctly.

Server-side Environment Configuration

// server/.env.development
PORT=5000
MONGODB_URI=mongodb://localhost:27017/mern-dev
JWT_SECRET=dev_jwt_secret
NODE_ENV=development

// server/.env.production
PORT=8080
MONGODB_URI=mongodb+srv://username:password@cluster.mongodb.net/production-db
JWT_SECRET=production_jwt_secret_very_secure_and_long
NODE_ENV=production

Loading environment variables based on the environment:

// server/config/config.js
const dotenv = require('dotenv');
const path = require('path');

// Load environment variables based on NODE_ENV
const env = process.env.NODE_ENV || 'development';
const envPath = path.resolve(__dirname, `../.env.${env}`);

dotenv.config({ path: envPath });

module.exports = {
  port: process.env.PORT || 5000,
  mongoUri: process.env.MONGODB_URI,
  jwtSecret: process.env.JWT_SECRET,
  nodeEnv: process.env.NODE_ENV,
  corsOrigin: process.env.CORS_ORIGIN || '*',
};

React Environment Variables

In React, we can use environment variables for configuration. Create these files in the client directory:

// client/.env.development
REACT_APP_API_URL=http://localhost:5000/api
REACT_APP_ENV=development

// client/.env.production
REACT_APP_API_URL=/api
REACT_APP_ENV=production

Note: In React, all environment variables must start with REACT_APP_ to be accessible in the application.

Using environment variables in React:

// client/src/services/api.js
import axios from 'axios';

// Get the API URL from environment variables
const apiUrl = process.env.REACT_APP_API_URL || '/api';

// Create an axios instance
const api = axios.create({
  baseURL: apiUrl,
  headers: {
    'Content-Type': 'application/json',
  },
});

// Rest of the api.js code...

export default api;

Deployment Considerations

When deploying a MERN application, there are several approaches to consider.

Option 1: Separate Deployment

Option 2: Single Server Deployment

// server/server.js - Add this code to serve React build files
const path = require('path');

// API routes
app.use('/api/users', usersRoutes);
// Other API routes...

// Serve static assets in production
if (process.env.NODE_ENV === 'production') {
  // Set static folder
  app.use(express.static(path.join(__dirname, '../client/build')));
  
  // Serve the React frontend for any other route
  app.get('*', (req, res) => {
    res.sendFile(path.resolve(__dirname, '../client/build/index.html'));
  });
}

Update package.json in the project root to include build scripts:

{
  "scripts": {
    "start": "node server/server.js",
    "server": "nodemon server/server.js",
    "client": "npm start --prefix client",
    "dev": "concurrently \"npm run server\" \"npm run client\"",
    "build": "npm run build --prefix client",
    "heroku-postbuild": "NPM_CONFIG_PRODUCTION=false npm install --prefix client && npm run build --prefix client"
  }
}
flowchart TD subgraph "Option 1: Separate Deployment" A[React Frontend] -->|API Calls| B[Express Backend] B -->|Database Queries| C[MongoDB Database] end subgraph "Option 2: Single Server Deployment" D[Express Server] --- E[Serves Static React Files] D --- F[Handles API Requests] F -->|Database Queries| G[MongoDB Database] end style A fill:#d4f0f0,stroke:#000 style B fill:#ffe6cc,stroke:#000 style C fill:#e6ccff,stroke:#000 style D fill:#ffe6cc,stroke:#000 style E fill:#d4f0f0,stroke:#000 style F fill:#ffe6cc,stroke:#000 style G fill:#e6ccff,stroke:#000

Common Issues and Troubleshooting

CORS Errors

Proxy Configuration Issues

Authentication Token Problems

Environment Variable Issues

Practice Activities

Activity 1: Basic Connection Setup

Create a minimal MERN application with:

  • Express server with a simple "ping" endpoint that returns a response
  • React frontend that calls the ping endpoint and displays the response
  • Proper error handling for network issues

Activity 2: Todo List API

Build a todo list application with:

  • Express API with endpoints for CRUD operations on todos
  • MongoDB integration to store todos
  • React frontend to display, add, edit, and delete todos

Activity 3: Protected Routes

Extend Activity 2 to include:

  • User authentication with JWT
  • Protected routes on both frontend and backend
  • User-specific todos (each user can only see their own todos)

Activity 4: Deployment Challenge

Deploy your MERN application using:

  • Heroku for full-stack deployment
  • MongoDB Atlas for the database
  • Environment variables for configuration

Summary

Connecting a React frontend to an Express backend is a fundamental skill for full-stack JavaScript development. The MERN stack offers a cohesive development experience with JavaScript throughout the entire application, making it easier to build modern web applications efficiently.

Further Resources