【全栈】Next.js + PostgreSQL + Vercel 实现完整登录系统(完整源码)

这篇博客会从环境准备到最终部署,一步步实现这套生产级的登录系统。 包含前端页面、后端接口(注册 / 登录 / 获取用户信息)的完整登录系统。
基于 Next.js框架,使用 PostgreSQL 作为数据库,最终部署到 Vercel 平台
博客中的代码为最小可用版本

一、技术栈与核心思路

核心技术栈

  • 前端 / 后端框架:Next.js 14+(App Router)
  • 数据库:PostgreSQL
  • ORM:Prisma(简化数据库操作)
  • 认证方案:JWT(JSON Web Token)
  • 部署平台:Vercel(前端 + API)+ Vercel Postgres(PostgreSQL 数据库)
  • 密码安全:bcrypt(密码哈希)

核心思路

  1. 初始化 Next.js 项目,配置 Prisma 连接 PostgreSQL
  2. 编写数据库模型(用户表)
  3. 实现后端 API 接口:注册、登录、获取用户信息
  4. 开发前端登录 / 注册页面,处理状态与请求
  5. 配置 Vercel 部署,关联 PostgreSQL 数据库
  6. 部署并验证生产环境可用性

目录结构

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 服务,无需自己搭建数据库:

  1. 登录 Vercel 官网,进入 Dashboard
  2. 点击「Storage」→「Create Database」→ 选择「Postgres」
  3. 命名数据库(如 auth-system-db),点击创建
  4. 创建完成后,复制数据库连接信息(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

  1. 初始化 Git 仓库:
typescript 复制代码
git init
git add .
git commit -m "初始化登录系统"
  1. 在 GitHub 创建新仓库,将本地代码推送到远程仓库。

2. Vercel 部署

  1. 登录 Vercel 官网,点击「Add New」→「Project」
  2. 选择你刚创建的 GitHub 仓库,点击「Import」
  3. 配置环境变量:
    • 在 Vercel 项目的「Settings」→「Environment Variables」中,添加:
      POSTGRES_URL:Vercel Postgres 提供的连接地址
      JWT_SECRET:你自定义的 JWT 密钥(和本地 .env 一致)
  4. 点击「Deploy」,等待部署完成。

3. 验证部署结果

部署完成后,Vercel 会生成一个在线域名(如 auth-system.vercel.app),访问该域名:

  • 访问 /register 注册账号
  • 访问 /login 登录
  • 登录后首页会展示用户信息
  • 调用 /api/auth/me 接口可验证 Token 有效性

七、核心优化与注意事项

  1. 密码安全:所有密码均通过 bcrypt 哈希存储,不会明文保存;
  2. JWT 有效期:设置 7 天有效期,可根据需求调整;
  3. 错误处理:接口均包含完善的错误捕获和状态码返回;
  4. 环境变量:生产环境的敏感信息(如 JWT_SECRET、数据库地址)通过 Vercel 环境变量管理,不硬编码;
  5. Prisma 优化:单例模式创建 Prisma 客户端,避免重复连接数据库。

总结

  1. 本次登录系统基于 Next.js 实现了「前端页面 + 后端 API」一体化开发,无需单独部署后端服务;
  2. 使用 Prisma 简化 PostgreSQL 操作,通过 bcrypt 保障密码安全,JWT 实现无状态认证;
  3. 借助 Vercel 实现一键部署,Vercel Postgres 提供托管的 PostgreSQL 数据库,无需自建数据库服务;
  4. 核心功能包含注册、登录、获取用户信息,且具备完善的错误处理和状态管理,可直接用于生产环境。

这套系统是 Next.js 全栈开发的,因为公司正好在做老系统重构,将之前比较轻量的服务都改为全栈开发了,记录一下整体开发过程。

相关推荐
李长渊哦2 小时前
PostgreSQL 18 本地部署与运维完全指南 (Windows版)
运维·windows·postgresql
亮子AI2 小时前
【PostgreSQL】如何清空数据库?
数据库·postgresql·oracle
前端荣耀14 小时前
Flutter 客户端热更新实战
前端框架·全栈
熙胤17 小时前
PostgreSQL 向量扩展插件pgvector安装和使用
数据库·postgresql
Mr.徐大人ゞ18 小时前
2-3.pg核心功能之索引特色
postgresql
知识分享小能手18 小时前
PostgreSQL 入门学习教程,从入门到精通,PostgreSQL 16 内部结构深度解析 —语法、实现与实战案例(20)
数据库·学习·postgresql
IvorySQL19 小时前
官宣!全球 PostgreSQL 大神再度集结,HOW 2026 正式定档
数据库·postgresql·开源
Aaron_Wjf19 小时前
关于PG兼容性的一点转换
数据库·postgresql
Mr.徐大人ゞ1 天前
PostgreSQL 常用查询命令汇总
postgresql