这篇博客会从环境准备到最终部署,一步步实现这套生产级的登录系统。 包含前端页面、后端接口(注册 / 登录 / 获取用户信息)的完整登录系统。
基于 Next.js框架,使用 PostgreSQL 作为数据库,最终部署到 Vercel 平台
博客中的代码为最小可用版本
一、技术栈与核心思路
核心技术栈
- 前端 / 后端框架:Next.js 14+(App Router)
- 数据库:PostgreSQL
- ORM:Prisma(简化数据库操作)
- 认证方案:JWT(JSON Web Token)
- 部署平台:Vercel(前端 + API)+ Vercel Postgres(PostgreSQL 数据库)
- 密码安全:bcrypt(密码哈希)
核心思路
- 初始化 Next.js 项目,配置 Prisma 连接 PostgreSQL
- 编写数据库模型(用户表)
- 实现后端 API 接口:注册、登录、获取用户信息
- 开发前端登录 / 注册页面,处理状态与请求
- 配置 Vercel 部署,关联 PostgreSQL 数据库
- 部署并验证生产环境可用性
目录结构
bash
auth-system/
├── app/
│ ├── api/
│ │ └── auth/
│ │ ├── login/
│ │ │ └── route.ts
│ │ ├── me/
│ │ │ └── route.ts
│ │ └── register/
│ │ └── route.ts
│ ├── login/
│ │ └── page.tsx
│ ├── register/
│ │ └── page.tsx
│ ├── layout.tsx
│ ├── page.tsx
│ └── globals.css
├── lib/
│ ├── auth-context.tsx
│ ├── prisma.ts
│ └── utils.ts
├── prisma/
│ └── schema.prisma
├── .env
└── package.json
二、环境准备
1. 基础工具安装
- Node.js 18+(推荐 20+)
- npm/yarn/pnpm(包管理器)
- Git(版本控制)
- Vercel CLI(可选,方便本地调试)
bash
# 安装 Vercel CLI:
npm i -g vercel
2. 创建 Vercel Postgres 数据库
Vercel 提供了托管的 PostgreSQL 服务,无需自己搭建数据库:
- 登录 Vercel 官网,进入 Dashboard
- 点击「Storage」→「Create Database」→ 选择「Postgres」
- 命名数据库(如 auth-system-db),点击创建
- 创建完成后,复制数据库连接信息(Vercel 会自动生成 POSTGRES_URL 等环境变量)



