Next.js 入门进阶——与数据库相连
引言
大家好!今天我们来聊聊如何让 Next.js 应用与数据库"牵手成功"🤝。在现代 Web 开发中,前端页面与后端数据的结合是必不可少的一环。而 Next.js 作为全栈框架,提供了优雅的方式来实现这一目标。
本文将带你使用 Next.js 和 Prisma(一个现代化的 ORM 工具)构建一个简单的待办事项应用,让你轻松掌握数据库连接的秘诀!
基础概念
什么是 Supabase?
虽然本文不使用 Supabase,但值得一提的是它作为一个开源替代 Firebase 的后端即服务(BaaS)平台,提供了数据库、认证、存储等功能,与 Next.js 也能完美配合。不过今天我们的主角是更传统的自主数据库方案。
数据库开发与 ORM 工具
直接写 SQL 语句操作数据库虽然强大,但往往繁琐且容易出错。这时候 ORM(Object-Relational Mapping)工具就派上用场了!
ORM 的优势:
- 不需要写原生 SQL,像操作对象一样操作数据库
- 提高开发效率,减少重复代码
- 更好的类型安全和代码提示
- 数据库迁移和版本控制
Prisma:数据库的工程化利器
Prisma 是一个现代化的数据库工具包,它包括:
- Prisma Client: 自动生成且类型安全的查询构建器
- Prisma Migrate: 声明式数据建模和迁移系统
- Prisma Studio: 可视化数据库浏览器
Prisma 不仅帮助我们操作数据库,还为数据库变更留下了清晰的历史记录,让团队协作更加顺畅。
实战:构建一个简单的待办事项应用
技术栈选择
- Next.js: 14+ (App Router)
- Prisma: 作为 ORM 工具
- MySQL: 数据库(也可替换为 PostgreSQL 或 SQLite)
项目初始化
首先,让我们创建一个新的 Next.js 项目:
npx create-next-app@latest my-todos
在提示中选择以下配置:
完成后,进入项目目录并安装 Prisma 相关依赖:
cd my-todos
pnpm install prisma @prisma/client
数据库配置
1. 设置环境变量
在项目根目录下的 .env 文件中,添加数据库连接字符串:
DATABASE_URL="mysql://root:your_password@localhost:3306/todos_db"
注意:这里的数据库需要是新建的,不要使用已有数据的数据库,因为 Prisma 迁移会修改数据库结构,可能导致数据丢失。
2. 初始化 Prisma
运行以下命令初始化 Prisma:
npx prisma init
这个命令会:
- 创建
prisma目录 - 在
prisma目录下生成schema.prisma文件 - 检查
.env文件是否存在,如果不存在则创建
3. 配置数据模型
编辑 prisma/schema.prisma 文件,定义我们的待办事项模型:
// 指定使用 Prisma 的客户端生成器
generator client {
provider = "prisma-client-js"
}
// 配置数据源(数据库连接)
datasource db {
provider = "mysql" // 也可以是 "postgresql" 或 "sqlite"
url = env("DATABASE_URL") // 从环境变量读取数据库连接字符串
}
// 定义 Todo 模型(对应数据库中的表)
model Todo {
id Int @id @default(autoincrement()) // 主键,自增
title String // 待办事项标题
completed Boolean @default(false) // 完成状态,默认为 false
createdAt DateTime @default(now()) // 创建时间,默认为当前时间
}
4. 运行数据库迁移
现在运行迁移命令,将我们的数据模型应用到数据库:
npx prisma migrate dev --name init
这个命令会:
- 在
prisma/migrations目录下创建迁移文件 - 执行 SQL 语句在数据库中创建表
- 生成 Prisma Client
为什么创建了两个表?
Prisma 迁移实际上创建了两个表:
Todo表:存储我们的待办事项数据_prisma_migrations表:Prisma 内部使用,用于记录已执行的迁移,确保数据库架构与代码定义保持一致
后端 API 实现
Next.js 使用 App Router 后,API 路由放在了 app/api 目录下。每个子目录代表一个路由端点,其中的 route.ts 文件定义了处理 HTTP 方法的函数。
1. 获取所有待办事项和创建新待办事项
创建 src/app/api/todos/route.ts:
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// 处理 POST 请求 - 创建新待办事项
export async function POST(req: Request) {
try {
const { title } = await req.json();
// 输入验证
if (!title || title.trim() === '') {
return NextResponse.json(
{ error: '标题不能为空' },
{ status: 400 }
);
}
const todo = await prisma.todo.create({
data: {
title: title.trim()
}
});
return NextResponse.json(todo);
} catch (error) {
return NextResponse.json(
{ error: '创建待办事项失败' },
{ status: 500 }
);
}
}
// 处理 GET 请求 - 获取所有待办事项
export async function GET() {
try {
const todos = await prisma.todo.findMany({
orderBy: {
createdAt: "desc" // 按创建时间降序排列
}
});
return NextResponse.json(todos);
} catch (error) {
return NextResponse.json(
{ error: '获取待办事项失败' },
{ status: 500 }
);
}
}
2. 更新和删除待办事项
创建 src/app/api/todos/[id]/route.ts:
[id] 目录的作用:Next.js 使用方括号 [] 表示动态路由段。这里的 [id] 可以匹配任何值,并通过 params.id 访问。
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// 处理 PATCH 请求 - 更新待办事项状态
export async function PATCH(
req: Request,
{ params }: { params: { id: string } }
) {
try {
const { completed } = await req.json();
// 验证 ID 是否有效
if (isNaN(Number(params.id))) {
return NextResponse.json(
{ error: '无效的待办事项 ID' },
{ status: 400 }
);
}
const todo = await prisma.todo.update({
where: { id: Number(params.id) },
data: { completed }
});
return NextResponse.json(todo);
} catch (error) {
return NextResponse.json(
{ error: '更新待办事项失败' },
{ status: 500 }
);
}
}
// 处理 DELETE 请求 - 删除待办事项
export async function DELETE(
req: Request,
{ params }: { params: { id: string } }
) {
try {
// 验证 ID 是否有效
if (isNaN(Number(params.id))) {
return NextResponse.json(
{ error: '无效的待办事项 ID' },
{ status: 400 }
);
}
const todo = await prisma.todo.delete({
where: { id: Number(params.id) }
});
return NextResponse.json(todo);
} catch (error) {
return NextResponse.json(
{ error: '删除待办事项失败' },
{ status: 500 }
);
}
}
现在,你可以使用 API 测试工具(如 Apifox、Postman 或 Thunder Client)测试这些接口是否正常工作。
前端界面实现
类型定义
首先,创建类型定义文件 src/types/todo.ts:
export interface Todo {
id: number;
title: string;
completed: boolean;
createdAt: string;
}
主页面组件
编辑 src/app/page.tsx:
'use client' 的作用:Next.js 默认是服务端组件,但我们需要使用 React 的 useState、useEffect 等客户端特性,所以需要在文件顶部添加 'use client' 指令,告知 Next.js 这是一个客户端组件。
'use client';
import { useEffect, useState } from 'react';
import { Todo } from '@/types/todo';
export default function Home() {
const [todos, setTodos] = useState<Todo[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
// 组件挂载时获取待办事项列表
useEffect(() => {
fetchTodos();
}, []);
// 获取所有待办事项
const fetchTodos = async () => {
try {
setLoading(true);
const res = await fetch('/api/todos');
if (!res.ok) throw new Error('获取数据失败');
const data = await res.json();
setTodos(data);
} catch (error) {
console.error('Error fetching todos:', error);
alert('获取待办事项失败');
} finally {
setLoading(false);
}
};
// 添加新待办事项
const addTodo = async () => {
if (input.trim() === '') {
alert('请输入待办事项内容');
return;
}
try {
const res = await fetch('/api/todos', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: input,
}),
});
if (!res.ok) throw new Error('添加失败');
const newTodo = await res.json();
setTodos([newTodo, ...todos]);
setInput('');
} catch (error) {
console.error('Error adding todo:', error);
alert('添加待办事项失败');
}
};
// 删除待办事项
const deleteTodo = async (id: number) => {
if (!confirm('确定要删除这个待办事项吗?')) return;
try {
const res = await fetch(`/api/todos/${id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error('删除失败');
setTodos(todos.filter(todo => todo.id !== id));
} catch (error) {
console.error('Error deleting todo:', error);
alert('删除待办事项失败');
}
};
// 切换待办事项完成状态
const toggleTodo = async (id: number, completed: boolean) => {
try {
const res = await fetch(`/api/todos/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
completed: !completed,
}),
});
if (!res.ok) throw new Error('更新失败');
const updatedTodo = await res.json();
setTodos(todos.map(todo =>
todo.id === id ? updatedTodo : todo
));
} catch (error) {
console.error('Error updating todo:', error);
alert('更新待办事项失败');
}
};
return (
<main className="min-h-screen bg-gray-100 py-8">
<div className="max-w-xl mx-auto bg-white p-6 rounded-lg shadow-md">
<h1 className="text-2xl font-bold text-gray-800 mb-6">我的待办事项</h1>
{/* 添加新待办事项的表单 */}
<div className="flex gap-2 mb-6">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && addTodo()}
placeholder="添加新的待办事项..."
className="flex-1 p-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={loading}
/>
<button
onClick={addTodo}
disabled={loading}
className="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 disabled:opacity-50"
>
添加
</button>
</div>
{/* 待办事项列表 */}
{loading && todos.length === 0 ? (
<div className="text-center py-4">加载中...</div>
) : todos.length === 0 ? (
<div className="text-center py-4 text-gray-500">
暂无待办事项,添加一个吧!
</div>
) : (
<ul className="space-y-3">
{todos.map(todo => (
<li
key={todo.id}
className="flex justify-between items-center p-3 border border-gray-200 rounded-md hover:bg-gray-50"
>
<div className="flex items-center">
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id, todo.completed)}
className="h-5 w-5 text-blue-500 rounded focus:ring-blue-400 mr-3"
/>
<span
className={`cursor-pointer select-none ${
todo.completed ? 'line-through text-gray-500' : 'text-gray-800'
}`}
onClick={() => toggleTodo(todo.id, todo.completed)}
>
{todo.title}
</span>
</div>
<button
onClick={() => deleteTodo(todo.id)}
disabled={loading}
className="text-red-500 hover:text-red-700 disabled:opacity-50"
title="删除待办事项"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</li>
))}
</ul>
)}
</div>
</main>
);
}
总结
通过这个简单的待办事项应用,我们学习了:
-
Prisma 的基本概念和使用方法:
- 使用
npx prisma init初始化 Prisma - 定义数据模型并在
schema.prisma中配置 - 使用
npx prisma migrate dev --name init创建数据库迁移
- 使用
-
Next.js API 路由的实现:
- 在
app/api目录下创建后端接口 - 使用动态路由
[id]处理单个资源的操作
- 在
-
前端与后端的交互:
- 使用
fetchAPI 调用后端接口 - 客户端组件需要使用
'use client'指令 - 实现 CRUD(创建、读取、更新、删除)操作
- 使用
-
错误处理和用户体验:
- 添加加载状态和错误提示
- 用户操作确认机制
这个项目虽然简单,但涵盖了 Next.js 与数据库相连的核心概念。你可以在此基础上继续扩展,比如添加用户认证、分类标签、截止日期等功能。
提示:本文所有代码已上传至 GitHub 仓库,欢迎 Star 和 Fork!