零成本构建全栈Web应用:基于Supabase与Vercel的实战指南
2026/8/22 1:54:16 网站建设 项目流程

最近在技术社区看到不少关于“未来一切或将免费”的讨论,这让我联想到在软件开发领域,我们其实已经部分实现了这种“免费”的愿景——通过开源软件、云计算免费额度、以及各种强大的免费工具链。对于开发者而言,掌握如何高效利用这些“免费”资源,构建稳定、可扩展的应用,是一项核心能力。本文将以一个全栈Web应用为例,手把手带你体验从零开始,不花一分钱,利用主流免费服务和框架,完成一个具备用户认证、数据存储和前端展示的完整项目。无论你是想学习现代全栈开发,还是希望为自己的创意项目寻找低成本启动方案,这篇实战指南都将提供清晰的路径和可复现的代码。

1. 项目概述与技术选型

我们的目标是构建一个简单的“个人任务看板”应用。用户可以进行注册、登录,创建、查看、更新和删除自己的任务卡片。

为了实现“零成本”,我们需要精心挑选技术栈:

  • 前端Vite + React。开发体验极佳,构建速度快,生态丰富。我们将使用纯函数组件和Hooks进行开发。
  • 后端/ServerlessVercel + Next.js API Routes。Vercel为Next.js项目提供免费的自动化部署、全球CDN和Serverless函数。我们将利用其API Routes功能构建后端接口,无需自己维护服务器。
  • 数据库Supabase。它提供了一个完整的开源后端即服务(BaaS),包含PostgreSQL数据库、实时订阅、身份认证、存储等。免费层额度对于个人项目或小型应用完全足够。
  • 身份认证Supabase Auth。集成在Supabase中,支持邮箱/密码、第三方OAuth等多种登录方式,我们无需从头实现复杂的认证逻辑。
  • 样式Tailwind CSS。实用优先的CSS框架,能让我们快速构建美观的UI,且Next.js对其有很好的支持。

这个组合涵盖了现代Web开发的核心环节,并且每个环节都有可靠的免费方案支撑。

2. 环境准备与工具安装

在开始编码前,请确保你的本地开发环境已就绪。

2.1 基础环境

  • Node.js:请安装LTS(长期支持版),建议版本为18.x或20.x。你可以从 Node.js官网 下载安装包。安装后,在终端运行以下命令验证:
    node --version npm --version
  • 代码编辑器:推荐使用Visual Studio Code,并安装诸如ES7+ React/Redux/React-Native snippetsTailwind CSS IntelliSense等插件以提升开发效率。
  • Git:用于版本控制和部署。确保已安装并配置好全局用户信息。
    git --version git config --global user.name "Your Name" git config --global user.email "your.email@example.com"

2.2 创建Supabase项目

  1. 访问 Supabase官网 并使用GitHub账号注册登录。
  2. 点击“New Project”。
  3. 填写项目名称(如my-task-board),设置数据库密码(请妥善保存),选择离你较近的区域以获得更低的网络延迟。
  4. 点击“Create new project”。等待几分钟,Supabase会自动为你初始化一个完整的PostgreSQL数据库和一套管理后台。

项目创建成功后,进入Project Settings -> API页面。这里有两个关键信息你需要记录下来:

  • Project URL:你的Supabase实例地址,格式如https://xxxxxx.supabase.co
  • anon/publicAPI Key:用于在前端以“匿名”身份(受行级安全策略限制)与数据库交互的密钥。

安全提示anonkey是公开的,但配合行级安全策略(RLS)使用是安全的。切勿将service_rolekey 暴露在前端。

2.3 初始化数据库表

我们将创建一张tasks表来存储任务数据。进入Supabase控制台的Table Editor,点击“Create a new table”。

  • 表名tasks
  • 字段
    • id(bigint, 主键,自增)
    • user_id(uuid, 关联auth.users表)
    • title(text, 非空)
    • description(text)
    • status(text, 例如:todo,in_progress,done)
    • created_at(timestamptz, 默认值now())

创建表后,必须启用行级安全策略(RLS),这是数据安全的核心。点击表名进入详情页,切换到Authentication -> Policies标签页。 点击“Create a new policy”,选择“For full customization”。

  • 策略名称Users can only CRUD their own tasks
  • 使用表达式定义策略
    -- 允许已认证用户插入自己的任务 (auth.uid() = user_id)
  • 操作:为SELECT,INSERT,UPDATE,DELETE全部勾选此策略。

