Ever spent an entire afternoon staring at a browser console, wondering why your perfectly working API suddenly refuses to talk to your React app? You're not alone. Thousands of developers hit this exact wall every single day, and the frustrating part is that it's almost never a bug in your code. It's three separate problems pretending to be one.
This guide breaks all three down in plain language, shows you exactly what's going wrong under the hood, and gives you the code to fix it — for good.
What's Actually Going Wrong
When you build a full-stack app with React on the frontend and Node.js with Express on the backend, your two apps usually live on different ports. React might sit on port 3000 while your Express server runs on port 5000. To your laptop, they're on the same machine. To your browser, however, they're on completely different origins.
The browser doesn't care that you own both. It sees two different ports and applies its built-in security rules. That's where CORS comes in.
CORS — or Cross-Origin Resource Sharing — is a system built into every modern browser. Its only job is to decide whether a webpage on one origin is allowed to receive data from another origin. The key word here is receive. The browser actually sends your request just fine. It just refuses to hand you the response back unless the server says it's okay.
And that's why Postman works but your React app doesn't. Postman doesn't have browser security rules. Your React app does.
Part 1: Solving CORS the Right Way
Most quick fixes you'll find online tell you to just throw app.use(cors()) into your server and move on. That works during development, sure. But it opens your API to literally any website on the internet. Not great when you're shipping something real.
Here's how to do it properly.
Setting Up Your .env File
Never put your database details or allowed URLs directly in your code. Create a .env file in your backend folder:
DB_HOST=localhost
DB_USER=your_username
DB_PASSWORD=your_password
DB_NAME=your_database
PORT=5000
FRONTEND_URL=http://localhost:3000
Add .env to your .gitignore right now. If this file ever makes it into a public repository, your database is exposed.
Configuring CORS in Express
// server.js
const express = require('express');
const cors = require('cors');
require('dotenv').config();
const app = express();
const corsOptions = {
origin: process.env.FRONTEND_URL,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
};
app.use(cors(corsOptions));
app.options('*', cors(corsOptions));
app.use(express.json());
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
Two things here that most tutorials skip over.
First, setting credentials: true is necessary whenever your app sends cookies or auth tokens. But once you turn it on, you absolutely cannot use a wildcard (*) as your allowed origin. The browser will reject it. You have to name the exact URL.
Second, that app.options('*', ...) line handles what browsers call preflight requests. Whenever your frontend sends a PUT, DELETE, or any request with custom headers, the browser first sends a quick OPTIONS request to check if it's allowed. If your server doesn't respond to that correctly, the actual request never fires. One line fixes it.
Part 2: Connecting to MySQL Safely
Why Connection Pooling Matters
Here's something that catches a lot of developers off guard. A single MySQL connection can only handle one query at a time. If two users make requests to your API at the exact same moment, the second query has to wait for the first one to finish. Under real traffic, this creates a bottleneck that eventually causes timeouts and crashes.
The solution is a connection pool. Instead of one connection doing all the work, a pool keeps several connections ready. When a query comes in, it borrows one. When the query finishes, it puts it back. Simple, automatic, and built into the mysql2 library.
// db.js
const mysql = require('mysql2');
require('dotenv').config();
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
connectionLimit: 10,
waitForConnections: true,
queueLimit: 0
});
module.exports = pool.promise();
connectionLimit: 10 means up to ten queries can run simultaneously. waitForConnections: true means if all ten are busy, new requests will wait in line instead of crashing. This is the setup you want in production.
Why You Should Use mysql2 Instead of mysql
The older mysql package still works, but mysql2 has native support for Promises, which means you can use async/await directly. It also handles parameterized queries more cleanly, which brings us to the next problem.
Part 3: Stopping SQL Injection
SQL injection is when someone tricks your database into running code they wrote instead of the query you intended. It happens when you build your SQL queries by gluing user input directly into a string.
Here's what that looks like when it goes wrong:
// DON'T DO THIS
const id = req.params.id;
const query = `SELECT * FROM users WHERE id = ${id}`;
If someone sends id = 1 OR 1=1; DROP TABLE users;-- as the URL parameter, your database just runs it. All your data is gone.
The fix is called parameterized queries. Instead of typing user input directly into your SQL string, you use a placeholder — a question mark — and pass the actual value separately:
// DO THIS INSTEAD
const [rows] = await db.query(
'SELECT * FROM users WHERE id = ?',
[req.params.id]
);
The mysql2 driver handles everything else. It escapes the input automatically so the database never interprets it as SQL code. The user's input is always treated as plain data, no matter what they type.
Part 4: Putting It All Together — Your Route Handlers
Now let's wire everything up. Here's a complete set of CRUD routes that use connection pooling and parameterized queries:
// routes/users.js
const express = require('express');
const router = express.Router();
const db = require('../db');
// Get all users
router.get('/', async (req, res) => {
try {
const [users] = await db.query('SELECT id, name, email FROM users');
res.json(users);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Something went wrong' });
}
});
// Get one user
router.get('/:id', async (req, res) => {
try {
const [rows] = await db.query(
'SELECT id, name, email FROM users WHERE id = ?',
[req.params.id]
);
if (rows.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
res.json(rows[0]);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Something went wrong' });
}
});
// Create a user
router.post('/', async (req, res) => {
try {
const { name, email } = req.body;
if (!name || !email || !email.includes('@')) {
return res.status(400).json({ error: 'Valid name and email are required' });
}
const [result] = await db.query(
'INSERT INTO users (name, email) VALUES (?, ?)',
[name, email]
);
res.status(201).json({ message: 'User created', id: result.insertId });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Something went wrong' });
}
});
// Update a user
router.put('/:id', async (req, res) => {
try {
const { name, email } = req.body;
const [result] = await db.query(
'UPDATE users SET name = ?, email = ? WHERE id = ?',
[name, email, req.params.id]
);
if (result.affectedRows === 0) {
return res.status(404).json({ error: 'User not found' });
}
res.json({ message: 'User updated' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Something went wrong' });
}
});
// Delete a user
router.delete('/:id', async (req, res) => {
try {
const [result] = await db.query(
'DELETE FROM users WHERE id = ?',
[req.params.id]
);
if (result.affectedRows === 0) {
return res.status(404).json({ error: 'User not found' });
}
res.json({ message: 'User deleted' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Something went wrong' });
}
});
module.exports = router;
Notice every single query uses the ? placeholder pattern. Not one of them concatenates user input into a string. That's the habit you want to build.
Part 5: The React Side — Keeping It Clean
// App.js
import React, { useState, useEffect } from 'react';
import axios from 'axios';
const API = process.env.REACT_APP_API_URL || 'http://localhost:5000/api';
function App() {
const [users, setUsers] = useState([]);
const [form, setForm] = useState({ name: '', email: '' });
const [error, setError] = useState(null);
const loadUsers = async () => {
try {
const { data } = await axios.get(`${API}/users`);
setUsers(data);
setError(null);
} catch (e) {
setError('Could not load users');
}
};
useEffect(() => { loadUsers(); }, []);
const handleAdd = async (e) => {
e.preventDefault();
try {
await axios.post(`${API}/users`, form);
setForm({ name: '', email: '' });
loadUsers();
} catch (e) {
setError('Could not create user');
}
};
const handleDelete = async (id) => {
try {
await axios.delete(`${API}/users/${id}`);
loadUsers();
} catch (e) {
setError('Could not delete user');
}
};
return (
<div style={{ maxWidth: 500, margin: '60px auto', fontFamily: 'system-ui' }}>
<h1>User Manager</h1>
{error && <p style={{ color: '#e44' }}>{error}</p>}
<form onSubmit={handleAdd}>
<input placeholder="Name" value={form.name}
onChange={e => setForm({ ...form, name: e.target.value })} required />
<input placeholder="Email" type="email" value={form.email}
onChange={e => setForm({ ...form, email: e.target.value })} required />
<button type="submit">Add User</button>
</form>
<ul>
{users.map(u => (
<li key={u.id}>
<strong>{u.name}</strong> — {u.email}
<button onClick={() => handleDelete(u.id)}>Remove</button>
</li>
))}
</ul>
</div>
);
}
export default App;
Keep your API base URL in an environment variable here too. That way, when you deploy, you just change one value and everything points to the right place.
The Three Mistakes That Break Everything
Using an open CORS wildcard in production. Writing app.use(cors()) with no options feels quick and easy. But it tells every website on the internet that they can read responses from your API. That's fine for local testing. It's a security hole the moment you ship.
Building SQL queries with string concatenation. The moment you write something like `WHERE name = '${input}'`, you've handed an attacker the keys to your database. Parameterized queries exist specifically to prevent this, and they're just as easy to use.
Using a single database connection. One connection handles one query at a time. The moment two requests arrive together, things start breaking. Connection pooling is the standard solution, it's built into mysql2, and it takes about five lines of code to set up.
What to Do Next
Once your app is working with this setup, here are the natural next steps to take it further.
Add input validation middleware like express-validator or the zod library so that bad data gets rejected before it ever reaches your database. Layer on JWT authentication so that only logged-in users can hit your protected routes — the credentials: true setting you already configured will support this out of the box. Consider adding rate limiting with something like express-rate-limit to protect your API from being hammered. And when your project grows beyond a handful of routes, look into an ORM like Prisma or Sequelize. They generate parameterized queries automatically and make working with your schema a lot more manageable.
Quick Recap
CORS blocks your frontend because your backend hasn't told the browser it's allowed. Fix it by specifying your exact frontend URL, not a wildcard. SQL injection happens when user input gets mixed directly into your queries. Fix it by always using the ? placeholder pattern. Database slowdowns under traffic happen because a single connection can only do one thing at a time. Fix it by switching to a connection pool.
Three problems. Three straightforward fixes. Once you build these habits into every project, a whole category of frustrating bugs just disappears.
Top comments (1)
Great breakdown 👏
This is one of those posts that saves people hours of “why does it work in Postman but not in the browser?” frustration.
I especially like how you didn’t just say “use cors()” but explained why the browser blocks the response, and tied that together with proper CORS config, pooling, and parameterized queries in one flow. That’s exactly how these issues show up in real projects — as one confusing mess.
The mysql2 + pool +
?placeholders section is gold for beginners who don’t realize how easily SQL injection and connection bottlenecks happen.Very practical, very real-world. 👍