The One Missing Line That Breaks React Router Login Redirects
React Router Route Guards: Why Can't Users Return to the Original Page After Logging In?
Opening: A Scene That Confuses Users
You've built a page /pay that requires login. A user eagerly clicks in, and bang—they're bounced to the login page. They enter their username and password, log in successfully, and the page jumps back to the homepage.
The user is stunned: I clearly wanted to see /pay, why am I back on the homepage?
I've seen this bug too many times. The routing isn't broken; you forgot to tell the login page "where the user came from."
This article solves that problem.
1. What Are Route Guards (Protected Routes)
Route guard = Before the route actually renders the target page, first check "do you have permission to view it." If not, redirect to the login page.
Think of it as a neighborhood gate: you want to enter the /pay building, the security guard (ProtectRoute) first checks your access card (isLogin in localStorage). No card? The guard sends you to the guardhouse (/login) to register. After registering, you're let into the building you originally wanted to go to.
The key isn't "blocking," but being able to send them back to the original destination after blocking—this is precisely the step most people miss.
2. Writing the Simplest Guard First
The skeleton has only three steps: read login state → if not logged in, <Navigate> away → if logged in, render children normally.
// protectRoute.jsx
import { Navigate } from 'react-router-dom';
const ProtectRoute = ({ children }) => {
// 🔑 Simulate login state with localStorage: the retrieved value is a string, must === 'true' to be valid
const isLogin = localStorage.getItem('isLogin') === 'true';
if (!isLogin) {
// ⚠️ If you only write <Navigate to="/login" /> here, you can't return to the original page after login
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
}
return <>{children}</>;
};
export default ProtectRoute;
In the route table, "wrap" it around the protected pages:
// App.jsx route configuration snippet
<Route
path="/pay"
element={
// 🔑 ProtectRoute itself is not a page, it's a "shell"
<ProtectRoute>
<Pay />
</ProtectRoute>
}
/>
<Pay />is thechildrenofProtectRoute, rendered only if the check passes. If the check fails, what renders is directly<Navigate>, and the page jumps away instantly.
3. Core: How to Remember "Where the User Came From"
This is the focus of the entire article, and the part 90% of people get wrong.
❌ Common incorrect approach: Block without recording
// Block without recording—after login, you have no idea where the user originally wanted to go
if (!isLogin) {
return <Navigate to="/login" />; // No source information carried at all
}
After a successful login on the login page, you can usually only hardcode navigate('/'), so the user always ends up back on the homepage.
✅ Correct approach: Use location's state to "smuggle" the source page over
// protectRoute.jsx (corrected version, safer to use useLocation)
import { Navigate, useLocation } from 'react-router-dom';
const ProtectRoute = ({ children }) => {
const location = useLocation(); // 🔑 Get "current route" info, not the global window.location
const isLogin = localStorage.getItem('isLogin') === 'true';
if (!isLogin) {
// 🔑 state.from hides /pay in the navigation, carrying it to the login page
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
}
return <>{children}</>;
};
The difference: the incorrect approach loses "where the user came from"; the correct approach conveniently tucks a
state.frominto the navigation. The login page can then read it out.
4. How the Login Page Reads Back This Source
The login page uses useLocation() to extract state.from, and after a successful login, navigates back to the original location instead of the homepage.
// Login/index.jsx
import { useNavigate, useLocation } from 'react-router-dom';
const Login = () => {
const navigate = useNavigate();
const location = useLocation();
// 🔑 Optional chaining: when user directly visits /login, state is empty, fallback to homepage '/'
const from = location.state?.from || '/';
function handleSubmit(e) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const username = formData.get('username');
const password = formData.get('password');
if (username === 'admin' && password === '123456') {
localStorage.setItem('isLogin', 'true');
// 🔑 replace: true makes /login disappear from history, so going back doesn't return to the login page
navigate(from, { replace: true });
}
}
return (
<form onSubmit={handleSubmit}>
<h1>Login</h1>
<input name="username" placeholder="Please enter username" />
<input name="password" placeholder="Please enter password" required />
<button type="submit">Login</button>
</form>
);
};
export default Login;
5. Why You Must Use replace
This is a detail easily overlooked but with a huge difference in experience. The changes in the browser's history stack across the entire chain are:
User visits /pay → History stack: [ /pay ]
Guard blocks → History stack: [ /login ] (replace kicks /pay out)
Login success navigates back → History stack: [ /pay ] (replace kicks /login out)
After two replace calls, the history stack is cleanly left with only /pay. When the user presses the browser's "back" button, they return to the page before entering /pay, not falling back into the login page.
Without replace, after logging in, pressing back shows the login page again—the user is baffled.
6. A Real Pitfall: Don't Use the Global location
Looking back at the first version of protectRoute.jsx, it wrote location.pathname instead of useLocation().pathname.
That location is the browser's global object window.location. It happens to work—because ProtectRoute only mounts when /pay is hit, and at that moment window.location.pathname indeed equals /pay.
But it's fragile:
- It's not React Router's "current route," but the real URL. Once the guard is reused and the component doesn't unmount with the route, it might read the wrong value;
- Team members reading the code will be confused: "Where did this
locationcome from?"
Conclusion: Uniformly use useLocation() inside the guard, consistent with the login page. This is the only writing style in the whole article you need to actively change.
7. A Diagram of the Complete Flow
sequenceDiagram
participant U as "User"
participant P as "ProtectRoute Guard"
participant L as "Login Page"
U->>P: Directly visits /pay
P->>P: Checks localStorage, finds not logged in
P->>L: Navigates to /login with state.from=/pay
L->>L: useLocation reads state.from
U->>L: Fills in credentials and submits
L->>L: Login successful, writes isLogin
L->>U: navigate(from, replace) jumps back to /pay
The most critical part of the entire chain isn't "blocking," but the handoff of
state.from—it quietly passes "where the user originally wanted to go" to the login page during the navigation.
Ending: Remember One Thing
The real difficulty of route guards has never been "blocking," but "sending them back to the original destination after blocking."
Next time you write <Navigate to="/login" />, pause for a second and ask yourself: Does the login page know where the user came from?
One behavioral change: When writing protected routes in the future, default to writing useLocation and state.from together. Don't wait for users to complain "I can't return to the original page" before patching it.
Open question: In your project's route guards, are they placed at the route configuration layer (wrapping a component like in this article), or encapsulated as a Higher-Order Component / Hook? Which of the two approaches do you prefer? Let's discuss in the comments.