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

NextJS水化失败求助:初始UI与服务端渲染结果不匹配

NextJS 水化失败错误排查方案

错误信息

Unhandled Runtime Error
Error: Hydration failed because the initial UI does not match what was rendered on the server.

现有代码情况

Login 路由组件

'use client';
import dynamic from 'next/dynamic';
// import AuthForm from '@/components/AuthForm';

// const DynamicAuthForm = dynamic(() => import('@/components/AuthForm'), {
//   ssr: false,
// });

const NoSSR = dynamic(() => import('@/components/AuthForm'), { ssr: false });

const LoginPage = () => {
  return (
    <div>
      <NoSSR />;
    </div>
  );
  // return <DynamicAuthForm />;
  // return <AuthForm />;
};

export default LoginPage;

AuthForm.tsx 组件

'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';

import callFetch from '@/utils/fetch/callFetch';

const apiUrl = process.env.NEXT_PUBLIC_API_DEV_LOGIN || '';

if (!apiUrl) {
  throw new Error('API URL is not defined');
}

function AuthForm() {
  const router = useRouter();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [isSubmitting, setIsSubmitting] = useState(false);

  // 表单提交处理函数
  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setIsSubmitting(true);

    const formBody = JSON.stringify({ user: email, password });

    try {
      const response = await callFetch(apiUrl, 'POST', formBody);

      if (!response.ok) {
        setIsSubmitting(false);
        // TODO - 在通知弹窗中处理错误
        throw new Error(
          `登录失败: ${response.status} - ${response.statusText}`
        );
      }

      console.log('登录响应:', response);
      setIsSubmitting(false);

      // 登录成功后跳转到职位列表页
      router.push('/postings');
    } catch (error) {
      console.error('登录错误:', error);
      setIsSubmitting(false);
      // 处理错误,显示错误信息等
    }
  };

  return (
    <>
      <div className="login-container">
        {/* <div className="form"> */}
        <form onSubmit={handleSubmit}>
          Login to
          <Link href="/">
            <h1 className="logo">
              Bounty<strong>Jobs</strong>
            </h1>
          </Link>
          <div>
            <label htmlFor="email">Email</label>
            <input
              type="email"
              id="email"
              value={email}
              onChange={e => setEmail(e.target.value)}
              required
            />
          </div>
          <div>
            <label htmlFor="password">Password:</label>
            <input
              type="password"
              id="password"
              value={password}
              onChange={e => setPassword(e.target.value)}
              required
            />
          </div>
          <div className="actions">
            <button disabled={isSubmitting}>Submit</button>
          </div>
        </form>
        {/* </div> */}
      </div>
    </>
  );
}

export default AuthForm;

排查思路

  1. 修复明显语法错误:Login组件中<NoSSR />后面多了分号,这会导致服务端渲染时输出额外的分号文本,客户端渲染时却不会,直接引发内容不匹配。删掉分号即可。
  2. 检查环境变量一致性:process.env.NEXT_PUBLIC_API_DEV_LOGIN在服务端和客户端是否注入一致?如果服务端未正确注入该变量,AuthForm里的throw new Error会在服务端抛出,导致服务端渲染内容与客户端完全不同。可以先注释该错误抛出逻辑,测试是否仍报错。
  3. 验证文本节点渲染差异:表单里的Login to是裸文本,检查服务端渲染时是否存在额外空格、换行或转义问题,比如服务端输出多了空白字符,客户端却没有。可以用<span>包裹该文本,避免裸文本的渲染差异。
  4. 排查客户端专属API调用:检查callFetch工具是否用到了window等客户端专属API,这类代码在服务端渲染时会触发错误,导致渲染内容异常。确保工具函数能兼容服务端环境,或在组件挂载后再执行相关逻辑。
  5. 调整禁用SSR的方式:Login组件本身已标记'use client',可尝试直接导入AuthForm使用,无需dynamic。若仍需禁用SSR,确认dynamic配置无拼写错误。
  6. 替换React.Fragment测试:AuthForm使用<>(React.Fragment)包裹内容,部分场景下服务端与客户端对Fragment的渲染可能存在差异,换成<div>包裹整个返回内容,测试是否解决问题。
  7. 对比DOM结构差异:打开浏览器开发者工具,查看页面源代码(服务端渲染的原始HTML)和客户端渲染后的DOM,逐节点对比找到第一个不匹配的位置,这是定位问题的核心方法。

内容的提问来源于stack exchange,提问作者Leon Gaban

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.27 22:23:30