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