这条策略意味着:任何数据库操作(增删改查)都只会作用于user_id字段等于当前登录用户ID (auth.uid()) 的数据行。这样,用户A永远看不到、改不了用户B的任务。

3. 构建Next.js前端应用

3.1 创建项目并安装依赖

使用Next.js官方工具快速创建项目,并集成Tailwind CSS和Supabase客户端库。

npx create-next-app@latest my-task-board --typescript --tailwind --app cd my-task-board npm install @supabase/supabase-js npm install react-icons # 用于图标

3.2 配置Supabase客户端

创建一个工具文件来初始化Supabase客户端,避免在多个组件中重复配置。

文件路径:/lib/supabaseClient.ts

import { createClient } from '@supabase/supabase-js'; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; export const supabase = createClient(supabaseUrl, supabaseAnonKey);

文件路径:/.env.local将你在Supabase控制台获取的密钥填入环境变量文件。此文件不应提交到Git仓库。

NEXT_PUBLIC_SUPABASE_URL=你的Project URL NEXT_PUBLIC_SUPABASE_ANON_KEY=你的anon/public API Key

3.3 实现用户认证界面

我们将使用Supabase提供的React Auth UI组件库来快速搭建登录页。首先安装它:

npm install @supabase/auth-ui-react @supabase/auth-ui-shared

文件路径:/app/auth/page.tsx

