这两年做内部管理系统,我几乎每个项目都要重新捋一遍“用户怎么登录、登录态怎么保持、哪些页面需要什么角色才能进”这件事。起初图省事,试过 Firebase Auth,可业务表都在 MySQL 里,认证数据在一个外部服务上,两套数据源来回查,非常别扭。后来又试过自己写 Session,结果 Cookie 加解密、续期、CSRF、退出失效,全得自己维护,代码越写越多,心里越来越没底。直到把 Next.js 15 和 NextAuth.js 组合起来,才找到一套比较顺手的方案:MySQL 存用户,NextAuth.js 生成会话并下发 Cookie,前端基本不用碰 Cookie/Session 的细节。这篇文章我会把完整过程过一遍,从选型、建表、配置,到登录、路由守卫、接口保护,最后是部署时容易踩的坑,适合正在用 Next.js 15 搭应用、需要一套完整登录鉴权方案的开发者参考。
1. 为什么要折腾这套组合:先想清楚认证需求再动手
1.1 一套内部系统最常见的认证需求
先别急着敲命令,我把这类项目的认证需求盘一下。大多数后台管理、内部工具、SaaS 产品,认证部分逃不开这几件事:
- 用户能用邮箱密码注册、登录
- 登录状态要能保持一段时间,刷新页面不丢
- 某些页面(比如管理后台)只有特定角色能访问
- 后端接口不能裸奔,请求必须能识别当前用户
- 用户能主动退出
这些需求听起来简单,但如果自己从零实现,每一件都要花不少功夫。尤其是 Session 的“服务端状态”和自己下发 Cookie 的“客户端载体”怎么配合,很容易在这种小项目里写成一团乱麻。
1.2 为什么把 NextAuth.js 放进技术栈
这套项目里最核心的选型是 NextAuth.js。我之前看过很多人纠结:自己写 Session 还是用第三方认证服务。我的结论是:绝不建议自己写完整会话体系,也别轻易引入外部用户体系。
自写 Session 的问题在于,账面上的“登录”只是冰山一角。Session 怎么存、过期时间怎么续、Cookie 要不要 httpOnly、CSRF 怎么防、退出后服务端 session 和客户端 cookie 怎么同步失效,这些问题一旦上线遇到真实用户,每个都是潜在的坑。NextAuth.js 把这些收敛成配置项,同时不绑架你的数据层和业务逻辑。
相比之下,Firebase Auth、Supabase Auth、Clerk 这类服务解决得也很好,但它们会引入外部依赖。如果业务数据已经在 MySQL 里,再让用户数据散落到外部认证服务,后续做关联查询、用户画像分析会非常难受。NextAuth.js 是 Next.js 生态的事实标准认证库,支持 Credentials 登录方式,认证逻辑完全由自己掌控,数据表也留在 MySQL 里,这才是它最大的价值。
1.3 为什么是 Next.js 15:App Router 带来的认证范式变化
Next.js 15 把cookies()、headers()、searchParams等都变成了异步 API,刚升级时确实会踩坑,但适应之后会发现,这套模型对认证是有利的:服务端组件可以直接await auth()拿 session,不需要客户端状态同步;middleware 可以在请求进入路由前统一做登录态校验;API Route 里也能随意读取 session 做接口鉴权。认证逻辑从“登录时发一次令牌”变成“每层都能基于会话信息做决策”,安全性和可维护性都提升一个档次。
顺带说明一下版本:NextAuth.js 目前稳定版是 v4,但 App Router 全面支持的 v5 直接称为 beta。我们项目直接用了 v5,下面所有配置都以 v5 为准。如果你的项目还在用 v4 配 App Router,我建议认真考虑升上来,v4 在 App Router 下的配置缝缝补补,体验差不少。
2. 从零初始化项目:依赖版本与目录结构
2.1 版本要求和项目创建命令
Next.js 15 要求 Node.js 18.18 以上,建议直接用 Node 20 LTS 或 22 LTS。创建项目我用最朴素的create-next-app,交互式命令行里选择 TypeScript、App Router、Tailwind(按需)、ESLint 就够用:
npx create-next-app@latest next-auth-mysql cd next-auth-mysql装依赖时注意版本。我项目里的关键依赖如下表:
| 包名 | 版本 | 用途 |
|---|---|---|
| next | 15.x | React 框架,App Router 模式 |
| next-auth | 5.0.0-beta.x | 认证与 Session 管理 |
| mysql2 | 3.x | 连接 MySQL 的驱动,支持 Promise |
| bcryptjs | 2.4.x | 密码哈希,纯 JS 实现,不需要 node-gyp 编译 |
安装命令:
npm install next-auth@beta mysql2 bcryptjs这里有个经验:bcrypt原生模块在部分 Linux 环境需要编译工具链,折腾起来费时间,而bcryptjs是纯 JS,安全性同级别,对中小项目完全够用。如果你后面要部署到 Serverless 环境,bcryptjs的兼容性也会好一些。
2.2 目录结构:认证文件怎么摆才不乱
项目初始化后,我会先把认证相关文件的位置规划好。下面是这套项目最终使用的目录结构:
. ├── app/ │ ├── api/ │ │ ├── auth/[...nextauth]/route.ts │ │ └── register/route.ts │ ├── login/page.tsx │ ├── dashboard/page.tsx │ ├── admin/page.tsx │ └── layout.tsx ├── lib/ │ ├── db.ts │ └── auth.ts ├── types/ │ └── next-auth.d.ts ├── auth.config.ts └── middleware.ts这个结构背后有个重要原因:NextAuth v5 的 middleware 跑在 Edge Runtime,而 Credentials Provider 的authorize函数需要查 MySQL,MySQL 驱动在 Edge Runtime 跑不了。所以官方推荐的拆分方式是把不依赖数据库的配置放在auth.config.ts,把数据库相关的 provider 放在lib/auth.ts,middleware 只引用前者,服务端组件和 API 路由引用后者。一开始没做这个拆分的话,middleware 一启动就报错,后面我在踩坑章节还会详细说。
2.3 MySQL 连接池:不要用 createConnection 硬连
数据库连接我用mysql2/promise的createPool,连接池是必须的。HTTP 场景下每个请求都可能查库,如果每次都新建连接,MySQL 会很快打满,而且握手流程开销很大。连接池复用连接,参数调优后效果明显。
// lib/db.ts import mysql from 'mysql2/promise' const pool = mysql.createPool({ host: process.env.MYSQL_HOST || 'localhost', port: Number(process.env.MYSQL_PORT || 3306), user: process.env.MYSQL_USER, password: process.env.MYSQL_PASSWORD, database: process.env.MYSQL_DATABASE, waitForConnections: true, connectionLimit: 10, queueLimit: 0, enableKeepAlive: true }) export default pool参数解释一下:waitForConnections表示连接池满时新请求排队等待而不是直接报错;connectionLimit小型应用设 10 就够;enableKeepAlive保持长连接,避免 MySQL 默认的wait_timeout回收连接后,池里突然蹦出一堆失效连接。如果跑在 Serverless 环境,连接数建议压到 1 或 2,因为实例会并发启动,每个实例都开 10 个连接很容易压垮数据库。这就是“为什么这么选型”的典型例子:同一个配置在不同的运行环境下要跟着调。
环境变量放在.env.local:
MYSQL_HOST=127.0.0.1 MYSQL_PORT=3306 MYSQL_USER=root MYSQL_PASSWORD=yourpassword MYSQL_DATABASE=myapp NEXTAUTH_SECRET=请生成一长串随机值NEXTAUTH_SECRET是 NextAuth 签名 session cookie 的关键密钥。开发环境下不配置也能跑,但那是因为 NextAuth 每次重启自动生成临时密钥,后果是每次npm run dev重启后所有旧登录状态全部失效。生产环境务必自己设置,生成方式可以用:
openssl rand -base64 323. 用户表设计与密码存储:直接决定后续安全下限
3.1 建表:字段别乱来,这些细节很关键
用户表的设计看起来简单,但有几个选择影响长期使用。先看完整的建表 SQL:
CREATE DATABASE IF NOT EXISTS myapp CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE myapp; CREATE TABLE users ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, email VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL, name VARCHAR(100) NOT NULL DEFAULT '', role VARCHAR(20) NOT NULL DEFAULT 'user', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_users_email (email) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有几个容易被忽略的细节:
id用BIGINT UNSIGNED:虽然现在用户量少,但INT上限 21 亿,真心不需要为这个赌未来。MySQL 8 的 AUTO_INCREMENT 不会重用,用大类型省心。role用VARCHAR(20)而不是ENUM('user','admin'):ENUM 在业务扩展时修改枚举值非常痛苦,要执行 ALTER 语句改表结构,VARCHAR 加个判断条件就行。password_hash定 255:bcrypt 输出固定 60 字符,但未来可能换更强的哈希算法(比如 scrypt、argon2),输出更长,VARCHAR(255) 余量足够。- 唯一索引放
email:登录时用邮箱查用户,这步要快,也杜绝了重复注册。
3.2 密码哈希:为什么一定用 bcrypt
密码绝不能明文存。常见的哈希算法 MD5、SHA-1、SHA-256 直接查彩虹表就能撞出来,几块钱就能买到的 GPU 几分钟跑完小字典。bcrypt 的设计目的是“慢”,通过可调节的工作因子让每次哈希计算消耗固定CPU时间,暴力破解成本指数级上升。
bcryptjs用法很简单:
import bcrypt from 'bcryptjs' // 注册时:哈希并存储 const hash = await bcrypt.hash(password, 10) // 登录时:比对 const isMatch = await bcrypt.compare(password, hash)工作因子 10 是性价比比较高的默认值,大概在几十到一两百毫秒,对用户体验无感,对暴力破解已是很大障碍。不建议低于 8,也不建议超过 12,否则高并发登录时服务器 CPU 会先扛不住。
3.3 注册接口:防重复、防注入、防弱密码
注册逻辑我放在一个独立的 API Route 里。边写边强调几个安全点:参数校验必须在服务端做;SQL 必须用预处理参数;邮箱唯一性要查库确认。
// app/api/register/route.ts import { NextResponse } from 'next/server' import bcrypt from 'bcryptjs' import pool from '@/lib/db' export async function POST(req: Request) { const { email, password, name } = await req.json() if (!email || !password || password.length < 6) { return NextResponse.json({ error: '邮箱或密码格式不正确' }, { status: 400 }) } const [rows] = await pool.execute( 'SELECT id FROM users WHERE email = ?', [email] ) if ((rows as any[]).length > 0) { return NextResponse.json({ error: '该邮箱已被注册' }, { status: 409 }) } const passwordHash = await bcrypt.hash(password, 10) await pool.execute( 'INSERT INTO users (email, password_hash, name) VALUES (?, ?, ?)', [email, passwordHash, name || ''] ) return NextResponse.json({ ok: true }, { status: 201 }) }注意我用的是pool.execute,这会走 MySQL 预处理语句,参数值不会被拼进 SQL,能挡住 SQL 注入。查询和插入都用问号占位符,不要用模板字符串拼接,这是对抗注入的基本纪律。
4. NextAuth.js 配置:接受“配置多但边界清楚”的事实
4.1 拆两个文件:一个给 Edge,一个给 Node
前面目录结构里我刻意安排了auth.config.ts和lib/auth.ts。这是 NextAuth v5 在 Next.js 15 下的推荐写法,核心原因是运行环境不同。
auth.config.ts放不依赖数据库的配置,middleware 可以直接引用:
// auth.config.ts import type { NextAuthConfig } from 'next-auth' export const authConfig = { pages: { signIn: '/login' }, session: { strategy: 'jwt' }, callbacks: { authorized({ auth, request: { nextUrl } }) { const isLoggedIn = !!auth?.user const isOnProtectedPage = nextUrl.pathname.startsWith('/dashboard') if (isOnProtectedPage) { return isLoggedIn } return true } } } satisfies NextAuthConfiglib/auth.ts是完整配置,合并了 Credentials Provider:
// lib/auth.ts import NextAuth from 'next-auth' import Credentials from 'next-auth/providers/credentials' import bcrypt from 'bcryptjs' import pool from '@/lib/db' import { authConfig } from '@/auth.config' export const { handlers, auth, signIn, signOut } = NextAuth({ ...authConfig, providers: [ Credentials({ name: 'credentials', credentials: { email: { label: '邮箱', type: 'email' }, password: { label: '密码', type: 'password' } }, async authorize(credentials) { if (!credentials?.email || !credentials?.password) return null const [rows] = await pool.execute( 'SELECT id, email, password_hash, name, role FROM users WHERE email = ?', [credentials.email] ) const user = (rows as any[])[0] if (!user) return null const isMatch = await bcrypt.compare(credentials.password, user.password_hash) if (!isMatch) return null return { id: String(user.id), email: user.email, name: user.name, role: user.role } } }) ], callbacks: { async jwt({ token, user }) { if (user) { token.id = user.id token.role = (user as { role: string }).role } return token }, async session({ session, token }) { if (session.user) { session.user.id = token.id session.user.role = token.role } return session } } })然后app/api/auth/[...nextauth]/route.ts只做一件事:
import { handlers } from '@/lib/auth' export const { GET, POST } = handlers如果项目里注册后想自动登录,可以在注册成功后直接跳登录页;想省这一步的话,注册完调用signIn('credentials', { redirect: false, email, password })也行,多一次密码校验并不亏。
4.2 Cookie 和 Session:先说清概念,否则后面全是糊涂账
标题里的“Cookie/Session 鉴权”有必要展开讲。很多人把这两个词混在一起,其实它们是不同层次的东西:
- Cookie 是浏览器端的存储载体,由服务器通过
Set-Cookie响应头下发。浏览器后续每个请求会自动带上同一站点的 Cookie。 - Session 是“会话状态”。在 NextAuth 的 JWT 策略下,会话状态被编码进一个 JWT,存进 Cookie 里,服务端不额外保存任何会话快照;在 database 策略下,会话是一条数据库记录,Cookie 里只放一个随机的 sessionToken,服务端拿 token 去查表。
所以无论哪种策略,最终都是“经由 Cookie 承载会话标识”。你看到的Set-Cookie、httpOnly、SameSite、Max-Age这些属性,都是这套鉴权机制的地基。
明白这个再看 NextAuth 的session.strategy就非常清晰了。JWT 和 database 的取舍如下表:
| 维度 | JWT 策略 | Database 策略 |
|---|---|---|
| 会话存储 | Cookie 内的 JWT | 数据库 sessions 表 |
| 每次请求查库 | 不需要 | 需要 |
| 服务端主动踢人 | 做不到 | 可以删 session 记录 |
| 横向扩展 | 无需共享 session 存储 | 需要共享数据库 |
| 适合场景 | API为主、微服务、Serverless | 单体应用、后台管理、需强制下线 |
我项目走的是strategy: 'jwt',主要原因是查库次数少、部署简单,而且后台系统对“踢人下线”这种需求不敏感。但坦白说,JWT 有一个容易被误解的点:JWT 内容是 Base64 编码的,并不是默认加密,只是带签名防篡改。所以千万不要把密码、手机号这些敏感字段塞进 token,放id、role、name这类不敏感信息就够了。
4.3 中间件、Cookie 属性和类型扩展:这些也得配到位
中间件文件非常简单,但它是整个路由守卫的第一道门:
// middleware.ts import NextAuth from 'next-auth' import { authConfig } from '@/auth.config' export default NextAuth(authConfig).auth export const config = { matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'] }matcher用正则排除掉api路径,非常重要:如果不排除,NextAuth 自己的api/auth/*也会被中间件拦下来,轻则报错,重则死循环。_next/static和_next/image是 Next.js 内部资源和图片接口,同样要排除。
关于 Cookie 属性,NextAuth 的默认值已经够安全:session cookie 默认httpOnly(JS 读不到,防 XSS 窃取)、sameSite: 'lax'(防 CSRF)、生产环境 HTTPS 下自动加secure。这些默认值不需要改,也不需要去理解“为什么自己写的 session 各种坑”,因为库已经在底层处理掉了。
TypeScript 类型扩展是很多人漏掉的一步。因为默认的session.user只有name、email、image,要加id和role,必须声明模块扩展:
// types/next-auth.d.ts import { DefaultSession } from 'next-auth' declare module 'next-auth' { interface Session { user: { id: string role: string } & DefaultSession['user'] } interface User { role: string } } declare module 'next-auth/jwt' { interface JWT { id: string role: string } }如果不做这步,后面在中间件和页面上访问session.user.id都会得到类型报错。这是我每次都能在别人项目里看到的问题。
5. 登录、路由守卫和接口鉴权:把鉴权落实到每层
5.1 登录页:客户端调用 signIn,别自己拼请求
登录页用客户端组件。核心函数是 NextAuth 的signIn,不要自己写 fetch 调接口,因为signIn内部处理了 CSRF、回调 URL 和错误码,自己拼请求容易踩坑。
'use client' import { useState } from 'react' import { signIn } from 'next-auth/react' import { useRouter } from 'next/navigation' export default function LoginPage() { const router = useRouter() const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [error, setError] = useState('') const [loading, setLoading] = useState(false) async function handleSubmit(e: React.FormEvent) { e.preventDefault() setLoading(true) setError('') const result = await signIn('credentials', { email, password, redirect: false }) if (result?.error) { setError('邮箱或密码错误') setLoading(false) return } router.push('/dashboard') router.refresh() } return ( <form onSubmit={handleSubmit}> <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="邮箱" required /> <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="密码" required /> {error && <p style={{ color: 'red' }}>{error}</p>} <button type="submit" disabled={loading}> {loading ? '登录中...' : '登录'} </button> </form> ) }登录成功后router.refresh()很关键:Next.js 客户端导航不会自动刷新服务端组件,如果不 refresh,跳转到 dashboard 后服务端组件可能拿不到最新的 session,导致刚登录完页面却显示未登录。
5.2 服务端组件:await auth() 是日常操作
受保护页面的服务端组件里,直接用auth():
// app/dashboard/page.tsx import { auth } from '@/lib/auth' import { redirect } from 'next/navigation' export default async function DashboardPage() { const session = await auth() if (!session?.user) { redirect('/login') } return ( <div> <h1>欢迎回来</h1> <p>当前用户:{session.user.name}({session.user.email})</p> <p>角色:{session.user.role}</p> </div> ) }这里有个值得说的点:为什么中间件拦了还不够,页面里还要再auth()一遍?因为中间件只是第一道防线,它做的是粗粒度“是否登录”的检查;服务端组件里查 session 是第二道防线,可以拿到完整的用户信息,做细粒度的页面级权限控制。两层都做,不是重复劳动,而是“边界检查 + 数据渲染”的合理分层。
5.3 中间件做角色控制:admin 页面怎么拦
角色控制可以放在中间件里。比如/admin前缀的页面只允许admin角色访问。这时候不能再用“直接导出已封装中间件”的方式,改成一个函数:
// middleware.ts import NextAuth from 'next-auth' import { NextResponse } from 'next/server' import { authConfig } from '@/auth.config' const { auth } = NextAuth(authConfig) export default auth((req) => { const { nextUrl } = req const isLoggedIn = !!req.auth const pathname = nextUrl.pathname if (pathname === '/login') { if (isLoggedIn) { return NextResponse.redirect(new URL('/dashboard', nextUrl)) } return NextResponse.next() } if (pathname.startsWith('/admin')) { if (!isLoggedIn) { return NextResponse.redirect(new URL('/login', nextUrl)) } if (req.auth?.user.role !== 'admin') { return NextResponse.redirect(new URL('/403', nextUrl)) } } if (!isLoggedIn) { return NextResponse.redirect(new URL('/login', nextUrl)) } return NextResponse.next() }) export const config = { matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'] }req.auth是中间件里 NextAuth 自动解出来的 JWT 内容,包含我们在jwt回调里塞进去的role,所以这里判断角色不需要再查数据库。这也是 JWT 策略的一个额外红利:中间件是基于签名验证和 Cookie 解析的,走了 Edge Runtime 也没有数据库负担。
5.4 API 接口保护:别让接口裸奔
前端页面能拦截,但后端 API 必须自己守卫。因为任何客户端都可能绕过页面直接调接口。NextAuth v5 的auth()可以直接在 API Route 里用:
// app/api/profile/route.ts import { NextRequest, NextResponse } from 'next/server' import { auth } from '@/lib/auth' export async function GET(req: NextRequest) { const session = await auth() if (!session?.user) { return NextResponse.json({ error: '未登录' }, { status: 401 }) } return NextResponse.json({ user: { id: session.user.id, email: session.user.email, name: session.user.name, role: session.user.role } }) }同样的模式可以扩展到任何需要用户身份的业务接口,比如读自己的订单、改自己的信息,都在查库之前先校验 session。接口里还要特别注意:不能只用session.user.email当用户身份锚点,应该用session.user.id,因为邮箱虽然唯一但理论上可变更。
5.5 退出登录:服务端和客户端各有一种姿势
客户端退出:
import { signOut } from 'next-auth/react' await signOut({ callbackUrl: '/login' })服务端 Action 里退出(如果要用 Server Actions 做):
import { signOut } from '@/lib/auth' export async function handleSignOut() { await signOut() }注意 import 路径:客户端从next-auth/react引,服务端从我们封装的@/lib/auth引。路径写错,比如在服务端组件里用next-auth/react的signOut,会直接报错。这个坑我见了太多次了。
6. 实战踩坑记录:这些坑不排掉,照着敲也跑不通
6.1 NextAuth v4 在 App Router 里的别扭体验
一开始我用的是 NextAuth v4,因为在 npm 上它是 stable。App Router 下跑起来问题不少:v4 的getServerSession在 app 目录里用起来很绕,middleware 的封装方式跟 v5 完全不同,还有很多针对 Pages Router 的默认行为。后来我把依赖切成next-auth@beta,世界清净了。
我的建议很直接:只要你是 Next.js 15 + App Router,直接用next-auth@beta。v5 虽然还挂着 beta 标签,但 API 已经非常稳定,官方文档也是按 v5 写的。v4 的文档在 App Router 场景下已经过时。
6.2 Edge Runtime 与 MySQL 的“地域隔离”式报错
这个坑我在 2.2 节埋了伏笔。刚开始我把 Credentials Provider 的authorize直接写进了auth.config.ts,middleware 一启动就报类似 “node-mysql2 is not compatible with Edge Runtime” 的错误。原因就是next-auth的 middleware 默认跑在 Edge Runtime,而mysql2依赖 Node 的 TCP 网络栈,Edge 环境没有。
解决办法就是拆文件。auth.config.ts只放pages、session、callbacks这些跟环境无关的配置,lib/auth.ts里再合并完整 provider。middleware 引用auth.config.ts,服务端页面和 API 引用lib/auth.ts。这个拆分一做好,Edge 和 Node 各司其职,报错直接消失。
6.3 bcrypt 在 Edge 环境的另一个隐藏问题
即使你把 middleware 切到auth.config.ts,还有一个小坑:很多人习惯在扩展authorized回调里做密码以外的逻辑判断,如果在auth.config.ts里 importbcryptjs或mysql2,还是一样炸。所以记住一条纪律:任何与数据库、密码哈希、文件系统相关的代码都不要放进auth.config.ts,它能且只能放纯逻辑和配置。
6.4 NEXTAUTH_SECRET 缺失导致“每次重启都要重新登录”
本地开发时如果没配NEXTAUTH_SECRET,NextAuth 会用临时密钥签名 cookie。next dev重启后临时密钥变了,之前签的所有 cookie 全部失效,表现为“每次改完代码都要重新登录”。第一次遇到还挺懵的,以为是代码写错了。
解决方案就是提前在.env.local里配好NEXTAUTH_SECRET,一劳永逸。生产环境更要重视,密钥泄漏等于能伪造任意用户会话。
6.5 MySQL 连接不上的常见原因
本地 MySQL 连接失败,我排过几类问题:
bind-address没监听0.0.0.0:另一个容器或远程机器连不上- 用户权限范围不对:
root@'127.0.0.1'不等于root@'localhost',用 TCP 连接时要给root或新用户加上合适的 host - MySQL 8 默认
caching_sha2_password认证插件:mysql2 是支持的,但如果你的驱动太老,可能报authSwitchRequest错误,升级 mysql2 到 3.x 解决 - 服务器要求 SSL 连接:mysqld 配置了
require_secure_transport时,连接池里要加ssl: { rejectUnauthorized: false }或配置证书
排查顺序建议:先mysql -h 127.0.0.1 -P 3306 -u username -p用命令行确认连通性,再去看应用层连接参数。命令行能连而应用连不上,大概率是参数问题。
6.6 部署到 Vercel 时的数据库选型
如果最终部署目标是 Vercel,它没有常驻本地 MySQL,需要外部数据库。几个可行方案:云厂商的 RDS 类服务、TiDB Cloud 的 Serverless 层、PlanetScale,或者自己租一台机器跑 Docker MySQL。Serverless 环境注意把连接池connectionLimit调低,否则冷启动并发实例会瞬间打爆连接数。
我最终用的是一台云 MySQL 实例,连接串走环境变量,应用代码零改动直接部署。这一点也正好体现了这套方案“不锁定厂商”的价值:换数据库只改环境变量,认证逻辑完全不变。
7. 最后再分享一点个人经验
整套组合跑通之后,最明显的感觉是“鉴权这件事终于不占脑子了”。登录、Cookie、Session 校验这些重复劳动全部交给 NextAuth 管,我只需要关心业务规则:哪个页面要哪种角色、哪个接口要校验什么身份。Next.js 15 的异步 API 一开始让人别扭,但配合await auth()的读取方式,反而比老的同步模式更符合服务端组件的直觉。
如果项目刚起步,我强烈建议直接上 Next.js 15 + NextAuth v5 这个组合,别再用 Pages Router 加手写 Session 的方式做新项目了。认证这块,auth.config.ts加middleware.ts加lib/auth.ts的三层划分已经足够清晰,几乎找不到理由自己从零再造一套轮子。后面我大概会把登录限流、邮箱验证、第三方 OAuth 登录也逐个加进来,到时候再拆开来写一篇。