Next.js全栈项目部署全流程|从0到1解决数据库、WebSocket、图片上传所有坑

0 阅读10分钟

为一名前端开发者,终于完整开发并部署了一个Next.js全栈项目,涵盖用户登录注册、商品发布、实时聊天等核心功能。部署过程中踩了无数坑,从数据库连接失败到WebSocket频繁断开,从图片上传报错到环境变量不生效,每一个问题都花了不少时间排查。

今天就把整个部署流程、用到的所有工具、遇到的问题及解决方案,完整梳理出来,希望能帮到正在做Next.js全栈部署的小伙伴,少走弯路,一次上线成功!

一、项目基础信息(先明确核心技术栈)

先交代下项目的核心技术栈,方便大家对应自己的项目场景参考:

  • 前端框架:Next.js 16(App Router)+ TypeScript
  • UI样式:Tailwind CSS
  • 数据库:MySQL(免费在线实例)
  • ORM工具:Prisma(连接、操作数据库)
  • 部署平台:Vercel(Next.js官方推荐,零配置部署)
  • 图片存储:Vercel Blob(解决Vercel只读文件系统问题)
  • 实时聊天:Pusher(第三方WebSocket服务,避免自建服务器)
  • 代码托管:GitHub(版本管理+Vercel自动拉取部署)

项目核心功能:用户登录注册、商品发布/展示、地址管理、一对一实时聊天,是一个完整的小型全栈应用,适合作为简历项目或练手案例。

二、用到的所有网站/工具(全程实战可用)

整个部署过程没有用到复杂的服务器,全部依赖免费工具/网站,新手也能轻松上手,整理了一张清单,一目了然:

工具/网站核心作用解决的问题
GitHub代码托管、版本管理存放项目代码,供Vercel自动拉取部署
Vercel全栈项目部署、公网访问实现项目一键上线,提供环境变量、日志排查功能
freesqldatabase.com免费在线MySQL数据库提供公网可访问的数据库实例,无需本地搭建
PrismaORM工具,连接/操作数据库无需写复杂SQL,自动建表、实现CRUD,防SQL注入
Vercel Blob免费文件存储服务解决Vercel只读文件系统,实现图片上传功能
Pusher第三方实时消息服务实现稳定WebSocket通信,解决聊天断开(code=1006)问题
phpMyAdmin数据库可视化管理查看数据库表结构、排查数据问题

三、完整部署流程(一步步跟着来,不踩坑)

部署全程遵循「代码准备 → 数据库配置 → 部署平台配置 → 功能适配 → 问题排查」的逻辑,每一步都对应实际操作,新手可直接照做。

第一步:代码准备与GitHub托管

  1. 本地完成Next.js全栈项目开发,确保核心功能(登录、商品、聊天)本地能正常运行。
  2. 在GitHub上新建仓库(比如命名为eslife-0929),将本地项目代码推送到GitHub仓库(注意忽略node_modules、.env等敏感文件)。
  3. 确认仓库代码完整,包含Prisma配置文件(schema.prisma)、API接口(app/api目录)、前端组件等核心文件。

第二步:数据库配置(MySQL + Prisma)

数据库是全栈项目的核心,这里用免费在线MySQL,无需本地搭建,步骤如下:

  1. 访问freesqldatabase.com,注册账号后,系统会自动创建一个MySQL数据库,获得核心连接信息:主机(host)、端口(port)、用户名(user)、密码(password)、数据库名(dbname)。

  2. 在本地项目根目录,配置Prisma:

    1. 修改schema.prisma文件,定义User、Commodity、Chat等数据表模型(对应项目功能)。
    2. 在.env.local文件中,配置数据库连接串:DATABASE_URL=mysql://user:password@host:port/dbname
  3. 本地执行prisma db push命令,Prisma会自动根据schema.prisma在远程MySQL数据库中生成对应的数据表。

  4. 通过phpMyAdmin登录数据库,确认数据表创建成功,确保本地能正常连接并操作数据库。

第三步:Vercel部署项目(核心步骤)

Vercel是Next.js官方部署平台,零配置、免费、自动CI/CD,是新手部署Next.js项目的首选,步骤如下:

  1. 访问Vercel官网,登录账号(建议用GitHub账号登录,方便关联仓库)。
  2. 点击「New Project」,导入GitHub上的项目仓库,Vercel会自动识别Next.js项目,无需手动配置构建命令。
  3. 配置环境变量:在Vercel项目后台,进入Settings → Environment Variables,添加项目所需的环境变量(重点是DATABASE_URL),并勾选Production、Preview、Development三个环境,确保环境变量全局生效。
  4. 点击「Deploy」,Vercel会自动拉取GitHub代码、安装依赖、构建项目,等待1-2分钟,部署完成后会生成一个公网可访问的域名(比如eslife-0929.vercel.app)。

