I store the Sanctum token in localStorage. That survives a page reload. React state does not. So on every reload my app knows it might have a session, but it knows nothing about the user — no name for the sidebar, no role for the route guards, nothing.
My fix is boring on purpose: one endpoint, GET /api/auth/me, and one useEffect that runs before anything else.
useEffect(() => {
const stored = localStorage.getItem('saas_token');
if (!stored) {
setLoading(false);
return;
}
client
.get('/auth/me')
.then((res) => {
setUser(res.data.user);
setToken(stored);
})
.catch(() => {
// Token is dead (revoked or forged) — throw it away.
localStorage.removeItem('saas_token');
setUser(null);
setToken(null);
})
.finally(() => setLoading(false));
}, []);
Three decisions hiding in there:
1. No token, no request. If localStorage is empty we skip the network call entirely and drop straight out of the loading state. A logged-out visitor never pays for a request that can only fail.
2. The endpoint returns the same UserResource as login. me() responds with ['user' => new UserResource($request->user())] — the exact same user shape the frontend got at login. There is no second code path for "logged in via reload".
3. A failed restore cleans house. A 401 on /me means the token is revoked or forged. We delete it from localStorage instead of keeping a zombie token that would fail every subsequent request and confuse the 401 interceptor. The user lands on the login page with a clean slate, not a broken session.
The loading flag matters more than it looks. My protected route waits on it — a spinner gate I wrote about earlier — so a refresh never flashes the login page for a user who is about to be restored. Restore first, route second.
Small, unglamorous, and every SPA with token auth needs it. This pattern lives in the Laravel + React SaaS Starter Kit I maintain, where the auth context, the /me endpoint and the route guards ship wired together: https://kamranofficial.gumroad.com/l/mhekoig
Top comments (0)