三、项目初始化与配置
1. 创建 Next.js 项目
bash
# 创建项目
npx create-next-app@latest auth-system
cd auth-system
# 安装依赖
npm install prisma bcrypt jsonwebtoken @types/bcrypt @types/jsonwebtoken
2. 配置 Prisma(ORM)
bash
# 初始化 Prisma
npx prisma init
修改 prisma/schema.prisma(数据库模型)
bash
// This is your Prisma schema file
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("POSTGRES_URL") // 关联 Vercel Postgres 的环境变量
}
// 用户表模型
model User {
id String @id @default(cuid()) // 唯一ID,自动生成
email String @unique // 邮箱唯一
password String // 哈希后的密码
name String? // 可选用户名
createdAt DateTime @default(now()) // 创建时间
updatedAt DateTime @updatedAt // 更新时间
}
配置环境变量(.env 文件)
将 Vercel Postgres 提供的 POSTGRES_URL 粘贴到 .env 文件:
bash
POSTGRES_URL="你的Vercel Postgres连接地址"
JWT_SECRET="your-secret-key-123456" // JWT 密钥(自定义,建议复杂一点)
同步数据库表
bash
npx prisma db push
四、实现后端 API 接口
Next.js 的 App Router 支持在 app/api 目录下编写 API 路由,我们实现 3 个核心接口:
1. 通用工具函数(lib/utils.ts)
封装密码哈希、JWT 生成 / 验证等通用逻辑:
typescript
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
// 密码哈希(加盐)
export const hashPassword = async (password: string) => {
const saltRounds = 10;
return await bcrypt.hash(password, saltRounds);
};
// 验证密码
export const verifyPassword = async (password: string, hashedPassword: string) => {
return await bcrypt.compare(password, hashedPassword);
};
// 生成 JWT Token
export const generateToken = (userId: string) => {
const secret = process.env.JWT_SECRET!;
// Token 有效期 7 天
return jwt.sign({ userId }, secret, { expiresIn: '7d' });
};
// 验证 JWT Token
export const verifyToken = (token: string) => {
const secret = process.env.JWT_SECRET!;
try {
return jwt.verify(token, secret);
} catch (error) {
return null;
}
};
2. 初始化 Prisma 客户端(lib/prisma.ts)
typescript
import { PrismaClient } from '@prisma/client';
const prismaClientSingleton = () => {
return new PrismaClient();
};
declare global {
var prisma: undefined | ReturnType<typeof prismaClientSingleton>;
}
const prisma = globalThis.prisma ?? prismaClientSingleton();
export default prisma;
if (process.env.NODE_ENV !== 'production') globalThis.prisma = prisma;
3. 注册接口(app/api/auth/register/route.ts)
typescript
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import { hashPassword } from '@/lib/utils';
export async function POST(request: NextRequest) {
try {
const { email, password, name } = await request.json();
// 验证参数
if (!email || !password) {
return NextResponse.json(
{ error: '邮箱和密码不能为空' },
{ status: 400 }
);
}
// 检查邮箱是否已存在
const existingUser = await prisma.user.findUnique({
where: { email },
});
if (existingUser) {
return NextResponse.json(
{ error: '该邮箱已注册' },
{ status: 409 }
);
}
// 密码哈希
const hashedPassword = await hashPassword(password);
// 创建用户
const user = await prisma.user.create({
data: {
email,
password: hashedPassword,
name,
},
// 不返回密码
select: {
id: true,
email: true,
name: true,
createdAt: true,
},
});
return NextResponse.json(
{ message: '注册成功', user },
{ status: 201 }
);
} catch (error) {
console.error('注册失败:', error);
return NextResponse.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
4. 登录接口(app/api/auth/login/route.ts)
typescript
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import { verifyPassword, generateToken } from '@/lib/utils';
export async function POST(request: NextRequest) {
try {
const { email, password } = await request.json();
// 验证参数
if (!email || !password) {
return NextResponse.json(
{ error: '邮箱和密码不能为空' },
{ status: 400 }
);
}
// 查询用户
const user = await prisma.user.findUnique({
where: { email },
});
if (!user) {
return NextResponse.json(
{ error: '邮箱或密码错误' },
{ status: 401 }
);
}
// 验证密码
const isPasswordValid = await verifyPassword(password, user.password);
if (!isPasswordValid) {
return NextResponse.json(
{ error: '邮箱或密码错误' },
{ status: 401 }
);
}
// 生成 JWT Token
const token = generateToken(user.id);
// 返回用户信息和 Token(不返回密码)
return NextResponse.json({
message: '登录成功',
token,
user: {
id: user.id,
email: user.email,
name: user.name,
},
});
} catch (error) {
console.error('登录失败:', error);
return NextResponse.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
5. 获取用户信息接口(app/api/auth/me/route.ts)
typescript
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import { verifyToken } from '@/lib/utils';
export async function GET(request: NextRequest) {
try {
// 从请求头获取 Token
const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return NextResponse.json(
{ error: '未授权' },
{ status: 401 }
);
}
const token = authHeader.split(' ')[1];
const decoded = verifyToken(token);
if (!decoded || typeof decoded !== 'object' || !decoded.userId) {
return NextResponse.json(
{ error: 'Token 无效' },
{ status: 401 }
);
}
// 查询用户信息
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: {
id: true,
email: true,
name: true,
createdAt: true,
},
});
if (!user) {
return NextResponse.json(
{ error: '用户不存在' },
{ status: 404 }
);
}
return NextResponse.json({ user });
} catch (error) {
console.error('获取用户信息失败:', error);
return NextResponse.json(
{ error: '服务器内部错误' },
{ status: 500 }
);
}
}
五、开发前端页面
1. 状态管理(lib/auth-context.tsx)
创建 React Context 管理登录状态,方便全局使用:
typescript
'use client';
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
type User = {
id: string;
email: string;
name?: string;
};
type AuthContextType = {
user: User | null;
token: string | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string, name?: string) => Promise<void>;
logout: () => void;
};
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// 初始化:从 localStorage 获取 Token 并验证
useEffect(() => {
const storedToken = localStorage.getItem('token');
if (storedToken) {
setToken(storedToken);
fetchUserInfo(storedToken);
}
}, []);
// 获取用户信息
const fetchUserInfo = async (token: string) => {
try {
const res = await fetch('/api/auth/me', {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (res.ok) {
const data = await res.json();
setUser(data.user);
} else {
localStorage.removeItem('token');
setToken(null);
}
} catch (error) {
console.error('获取用户信息失败:', error);
}
};
// 注册
const register = async (email: string, password: string, name?: string) => {
setLoading(true);
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, name }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || '注册失败');
alert('注册成功,请登录');
} catch (error) {
alert((error as Error).message);
} finally {
setLoading(false);
}
};
// 登录
const login = async (email: string, password: string) => {
setLoading(true);
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || '登录失败');
// 存储 Token 到 localStorage
localStorage.setItem('token', data.token);
setToken(data.token);
setUser(data.user);
} catch (error) {
alert((error as Error).message);
} finally {
setLoading(false);
}
};
// 退出登录
const logout = () => {
localStorage.removeItem('token');
setToken(null);
setUser(null);
};
return (
<AuthContext.Provider value={{ user, token, loading, login, register, logout }}>
{children}
</AuthContext.Provider>
);
}
// 自定义 Hook,方便使用 AuthContext
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth 必须在 AuthProvider 内部使用');
}
return context;
};
2. 登录页面(app/login/page.tsx)
typescript
'use client';
import { useState } from 'react';
import { useAuth } from '@/lib/auth-context';
import Link from 'next/link';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const { login, loading } = useAuth();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await login(email, password);
};
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-100">
<div className="w-full max-w-md p-8 bg-white rounded-lg shadow-md">
<h1 className="text-2xl font-bold mb-6 text-center">登录</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">邮箱</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">密码</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400"
>
{loading ? '登录中...' : '登录'}
</button>
</form>
<p className="mt-4 text-center text-sm">
还没有账号?<Link href="/register" className="text-blue-600 hover:underline">注册</Link>
</p>
</div>
</div>
);
}
3. 注册页面(app/register/page.tsx)
typescript
'use client';
import { useState } from 'react';
import { useAuth } from '@/lib/auth-context';
import Link from 'next/link';
export default function RegisterPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
const { register, loading } = useAuth();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await register(email, password, name);
};
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-100">
<div className="w-full max-w-md p-8 bg-white rounded-lg shadow-md">
<h1 className="text-2xl font-bold mb-6 text-center">注册</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">用户名(可选)</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">邮箱</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">密码</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400"
>
{loading ? '注册中...' : '注册'}
</button>
</form>
<p className="mt-4 text-center text-sm">
已有账号?<Link href="/login" className="text-blue-600 hover:underline">登录</Link>
</p>
</div>
</div>
);
}
4. 首页(登录后展示,app/page.tsx)
typescript
'use client';
import { useAuth } from '@/lib/auth-context';
import Link from 'next/link';
export default function Home() {
const { user, logout, loading } = useAuth();
if (loading) {
return <div className="flex justify-center items-center min-h-screen">加载中...</div>;
}
if (!user) {
return (
<div className="flex justify-center items-center min-h-screen">
<div className="text-center">
<h1 className="text-3xl font-bold mb-4">欢迎访问</h1>
<Link
href="/login"
className="bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700"
>
请登录
</Link>
</div>
</div>
);
}
return (
<div className="flex flex-col justify-center items-center min-h-screen bg-gray-100">
<div className="bg-white p-8 rounded-lg shadow-md text-center">
<h1 className="text-3xl font-bold mb-4">登录成功!</h1>
<p className="text-lg mb-2">用户名:{user.name || '未设置'}</p>
<p className="text-lg mb-6">邮箱:{user.email}</p>
<button
onClick={logout}
className="bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700"
>
退出登录
</button>
</div>
</div>
);
}
5. 全局引入 AuthProvider(app/layout.tsx)
typescript
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import { AuthProvider } from '@/lib/auth-context';
import './globals.css';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: '登录系统 | Next.js + PostgreSQL',
description: '基于 Next.js 和 PostgreSQL 的登录系统',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN">
<body className={inter.className}>
<AuthProvider>{children}</AuthProvider>
</body>
</html>
);
}
六、部署到 Vercel
1. 提交代码到 GitHub
- 初始化 Git 仓库:
typescript
git init
git add .
git commit -m "初始化登录系统"
- 在 GitHub 创建新仓库,将本地代码推送到远程仓库。
2. Vercel 部署
- 登录 Vercel 官网,点击「Add New」→「Project」
- 选择你刚创建的 GitHub 仓库,点击「Import」
- 配置环境变量:
- 在 Vercel 项目的「Settings」→「Environment Variables」中,添加:
POSTGRES_URL:Vercel Postgres 提供的连接地址
JWT_SECRET:你自定义的 JWT 密钥(和本地 .env 一致)
- 在 Vercel 项目的「Settings」→「Environment Variables」中,添加:
- 点击「Deploy」,等待部署完成。
3. 验证部署结果
部署完成后,Vercel 会生成一个在线域名(如 auth-system.vercel.app),访问该域名:
- 访问 /register 注册账号
- 访问 /login 登录
- 登录后首页会展示用户信息
- 调用 /api/auth/me 接口可验证 Token 有效性
七、核心优化与注意事项
- 密码安全:所有密码均通过 bcrypt 哈希存储,不会明文保存;
- JWT 有效期:设置 7 天有效期,可根据需求调整;
- 错误处理:接口均包含完善的错误捕获和状态码返回;
- 环境变量:生产环境的敏感信息(如 JWT_SECRET、数据库地址)通过 Vercel 环境变量管理,不硬编码;
- Prisma 优化:单例模式创建 Prisma 客户端,避免重复连接数据库。
总结
- 本次登录系统基于 Next.js 实现了「前端页面 + 后端 API」一体化开发,无需单独部署后端服务;
- 使用 Prisma 简化 PostgreSQL 操作,通过 bcrypt 保障密码安全,JWT 实现无状态认证;
- 借助 Vercel 实现一键部署,Vercel Postgres 提供托管的 PostgreSQL 数据库,无需自建数据库服务;
- 核心功能包含注册、登录、获取用户信息,且具备完善的错误处理和状态管理,可直接用于生产环境。
这套系统是 Next.js 全栈开发的,因为公司正好在做老系统重构,将之前比较轻量的服务都改为全栈开发了,记录一下整体开发过程。