第四步:功能适配(图片上传 + WebSocket)

部署完成后,会发现两个核心功能报错(图片上传、聊天),这是Vercel和免费WebSocket测试地址的限制,需要针对性适配:

1. 图片上传适配(解决Vercel只读文件系统)

Vercel的文件系统是只读的,无法将图片存到项目本地(会报EROFS: read-only file system错误),解决方案是使用Vercel Blob免费存储:

  1. 在Vercel项目后台,进入Storage → Create Store,选择Blob,创建一个存储实例(命名随意,比如eslife-images),选择Public模式(图片需要公网访问)。
  2. 创建完成后,Vercel会自动生成BLOB_READ_WRITE_TOKEN环境变量,无需手动创建。
  3. 本地项目安装@vercel/blob依赖(npm install @vercel/blob),修改图片上传API代码,替换成本地文件上传为Vercel Blob上传(代码见文末附录)。
  4. 将修改后的代码推送到GitHub,Vercel自动重新部署,图片上传功能正常。

2. WebSocket适配(解决聊天断开code=1006)

初期使用公共WebSocket测试地址(如wss://ws.postman-echo.com/raw),频繁断开报错(code=1006),原因是公共测试地址不支持持久聊天,解决方案是使用Pusher第三方实时服务:

  1. 访问Pusher官网,注册账号后,新建一个Channels应用,获得4个核心密钥:appId、key、secret、cluster。
  2. 在Vercel环境变量中,添加Pusher相关变量(NEXT_PUBLIC_PUSHER_KEY、NEXT_PUBLIC_PUSHER_CLUSTER、PUSHER_APP_ID、PUSHER_SECRET),勾选三个环境并保存。
  3. 修改前端聊天组件代码(初始化Pusher、订阅频道、监听消息)和后端发送消息API(触发Pusher消息推送),替换原来的WebSocket连接代码(代码见文末附录)。
  4. 推送代码到GitHub,Vercel自动部署,聊天功能稳定连接,不再出现断开报错。

第五步:测试验证

部署完成后,访问Vercel生成的公网域名,逐一测试核心功能:

  • 登录注册:正常访问,数据能写入数据库
  • 商品发布:能正常上传图片、保存商品信息
  • 实时聊天:能正常发送、接收消息,无断开报错
  • 地址管理:能正常新增、编辑、删除地址

所有功能正常,说明项目部署成功,可公网访问、正常使用。

四、部署过程中遇到的核心问题及解决方案(重点!)

这部分是重点,整理了我部署时遇到的5个核心问题,每个问题都有明确的报错、原因和解决方案,大家遇到相同问题可直接参考:

问题1:Prisma客户端未生成,部署失败(PrismaClientInitializationError)

  • 报错信息:Invalid prisma.user.findUnique() invocation: PrismaClientInitializationError
  • 原因:Vercel缓存依赖,构建时不会自动执行prisma generate,导致Prisma客户端未生成,无法连接数据库。
  • 解决方案:修改package.json的build命令,添加prisma generate,修改后为:"build": "prisma generate && next build",推送代码重新部署即可。

问题2:数据库连接失败,用户名被拒绝访问(%20xxx)

  • 报错信息:User 'sql12821786' was denied access on the database '%20sql12821786'
  • 原因:DATABASE_URL环境变量中,数据库名前面多了一个空格(%20是URL编码后的空格),导致连接失败。
  • 解决方案:重新粘贴无空格的DATABASE_URL连接串,确保前后无空格、无引号,保存后重新部署。

问题3:环境变量未生效(Environment variable not found: DATABASE_URL)

  • 报错信息:Environment variable not found: DATABASE_URL,Validation Error Count: 1
  • 原因:Vercel环境变量未勾选Production环境,导致线上部署时无法读取环境变量。
  • 解决方案:进入Vercel环境变量页面,勾选Production、Preview、Development三个环境,点击Save保存,重新部署。

问题4:图片上传报错(EROFS: read-only file system)

  • 报错信息:Error: EROFS: read-only file system, open '/var/task/public/uploads/xxx.png'
  • 原因:Vercel服务器文件系统是只读的,无法将图片写入本地public目录。
  • 解决方案:使用Vercel Blob云存储,替换图片上传逻辑,具体代码见附录。

问题5:WebSocket频繁断开(code=1006)

  • 报错信息:WebSocket 已断开(code=1006),请确认聊天服务已启动
  • 原因:使用的公共WebSocket测试地址(如postman-echo)是echo服务,不支持持久聊天,连接会自动断开。
  • 解决方案:接入Pusher第三方实时服务,替换WebSocket连接逻辑,具体代码见附录。

五、总结与感悟

从开发到部署,整个过程虽然踩了很多坑,但每解决一个问题,都对Next.js全栈部署有了更深入的理解。其实Next.js全栈部署并不复杂,核心是掌握「环境变量配置、数据库连接、第三方服务适配」这三个关键点。

本次部署全程使用免费工具,无需购买服务器,新手也能轻松上手,最终实现了项目公网访问,所有核心功能正常运行,这个项目也成为了我简历中的一个重要亮点。

最后,整理了两个核心功能的适配代码,大家可直接复制使用,避免重复踩坑。如果大家在部署过程中遇到其他问题,欢迎在评论区交流,一起解决!

附录:核心适配代码(直接复制可用)

1. Vercel Blob图片上传API(app/api/upload/route.ts)

import { put } from '@vercel/blob';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  try {
    const formData = await request.formData();
    const file = formData.get('file') as File;

    if (!file) {
      return NextResponse.json({ error: '请选择文件' }, { status: 400 });
    }

    // 上传到Vercel Blob,公开访问
    const blob = await put(file.name, file, {
      access: 'public',
    });

    // 返回图片URL,用于前端展示和数据库存储
    return NextResponse.json({
      url: blob.url,
    });
  } catch (error) {
    return NextResponse.json({ error: '上传失败' }, { status: 500 });
  }
}