'use client'; import { Auth } from '@supabase/auth-ui-react'; import { ThemeSupa } from '@supabase/auth-ui-shared'; import { supabase } from '@/lib/supabaseClient'; import { useRouter } from 'next/navigation'; import { useEffect } from 'react'; export default function AuthPage() { const router = useRouter(); useEffect(() => { // 检查当前会话,如果已登录则跳转到主页 const checkUser = async () => { const { data: { session } } = await supabase.auth.getSession(); if (session) { router.push('/'); } }; checkUser(); }, [router]); return ( <div className="flex justify-center items-center min-h-screen bg-gray-50"> <div className="w-full max-w-md p-8 space-y-6 bg-white rounded-xl shadow-lg"> <h1 className="text-2xl font-bold text-center text-gray-800">任务看板</h1> <p className="text-center text-gray-600">请登录或注册以管理你的任务</p> <Auth supabaseClient={supabase} appearance={{ theme: ThemeSupa }} theme="light" providers={['github']} // 可选:启用GitHub OAuth登录 redirectTo={`${typeof window !== 'undefined' ? window.location.origin : ''}/auth/callback`} /> </div> </div> ); }

文件路径:/app/auth/callback/route.ts这是一个API Route,用于处理OAuth登录后的回调,将用户重定向回主页。

import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; import { cookies } from 'next/headers'; import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; export async function GET(request: NextRequest) { const requestUrl = new URL(request.url); const code = requestUrl.searchParams.get('code'); if (code) { const supabase = createRouteHandlerClient({ cookies }); await supabase.auth.exchangeCodeForSession(code); } // 重定向用户到首页 return NextResponse.redirect(requestUrl.origin); }

3.4 构建主应用布局与任务看板

文件路径:/app/layout.tsx修改根布局,添加导航栏和用户状态管理。

import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; import './globals.css'; import Navbar from '@/components/Navbar'; const inter = Inter({ subsets: ['latin'] }); export const metadata: Metadata = { title: '免费任务看板', description: '基于Supabase和Vercel构建的零成本全栈应用', }; export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( <html lang="zh-CN"> <body className={`${inter.className} bg-gray-50`}> <Navbar /> <main className="container mx-auto px-4 py-8">{children}</main> </body> </html> ); }

文件路径:/components/Navbar.tsx

'use client'; import { supabase } from '@/lib/supabaseClient'; import { useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; export default function Navbar() { const [userEmail, setUserEmail] = useState<string | null>(null); const router = useRouter(); useEffect(() => { const getUser = async () => { const { data: { user } } = await supabase.auth.getUser(); setUserEmail(user?.email || null); }; getUser(); // 监听认证状态变化 const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => { setUserEmail(session?.user?.email || null); }); return () => subscription.unsubscribe(); }, []); const handleSignOut = async () => { await supabase.auth.signOut(); router.push('/auth'); // 登出后跳转到登录页 router.refresh(); }; return ( <nav className="bg-white shadow-md"> <div className="container mx-auto px-4 py-3 flex justify-between items-center"> <h1 className="text-xl font-bold text-blue-600">🚀 我的任务看板</h1> <div className="flex items-center space-x-4"> {userEmail ? ( <> <span className="text-sm text-gray-600">欢迎,{userEmail}</span> <button onClick={handleSignOut} className="px-4 py-2 text-sm bg-red-500 text-white rounded hover:bg-red-600 transition" > 退出登录 </button> </> ) : ( <button onClick={() => router.push('/auth')} className="px-4 py-2 text-sm bg-blue-500 text-white rounded hover:bg-blue-600 transition" > 登录 / 注册 </button> )} </div> </div> </nav> ); }

文件路径:/app/page.tsx主页面,包含任务列表和创建新任务的表单。

'use client'; import { supabase } from '@/lib/supabaseClient'; import { useEffect, useState } from 'react'; import TaskCard from '@/components/TaskCard'; import { Task } from '@/types/task'; export default function HomePage() { const [tasks, setTasks] = useState<Task[]>([]); const [newTaskTitle, setNewTaskTitle] = useState(''); const [newTaskDesc, setNewTaskDesc] = useState(''); const [loading, setLoading] = useState(true); // 获取当前用户的所有任务 const fetchTasks = async () => { setLoading(true); const { data, error } = await supabase .from('tasks') .select('*') .order('created_at', { ascending: false }); if (error) { console.error('获取任务失败:', error); } else { setTasks(data || []); } setLoading(false); }; useEffect(() => { fetchTasks(); }, []); // 创建新任务 const handleCreateTask = async (e: React.FormEvent) => { e.preventDefault(); if (!newTaskTitle.trim()) return; const { data: { user } } = await supabase.auth.getUser(); if (!user) { alert('请先登录'); return; } const { error } = await supabase.from('tasks').insert([ { user_id: user.id, title: newTaskTitle, description: newTaskDesc, status: 'todo', }, ]); if (error) { console.error('创建任务失败:', error); } else { setNewTaskTitle(''); setNewTaskDesc(''); fetchTasks(); // 刷新列表 } }; if (loading) { return <div className="text-center py-10">加载任务中...</div>; } return ( <div> {/* 创建任务表单 */} <div className="mb-10 p-6 bg-white rounded-xl shadow"> <h2 className="text-2xl font-bold mb-4">创建新任务</h2> <form onSubmit={handleCreateTask} className="space-y-4"> <div> <label className="block text-sm font-medium mb-1">任务标题 *</label> <input type="text" value={newTaskTitle} onChange={(e) => setNewTaskTitle(e.target.value)} className="w-full p-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="例如:完成项目周报" required /> </div> <div> <label className="block text-sm font-medium mb-1">任务描述</label> <textarea value={newTaskDesc} onChange={(e) => setNewTaskDesc(e.target.value)} className="w-full p-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent" rows={3} placeholder="详细描述任务内容..." /> </div> <button type="submit" className="px-6 py-2 bg-green-500 text-white font-medium rounded hover:bg-green-600 transition" > 添加任务 </button> </form> </div> {/* 任务列表 */} <div> <h2 className="text-2xl font-bold mb-6">我的任务 ({tasks.length})</h2> {tasks.length === 0 ? ( <p className="text-gray-500 text-center py-10">暂无任务,创建一个吧!</p> ) : ( <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> {tasks.map((task) => ( <TaskCard key={task.id} task={task} onTaskUpdated={fetchTasks} /> ))} </div> )} </div> </div> ); }

文件路径:/components/TaskCard.tsx

'use client'; import { Task } from '@/types/task'; import { supabase } from '@/lib/supabaseClient'; import { useState } from 'react'; import { FiEdit2, FiTrash2, FiCheckCircle } from 'react-icons/fi'; interface TaskCardProps { task: Task; onTaskUpdated: () => void; } export default function TaskCard({ task, onTaskUpdated }: TaskCardProps) { const [isEditing, setIsEditing] = useState(false); const [editTitle, setEditTitle] = useState(task.title); const [editDesc, setEditDesc] = useState(task.description || ''); const [editStatus, setEditStatus] = useState(task.status); const handleDelete = async () => { if (!confirm('确定要删除这个任务吗?')) return; const { error } = await supabase.from('tasks').delete().eq('id', task.id); if (error) { console.error('删除失败:', error); } else { onTaskUpdated(); } }; const handleUpdate = async () => { const { error } = await supabase .from('tasks') .update({ title: editTitle, description: editDesc, status: editStatus }) .eq('id', task.id); if (error) { console.error('更新失败:', error); } else { setIsEditing(false); onTaskUpdated(); } }; const statusColor: Record<string, string> = { todo: 'bg-yellow-100 text-yellow-800', in_progress: 'bg-blue-100 text-blue-800', done: 'bg-green-100 text-green-800', }; return ( <div className="bg-white p-6 rounded-xl shadow hover:shadow-md transition-shadow"> {isEditing ? ( <div className="space-y-3"> <input className="w-full p-2 border rounded font-bold" value={editTitle} onChange={(e) => setEditTitle(e.target.value)} /> <textarea className="w-full p-2 border rounded text-sm" rows={3} value={editDesc} onChange={(e) => setEditDesc(e.target.value)} /> <select className="w-full p-2 border rounded" value={editStatus} onChange={(e) => setEditStatus(e.target.value)} > <option value="todo">待办</option> <option value="in_progress">进行中</option> <option value="done">已完成</option> </select> <div className="flex justify-end space-x-2"> <button onClick={() => setIsEditing(false)} className="px-3 py-1 text-sm border rounded"> 取消 </button> <button onClick={handleUpdate} className="px-3 py-1 text-sm bg-blue-500 text-white rounded"> 保存 </button> </div> </div> ) : ( <> <div className="flex justify-between items-start mb-3"> <h3 className="text-lg font-bold text-gray-800">{task.title}</h3> <span className={`px-3 py-1 rounded-full text-xs font-semibold ${statusColor[task.status]}`}> {task.status === 'todo' ? '待办' : task.status === 'in_progress' ? '进行中' : '已完成'} </span> </div> <p className="text-gray-600 mb-4 text-sm">{task.description}</p> <div className="flex justify-between items-center text-sm text-gray-500"> <span>创建于:{new Date(task.created_at).toLocaleDateString('zh-CN')}</span> <div className="flex space-x-2"> <button onClick={() => setIsEditing(true)} className="p-1 hover:text-blue-600"> <FiEdit2 /> </button> <button onClick={handleDelete} className="p-1 hover:text-red-600"> <FiTrash2 /> </button> {task.status !== 'done' && ( <button onClick={async () => { await supabase.from('tasks').update({ status: 'done' }).eq('id', task.id); onTaskUpdated(); }} className="p-1 hover:text-green-600" title="标记为完成" > <FiCheckCircle /> </button> )} </div> </div> </> )} </div> ); }

文件路径:/types/task.ts

export interface Task { id: number; user_id: string; title: string; description: string | null; status: 'todo' | 'in_progress' | 'done'; created_at: string; }

4. 部署到Vercel

至此,一个功能完整的全栈应用已经完成。现在,我们将其免费部署到线上。

  1. 推送代码到GitHub:在项目根目录初始化Git仓库,并将代码推送到你的GitHub仓库。

    git init git add . git commit -m "Initial commit: Fullstack task board app" # 关联你的GitHub远程仓库并推送 git remote add origin https://github.com/你的用户名/my-task-board.git git branch -M main git push -u origin main
  2. 部署到Vercel

    • 访问 Vercel官网 并使用GitHub账号登录。
    • 点击“Add New...” -> “Project”,导入你刚创建的GitHub仓库。
    • 在配置页面,Vercel会自动检测为Next.js项目。你需要添加在本地.env.local中配置的两个环境变量:
      • NEXT_PUBLIC_SUPABASE_URL
      • NEXT_PUBLIC_SUPABASE_ANON_KEY
    • 点击“Deploy”。几分钟后,你的应用就会拥有一个*.vercel.app的免费域名,并可通过全球CDN访问。
  3. 配置Supabase认证重定向:部署完成后,你需要回到Supabase控制台,进入Authentication -> URL Configuration

    • 在“Site URL”中填入你的Vercel应用地址(如https://my-task-board.vercel.app)。
    • 在“Redirect URLs”中添加你的应用地址和回调地址(如https://my-task-board.vercel.app/auth/callback)。
    • 保存设置。这样,OAuth登录和邮箱确认链接才能正确跳转回你的线上应用。

5. 常见问题与排查思路

在开发和部署过程中,你可能会遇到以下问题:

问题现象可能原因排查与解决思路
本地运行时报错NEXT_PUBLIC_SUPABASE_URL is not defined环境变量未正确加载1. 检查.env.local文件是否存在且位于项目根目录。
2. 确认变量名拼写无误。
3. 重启开发服务器 (npm run dev)。
用户登录后,无法看到或操作任务1. 行级安全策略(RLS)未启用或策略错误。
2. 前端未获取到用户会话。
1. 登录Supabase控制台,确认tasks表的RLS已启用,且策略正确(auth.uid() = user_id)。
2. 在前端组件中使用supabase.auth.getUser()supabase.auth.getSession()检查是否成功获取用户对象。
部署到Vercel后,应用无法连接数据库Vercel环境变量未设置或设置错误。1. 登录Vercel项目控制台,进入Settings -> Environment Variables,确认已添加NEXT_PUBLIC_SUPABASE_URLNEXT_PUBLIC_SUPABASE_ANON_KEY
2. 确保值与本地.env.local一致,且没有多余的空格。
3. 重新部署项目。
点击登录/注册按钮无反应或报错Supabase Auth重定向URL配置错误。1. 检查Supabase控制台Authentication -> URL Configuration
2. 确保“Site URL”和“Redirect URLs”包含你应用的完整线上地址(Vercel分配的域名)。
3. 本地开发时,Redirect URLs应包含http://localhost:3000/auth/callback
任务列表不更新或操作后UI无变化前端状态未及时同步。1. 确保在增删改查操作成功后,手动调用fetchTasks或使用Supabase的实时订阅功能。
2. 检查网络请求,在浏览器开发者工具的“Network”标签页查看API请求是否成功。

6. 最佳实践与进阶建议

这个“免费”全栈项目虽然简单,但遵循了现代Web开发的核心模式。要将其用于更严肃的项目,可以考虑以下优化:

  1. 类型安全:我们已经使用了TypeScript。可以进一步利用Supabase CLI生成完整的数据库类型定义,实现端到端的类型安全。

    npx supabase gen types typescript --project-id your-project-ref --schema public > lib/database.types.ts

    然后,在创建客户端时传入类型:

    import { Database } from '@/lib/database.types'; export const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey);
  2. 状态管理:对于更复杂的应用,可以考虑使用Zustand、Jotai或React Context来集中管理用户状态和任务状态,避免属性钻取。

  3. API安全:虽然我们使用了RLS保护数据库,但对于更敏感的操作,应考虑在Serverless Function(如Next.js API Route)中进行额外的业务逻辑验证和权限检查,而不仅依赖前端调用。

  4. 性能优化

    • 图片优化:使用Next.js的<Image>组件或Supabase Storage进行图片处理和CDN分发。
    • 数据库查询:为频繁查询的字段(如user_id,status)创建索引。
    • 静态生成:对于公开、不常变的内容,使用Next.js的getStaticProps进行静态生成。
  5. 监控与日志:利用Vercel的Analytics和Supabase的Logs功能,监控应用性能和错误。对于关键操作,可以记录审计日志。

  6. 免费额度管理:密切关注Supabase和Vercel的免费额度使用情况(如数据库空间、带宽、函数调用次数)。制定计划,当项目增长时,如何平滑迁移到付费计划或替代方案。

通过这个项目,你不仅实践了React、Next.js、Tailwind CSS和Supabase的整合,更重要的是掌握了如何利用当今强大的免费云服务生态,以极低的成本验证想法、构建原型甚至运行小型生产应用。这正是开发者世界里的“免费”盛宴——核心工具和基础设施的边际成本正在趋近于零,创造力成为了最大的限制因素。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询