You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Next.js中使用localStorage持久化登录状态失效问题排查

登录状态无法持久化(Next.js v12.2.5 + React v18.2.0)

核心问题

基于Next.js和React开发的认证应用,浏览器刷新后登录会话失效。已通过localStorage存储JWT token,但问题依旧。期望登录状态在刷新后保留,仅主动登出时终止。

应用流程

用户通过登录表单调用Spring接口从MySQL获取凭证,登录、条件渲染及路由保护功能正常。登录成功后JWT token已成功存入localStorage(Chrome控制台可查),但刷新后状态丢失。

背景调研

Stack Overflow相关问题多聚焦Cookie方案,提及localStorage的仅建议用useEffect包裹获取逻辑,未解决问题。考虑过useReducer方案,但当前用useEffect实现,可接受重构。怀疑Next.js的SSR特性是问题根源。


解决方案

问题根源分析

  1. 初始化逻辑顺序错误:useState(initialToken)在组件挂载时执行,而initialToken的赋值在useEffect中(组件挂载后才触发),导致初始state始终为空,刷新后无法从localStorage恢复token。
  2. 服务端localStorage访问错误:Next.js SSR阶段localStorage不存在,代码中else分支仍尝试获取token,会触发报错。
  3. admin状态未持久化:admin状态仅存在内存中,未存入localStorage,刷新后直接丢失。
  4. 角色判断逻辑冗余:循环字符串判断角色的写法低效且无意义,可直接用includes判断。

修复步骤

  1. 修正token初始化逻辑:直接在useState中从localStorage取值(需判断window是否存在,避免SSR报错),移除无效的useEffect。
  2. 持久化admin状态:将admin标识存入localStorage,初始化时同步读取。
  3. 优化路由保护逻辑:改用Next.js的useRouter获取当前路径,避免直接操作window.location。
  4. 简化角色判断逻辑:直接从返回数据中提取权限信息判断admin身份。

修改后的代码

auth-context.js

import React, { useState } from 'react';

const AuthContext = React.createContext({
  token: '',
  isAdmin: false,
  isLoggedIn: false,
  login: (token, isAdmin) => {},
  logout: () => {},
});

export const AuthContextProvider = (props) => {
  // 初始化时直接从localStorage取值,兼容SSR环境
  const initialToken = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
  const initialIsAdmin = typeof window !== 'undefined' ? localStorage.getItem('isAdmin') === 'true' : false;

  const [token, setToken] = useState(initialToken);
  const [isAdmin, setIsAdmin] = useState(initialIsAdmin);

  const userIsLoggedIn = !!token;

  const loginHandler = (token, isAdmin) => {
    setToken(token);
    setIsAdmin(isAdmin);
    localStorage.setItem('token', token);
    localStorage.setItem('isAdmin', isAdmin.toString());
  };

  const logoutHandler = () => {
    setToken(null);
    setIsAdmin(false);
    localStorage.removeItem('token');
    localStorage.removeItem('isAdmin');
  };

  const contextValue = {
    token,
    isAdmin,
    isLoggedIn: userIsLoggedIn,
    login: loginHandler,
    logout: logoutHandler,
  };

  return (
    <AuthContext.Provider value={contextValue}>
      {props.children}
    </AuthContext.Provider>
  );
};

export default AuthContext;

ProtectRoute.js

import { useContext } from 'react';
import { useRouter } from 'next/router';
import AuthContext from './auth-context';
import HomePage from './HomePage';
import RestrictedSection from './RestrictedSection';

const ProtectRoute = ({ children }) => {
  const authCtx = useContext(AuthContext);
  const router = useRouter();
  const currentPath = router.pathname;

  if (!authCtx.isLoggedIn) {
    if (currentPath === '/') {
      return <HomePage />;
    } else if (currentPath !== '/auth') {
      return <RestrictedSection />;
    }
  }

  return children;
};

export default ProtectRoute;

AuthForm.js

import { useRef, useState, useContext } from 'react';
import { useRouter } from 'next/router';
import AuthContext from './auth-context';
import classes from './AuthForm.module.css';

const API_LOGIN_URL = '你的登录接口地址';

const AuthForm = () => {
  const emailInputRef = useRef();
  const passwordInputRef = useRef();
  const [isLoading, setIsLoading] = useState(false);
  const router = useRouter();
  const authCtx = useContext(AuthContext);

  const submitHandler = (event) => {
    event.preventDefault();
    setIsLoading(true);

    const enteredEmail = emailInputRef.current.value;
    const enteredPassword = passwordInputRef.current.value;

    const myHeaders = new Headers();
    myHeaders.append("Content-Type", "application/x-www-form-urlencoded");
    
    const urlencoded = new URLSearchParams();
    urlencoded.append("username", enteredEmail);
    urlencoded.append("password", enteredPassword);

    const requestOptions = {
      method: 'POST',
      headers: myHeaders,
      body: urlencoded,
      redirect: 'follow'
    };

    fetch(API_LOGIN_URL, requestOptions)
      .then(async (res) => {
        setIsLoading(false);
        if (res.ok) {
          return res.json();
        } else {
          const data = await res.json();
          throw new Error('Authentication failed!');
        }
      })
      .then((data) => {
        // 直接从返回数据中判断admin角色
        const isAdmin = data.authorities?.includes('ROLE_SUPER_ADMIN');
        authCtx.login(data.access_token, isAdmin);
        router.replace('/');
      })
      .catch((err) => {
        alert(err.message);
        setIsLoading(false);
      });
  };

  return (
    <section className={classes.auth}>
      <h1>Login</h1>
      <form onSubmit={submitHandler}>
        <div className={classes.control}>
          <label htmlFor='email'>Your Email</label>
          <input type='email' id='email' required ref={emailInputRef} />
        </div>
        <div className={classes.control}>
          <label htmlFor='password'>Your Password</label>
          <input type='password' id='password' required ref={passwordInputRef} />
        </div>
        <div className={classes.actions}>
          {!isLoading && <button>Login</button>}
          {isLoading && <p>Sending request</p>}
        </div>
      </form>
    </section>
  );
};

export default AuthForm;

内容的提问来源于stack exchange,提问作者jay tai

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.18 18:55:29