2. Pusher实时聊天适配代码

前端聊天组件(components/ChatWindow.tsx)

import { useEffect, useState } from 'react';
import Pusher from 'pusher-js';

const ChatWindow = ({ userId, targetUserId }: { userId: number; targetUserId: number }) => {
  const [messages, setMessages] = useState<Array<{fromUserId: number, content: string, timestamp: string}>>([]);
  const [inputContent, setInputContent] = useState('');

  useEffect(() => {
    // 初始化Pusher
    if (!process.env.NEXT_PUBLIC_PUSHER_KEY || !process.env.NEXT_PUBLIC_PUSHER_CLUSTER) return;

    const pusher = new Pusher(process.env.NEXT_PUBLIC_PUSHER_KEY, {
      cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER,
    });

    // 订阅私有聊天频道(两个用户ID组合,保证隐私)
    const channelName = `private-chat-${Math.min(userId, targetUserId)}-${Math.max(userId, targetUserId)}`;
    const channel = pusher.subscribe(channelName);

    // 监听聊天消息
    channel.bind('chat-message', (data) => {
      setMessages(prev => [...prev, data]);
    });

    // 组件卸载时清理连接
    return () => {
      channel.unbind_all();
      channel.unsubscribe();
      pusher.disconnect();
    };
  }, [userId, targetUserId]);

  // 发送消息
  const sendMessage = async () => {
    if (!inputContent.trim()) return;

    await fetch('/api/chat/send', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ fromUserId: userId, toUserId: targetUserId, content: inputContent }),
    });
    
    setInputContent('');
  };

  return (
    
        {messages.map((msg, index) => (
          <div 
            key={ ${
              msg.fromUserId === userId ? 'bg-blue-500 text-white self-end' : 'bg-gray-200 self-start'
            }`}
          >
           {msg.content}{new Date(msg.timestamp).toLocaleTimeString()}
        ))}
      <input
          type="text"
          value={ onChange={(e) => setInputContent(e.target.value)}
          placeholder="输入消息..."
          className="flex-1 px-3 py-2 border rounded"
        />
        <button onClick={发送
  );
};

export default ChatWindow;

后端发送消息API(app/api/chat/send/route.ts)

import { NextRequest, NextResponse } from 'next/server';
import Pusher from 'pusher';

// 初始化Pusher后端实例
const pusher = new Pusher({
  appId: process.env.PUSHER_APP_ID as string,
  key: process.env.PUSHER_KEY as string,
  secret: process.env.PUSHER_SECRET as string,
  cluster: process.env.PUSHER_CLUSTER as string,
  useTLS: true, // 开启加密连接,避免安全问题
});

export async function POST(request: NextRequest) {
  try {
    const { fromUserId, toUserId, content } = await request.json();

    // 定义聊天频道(与前端一致,保证消息能正确推送)
    const channelName = `private-chat-${Math.min(fromUserId, toUserId)}-${Math.max(fromUserId, toUserId)}`;
    const eventName = 'chat-message';

    // 触发消息推送,推送给订阅该频道的前端
    await pusher.trigger(channelName, eventName, {
      fromUserId,
      toUserId,
      content,
      timestamp: new Date().toISOString(),
    });

    return NextResponse.json({ success: true, message: '消息已发送' });
  } catch (error) {
    console.error('Pusher推送失败:', error);
    return NextResponse.json({ error: '发送失败' }, { status: 500 });
  }
}