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 (2)
Returning the same UserResource from /me and from login keeps one code path. One thing I would change is the catch block. It removes the token on any error. So a timeout or a 502 during a deploy logs the user out. Clear it only on 401, and show a retry state for other errors. How does the 401 interceptor behave if it fires while /me is still pending?
You're right, that's a real bug in the shape I showed. Nuking the token on any error conflates "the server said no" with "the server said nothing." A 502 mid-deploy or a flaky mobile connection should never log someone out. The fix is exactly what you said: clear only on 401, and let everything else fall through to a retry state. Concretely,
if (err.response?.status === 401)before touching localStorage. Anything without a response object is a network failure, and it keeps the token.Your second question is the sharper one. The /me restore call has to bypass the 401 interceptor, because it is the auth check. If the interceptor sees its 401 first, it clears the token and kicks to login while the restore effect is still waiting on the same request, and you can end up in a loop where the login page's guarded route fires /me again on a dead token. I mark the request with a flag the interceptor respects,
skipAuthRedirect: truein the request config, so the interceptor ignores that one call and the restore effect owns the outcome: 401 clears the token and drops out of loading, anything else keeps the token and renders retry.