最近在技术社区看到不少关于“未来一切或将免费”的讨论,这让我联想到在软件开发领域,我们其实已经部分实现了这种“免费”的愿景——通过开源软件、云计算免费额度、以及各种强大的免费工具链。对于开发者而言,掌握如何高效利用这些“免费”资源,构建稳定、可扩展的应用,是一项核心能力。本文将以一个全栈Web应用为例,手把手带你体验从零开始,不花一分钱,利用主流免费服务和框架,完成一个具备用户认证、数据存储和前端展示的完整项目。无论你是想学习现代全栈开发,还是希望为自己的创意项目寻找低成本启动方案,这篇实战指南都将提供清晰的路径和可复现的代码。
1. 项目概述与技术选型
我们的目标是构建一个简单的“个人任务看板”应用。用户可以进行注册、登录,创建、查看、更新和删除自己的任务卡片。
为了实现“零成本”,我们需要精心挑选技术栈:
- 前端:Vite + React。开发体验极佳,构建速度快,生态丰富。我们将使用纯函数组件和Hooks进行开发。
- 后端/Serverless:Vercel + 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 snippets、Tailwind CSS IntelliSense等插件以提升开发效率。 - Git:用于版本控制和部署。确保已安装并配置好全局用户信息。
git --version git config --global user.name "Your Name" git config --global user.email "your.email@example.com"
2.2 创建Supabase项目
- 访问 Supabase官网 并使用GitHub账号注册登录。
- 点击“New Project”。
- 填写项目名称(如
my-task-board),设置数据库密码(请妥善保存),选择离你较近的区域以获得更低的网络延迟。 - 点击“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 Key3.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
至此,一个功能完整的全栈应用已经完成。现在,我们将其免费部署到线上。
推送代码到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部署到Vercel:
- 访问 Vercel官网 并使用GitHub账号登录。
- 点击“Add New...” -> “Project”,导入你刚创建的GitHub仓库。
- 在配置页面,Vercel会自动检测为Next.js项目。你需要添加在本地
.env.local中配置的两个环境变量:NEXT_PUBLIC_SUPABASE_URLNEXT_PUBLIC_SUPABASE_ANON_KEY
- 点击“Deploy”。几分钟后,你的应用就会拥有一个
*.vercel.app的免费域名,并可通过全球CDN访问。
配置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登录和邮箱确认链接才能正确跳转回你的线上应用。
- 在“Site URL”中填入你的Vercel应用地址(如
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_URL和NEXT_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开发的核心模式。要将其用于更严肃的项目,可以考虑以下优化:
类型安全:我们已经使用了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);状态管理:对于更复杂的应用,可以考虑使用Zustand、Jotai或React Context来集中管理用户状态和任务状态,避免属性钻取。
API安全:虽然我们使用了RLS保护数据库,但对于更敏感的操作,应考虑在Serverless Function(如Next.js API Route)中进行额外的业务逻辑验证和权限检查,而不仅依赖前端调用。
性能优化:
- 图片优化:使用Next.js的
<Image>组件或Supabase Storage进行图片处理和CDN分发。 - 数据库查询:为频繁查询的字段(如
user_id,status)创建索引。 - 静态生成:对于公开、不常变的内容,使用Next.js的
getStaticProps进行静态生成。
- 图片优化:使用Next.js的
监控与日志:利用Vercel的Analytics和Supabase的Logs功能,监控应用性能和错误。对于关键操作,可以记录审计日志。
免费额度管理:密切关注Supabase和Vercel的免费额度使用情况(如数据库空间、带宽、函数调用次数)。制定计划,当项目增长时,如何平滑迁移到付费计划或替代方案。
通过这个项目,你不仅实践了React、Next.js、Tailwind CSS和Supabase的整合,更重要的是掌握了如何利用当今强大的免费云服务生态,以极低的成本验证想法、构建原型甚至运行小型生产应用。这正是开发者世界里的“免费”盛宴——核心工具和基础设施的边际成本正在趋近于零,创造力成为了最大的限制因素。