Architecture Overview
A React + Node.js full-stack application has two distinct parts:
Frontend (React) Backend (Node.js/Express)
───────────────────── ─────────────────────────
Browser renders UI Handles business logic
Makes API calls Talks to databases
Manages client state Manages sessions/tokens
Port 3000 (development) Port 5000 (development)
During development, both run separately. In production, you either:
- Deploy separately — React to a CDN (Vercel/Netlify), Node.js to a server
- Serve React from Express — Express serves the built React app as static files
Project Structure
my-fullstack-app/
├── client/ # React app
│ ├── public/
│ ├── src/
│ │ ├── components/
│ │ ├── pages/
│ │ ├── hooks/
│ │ ├── services/ # API call functions
│ │ │ └── api.js
│ │ ├── context/
│ │ └── App.jsx
│ ├── package.json
│ └── .env
├── server/ # Node.js/Express app
│ ├── controllers/
│ ├── middleware/
│ ├── models/
│ ├── routes/
│ ├── config/
│ ├── server.js
│ ├── package.json
│ └── .env
└── package.json # Root package.json for scripts
Setting Up the Backend
mkdir my-fullstack-app && cd my-fullstack-app
mkdir server && cd server
npm init -y
npm install express mongoose cors dotenv bcryptjs jsonwebtoken
npm install --save-dev nodemon
server/server.js:
const express = require('express');
const mongoose = require('mongoose');
const cors = require('cors');
require('dotenv').config();
const app = express();
// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
// CORS configuration
app.use(cors({
origin: process.env.CLIENT_URL || 'http://localhost:3000',
credentials: true,
}));
// Connect to MongoDB
mongoose.connect(process.env.MONGODB_URI)
.then(() => console.log('MongoDB connected'))
.catch(err => console.error('MongoDB error:', err));
// Routes
app.use('/api/auth', require('./routes/auth'));
app.use('/api/users', require('./routes/users'));
app.use('/api/posts', require('./routes/posts'));
// Error handling middleware
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(err.status || 500).json({
error: process.env.NODE_ENV === 'production' ? 'Server Error' : err.message,
});
});
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
server/.env:
PORT=5000
MONGODB_URI=mongodb://localhost:27017/myapp
JWT_SECRET=your-very-long-random-secret-key
JWT_EXPIRE=7d
CLIENT_URL=http://localhost:3000
NODE_ENV=development
Authentication Routes
// server/routes/auth.js
const router = require('express').Router();
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const User = require('../models/User');
// POST /api/auth/register
router.post('/register', async (req, res) => {
try {
const { name, email, password } = req.body;
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(400).json({ error: 'Email already registered' });
}
const salt = await bcrypt.genSalt(12);
const hashedPassword = await bcrypt.hash(password, salt);
const user = await User.create({ name, email, password: hashedPassword });
const token = jwt.sign({ id: user._id }, process.env.JWT_SECRET, {
expiresIn: process.env.JWT_EXPIRE,
});
res.status(201).json({
token,
user: { id: user._id, name: user.name, email: user.email },
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/auth/login
router.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
const user = await User.findOne({ email }).select('+password');
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) return res.status(401).json({ error: 'Invalid credentials' });
const token = jwt.sign({ id: user._id }, process.env.JWT_SECRET, {
expiresIn: process.env.JWT_EXPIRE,
});
res.json({
token,
user: { id: user._id, name: user.name, email: user.email },
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;
Auth Middleware
// server/middleware/auth.js
const jwt = require('jsonwebtoken');
const User = require('../models/User');
module.exports = async (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = await User.findById(decoded.id).select('-password');
next();
} catch (err) {
res.status(403).json({ error: 'Invalid or expired token' });
}
};
Setting Up the React Frontend
# From the project root
npm create vite@latest client -- --template react
cd client
npm install axios react-router-dom
client/src/services/api.js — centralized API service:
import axios from 'axios';
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:5000/api',
});
// Attach JWT token to every request
api.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// Handle auth errors globally
api.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export const authAPI = {
register: (data) => api.post('/auth/register', data),
login: (data) => api.post('/auth/login', data),
getMe: () => api.get('/auth/me'),
};
export const postsAPI = {
getAll: (params) => api.get('/posts', { params }),
getById: (id) => api.get(`/posts/${id}`),
create: (data) => api.post('/posts', data),
update: (id, data) => api.put(`/posts/${id}`, data),
delete: (id) => api.delete(`/posts/${id}`),
};
export default api;
Auth Context
// client/src/context/AuthContext.jsx
import { createContext, useContext, useState, useEffect } from 'react';
import { authAPI } from '../services/api';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
authAPI.getMe()
.then(res => setUser(res.data.user))
.catch(() => localStorage.removeItem('token'))
.finally(() => setLoading(false));
} else {
setLoading(false);
}
}, []);
const login = async (email, password) => {
const { data } = await authAPI.login({ email, password });
localStorage.setItem('token', data.token);
setUser(data.user);
};
const logout = () => {
localStorage.removeItem('token');
setUser(null);
};
return (
<AuthContext.Provider value={{ user, login, logout, loading }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);
Protected Routes
// client/src/components/ProtectedRoute.jsx
import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
export default function ProtectedRoute({ children }) {
const { user, loading } = useAuth();
if (loading) return <div>Loading...</div>;
return user ? children : <Navigate to="/login" replace />;
}
CORS: Solving the Most Common Development Problem
CORS (Cross-Origin Resource Sharing) errors occur when your React app (localhost:3000) tries to call your API (localhost:5000). The cors middleware on the Express server solves this:
app.use(cors({
origin: 'http://localhost:3000', // Allow only your React app
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true, // Allow cookies and auth headers
}));
In production, set the origin to your deployed frontend URL.
Deployment Strategy
Option 1: Deploy separately (recommended)
- Frontend: Deploy React build to Vercel or Netlify (free, automatic HTTPS)
- Backend: Deploy to Railway, Render, Heroku, or a VPS
Option 2: Serve React from Express
// In server.js, after all API routes:
const path = require('path');
if (process.env.NODE_ENV === 'production') {
app.use(express.static(path.join(__dirname, '../client/dist')));
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '../client/dist', 'index.html'));
});
}
Build React first (npm run build in the client folder), then deploy the entire project as a single Node.js app.