4.3 编写后端服务
前面学了调用别人的 API 和操作数据库,现在学自己写后端服务。
学完这节你会:
- 理解什么时候需要自己写后端
- 会创建 Next.js API Routes
- 会处理表单提交、文件上传
- 会集成第三方服务(邮件、支付等)
- 掌握 API 设计最佳实践
为什么需要自己写后端?
前面我们学了两种方式:
- 调用第三方 API(如天气、AI)
- 操作数据库(增删改查)
但有些场景,这两种方式解决不了:
需要自己写后端的场景:
-
复杂业务逻辑
- 用户注册时需要发邮件 + 创建数据库记录 + 赠送优惠券
-
数据处理
- 上传图片后需要压缩、加水印、生成缩略图
-
安全保护
- 支付接口不能在前端直接调用,会泄露密钥
-
多个服务整合
- 一个接口调用多个第三方 API,再组合结果返回
-
定时任务
- 每天凌晨清理过期数据
核心原则:前端不安全,敏感操作必须在后端处理!
Next.js API Routes 基础
什么是 API Route?
API Route 就是你自己的后端接口,就像餐厅里的厨房。
比喻:
- 第三方 API = 去别人餐厅点菜
- 自己的 API Route = 你家厨房做菜
- 数据库 = 冰箱里的食材
客人(前端)在你家点菜,你的厨房(API Route)从冰箱(数据库)拿食材,再用外面买的调料(第三方 API),最后做出一道菜。
创建第一个 API Route
在 Next.js 中,创建 app/api/ 下的文件就自动成为 API 接口:
app/
├── api/
│ ├── hello/
│ │ └── route.ts # 接口地址: /api/hello
│ ├── users/
│ │ └── route.ts # 接口地址: /api/users
│ └── upload/
│ └── route.ts # 接口地址: /api/upload最简单的例子 - 创建 app/api/hello/route.ts:
import { NextRequest, NextResponse } from 'next/server'
// GET /api/hello
export async function GET(request: NextRequest) {
return NextResponse.json({
message: 'Hello from API!'
})
}
// POST /api/hello
export async function POST(request: NextRequest) {
const body = await request.json()
return NextResponse.json({
message: `You sent: ${body.name}`
})
}测试:
# GET 请求
curl http://localhost:3000/api/hello
# POST 请求
curl -X POST http://localhost:3000/api/hello \
-H "Content-Type: application/json" \
-d '{"name":"Ben"}'实战 1:表单提交与邮件通知
做一个联系表单,用户提交后:
- 保存到数据库
- 发送邮件通知你
步骤 1:安装依赖
ppnpm install @supabase/supabase-js nodemailer
ppnpm install -D @types/nodemailer步骤 2:配置环境变量
在 .env.local 添加:
# Supabase(之前已配置)
NEXT_PUBLIC_SUPABASE_URL=你的项目URL
NEXT_PUBLIC_SUPABASE_ANON_KEY=你的公钥
# 邮件配置(使用 Gmail 为例)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=[email protected]
SMTP_PASS=your-app-password # 不是邮箱密码!需要在 Gmail 设置里生成应用专用密码
EMAIL_FROM=[email protected]
EMAIL_TO=[email protected]Gmail 应用专用密码设置:
- 访问 https://myaccount.google.com/security
- 开启「两步验证」
- 搜索「应用专用密码」
- 生成一个新密码(16 位),复制到
SMTP_PASS
步骤 3:创建数据库表
在 Supabase SQL Editor 执行:
create table contacts (
id uuid default gen_random_uuid() primary key,
name text not null,
email text not null,
message text not null,
created_at timestamp with time zone default timezone('utc'::text, now()) not null
);
-- 允许匿名插入(因为是公开表单)
alter table contacts enable row level security;
create policy "Allow anonymous insert"
on contacts for insert
to anon
with check (true);步骤 4:创建 API Route
创建 app/api/contact/route.ts:
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'
import nodemailer from 'nodemailer'
// Supabase 客户端
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
// 邮件发送器
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
secure: false,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
})
export async function POST(request: NextRequest) {
try {
const { name, email, message } = await request.json()
// 1. 验证数据
if (!name || !email || !message) {
return NextResponse.json(
{ error: '所有字段都必填' },
{ status: 400 }
)
}
// 2. 保存到数据库
const { data, error: dbError } = await supabase
.from('contacts')
.insert([{ name, email, message }])
.select()
if (dbError) {
console.error('数据库错误:', dbError)
return NextResponse.json(
{ error: '保存失败' },
{ status: 500 }
)
}
// 3. 发送邮件通知
try {
await transporter.sendMail({
from: process.env.EMAIL_FROM,
to: process.env.EMAIL_TO,
subject: `新的联系表单:${name}`,
html: `
<h2>新的联系表单提交</h2>
<p><strong>姓名:</strong>${name}</p>
<p><strong>邮箱:</strong>${email}</p>
<p><strong>留言:</strong></p>
<p>${message.replace(/\n/g, '<br>')}</p>
`,
})
} catch (emailError) {
console.error('邮件发送失败:', emailError)
// 邮件失败不影响整体流程,只记录错误
}
return NextResponse.json({
success: true,
message: '提交成功,我们会尽快回复!',
data: data[0]
})
} catch (error) {
console.error('请求处理失败:', error)
return NextResponse.json(
{ error: '服务器错误' },
{ status: 500 }
)
}
}步骤 5:创建前端表单
创建 app/contact/page.tsx:
'use client'
import { useState } from 'react'
export default function ContactPage() {
const [formData, setFormData] = useState({
name: '',
email: '',
message: ''
})
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<{type: 'success' | 'error', message: string} | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setResult(null)
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
})
const data = await res.json()
if (res.ok) {
setResult({ type: 'success', message: data.message })
setFormData({ name: '', email: '', message: '' }) // 清空表单
} else {
setResult({ type: 'error', message: data.error || '提交失败' })
}
} catch (error) {
setResult({ type: 'error', message: '网络错误,请重试' })
}
setLoading(false)
}
return (
<div className="max-w-2xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-6">联系我们</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block mb-2 font-medium">姓名</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({...formData, name: e.target.value})}
className="w-full border rounded p-2"
required
/>
</div>
<div>
<label className="block mb-2 font-medium">邮箱</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({...formData, email: e.target.value})}
className="w-full border rounded p-2"
required
/>
</div>
<div>
<label className="block mb-2 font-medium">留言</label>
<textarea
value={formData.message}
onChange={(e) => setFormData({...formData, message: e.target.value})}
className="w-full border rounded p-2 h-32"
required
/>
</div>
<button
type="submit"
disabled={loading}
className="bg-blue-500 text-white px-6 py-2 rounded disabled:opacity-50"
>
{loading ? '提交中...' : '提交'}
</button>
</form>
{result && (
<div className={`mt-4 p-4 rounded ${
result.type === 'success' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{result.message}
</div>
)}
</div>
)
}步骤 6:测试
- 启动开发服务器:
pnpm run dev - 访问
http://localhost:3000/contact - 填写表单提交
- 检查:
- Supabase 数据库是否有新记录
- 你的邮箱是否收到通知邮件
实战 2:图片上传与处理
实现一个图片上传功能,上传后自动:
- 压缩图片
- 生成缩略图
- 保存到云存储
步骤 1:安装依赖
ppnpm install sharp # 图片处理库步骤 2:创建上传 API
创建 app/api/upload/route.ts:
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'
import sharp from 'sharp'
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
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 }
)
}
// 1. 读取文件
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
// 2. 压缩图片(最大宽度 1200px,质量 80%)
const compressedImage = await sharp(buffer)
.resize(1200, null, {
fit: 'inside',
withoutEnlargement: true
})
.jpeg({ quality: 80 })
.toBuffer()
// 3. 生成缩略图(200x200)
const thumbnail = await sharp(buffer)
.resize(200, 200, {
fit: 'cover'
})
.jpeg({ quality: 70 })
.toBuffer()
// 4. 生成文件名
const timestamp = Date.now()
const originalName = file.name.replace(/\.[^/.]+$/, '') // 去掉扩展名
const mainFileName = `${timestamp}-${originalName}.jpg`
const thumbFileName = `${timestamp}-${originalName}-thumb.jpg`
// 5. 上传到 Supabase Storage
const { data: mainData, error: mainError } = await supabase.storage
.from('images') // 需要先在 Supabase 创建这个 bucket
.upload(mainFileName, compressedImage, {
contentType: 'image/jpeg'
})
if (mainError) {
console.error('主图上传失败:', mainError)
return NextResponse.json(
{ error: '上传失败' },
{ status: 500 }
)
}
// 6. 上传缩略图
await supabase.storage
.from('images')
.upload(thumbFileName, thumbnail, {
contentType: 'image/jpeg'
})
// 7. 获取公开访问 URL
const { data: { publicUrl: mainUrl } } = supabase.storage
.from('images')
.getPublicUrl(mainFileName)
const { data: { publicUrl: thumbUrl } } = supabase.storage
.from('images')
.getPublicUrl(thumbFileName)
return NextResponse.json({
success: true,
urls: {
main: mainUrl,
thumbnail: thumbUrl
},
info: {
originalSize: buffer.length,
compressedSize: compressedImage.length,
compressionRatio: ((1 - compressedImage.length / buffer.length) * 100).toFixed(2) + '%'
}
})
} catch (error) {
console.error('上传处理失败:', error)
return NextResponse.json(
{ error: '服务器错误' },
{ status: 500 }
)
}
}步骤 3:创建上传界面
创建 app/upload/page.tsx:
'use client'
import { useState } from 'react'
export default function UploadPage() {
const [file, setFile] = useState<File | null>(null)
const [preview, setPreview] = useState<string>('')
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<any>(null)
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0]
if (selectedFile) {
setFile(selectedFile)
setPreview(URL.createObjectURL(selectedFile))
}
}
const handleUpload = async () => {
if (!file) return
setLoading(true)
const formData = new FormData()
formData.append('file', file)
try {
const res = await fetch('/api/upload', {
method: 'POST',
body: formData
})
const data = await res.json()
if (res.ok) {
setResult(data)
} else {
alert(data.error || '上传失败')
}
} catch (error) {
alert('上传失败')
}
setLoading(false)
}
return (
<div className="max-w-2xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-6">图片上传</h1>
<div className="space-y-4">
<input
type="file"
accept="image/*"
onChange={handleFileChange}
className="block"
/>
{preview && (
<div>
<h3 className="font-medium mb-2">预览:</h3>
<img src={preview} alt="预览" className="max-w-md border rounded" />
</div>
)}
<button
onClick={handleUpload}
disabled={!file || loading}
className="bg-blue-500 text-white px-6 py-2 rounded disabled:opacity-50"
>
{loading ? '上传中...' : '上传'}
</button>
{result && (
<div className="bg-green-100 p-4 rounded">
<h3 className="font-bold mb-2">上传成功!</h3>
<p className="mb-2">压缩率:{result.info.compressionRatio}</p>
<div className="space-y-2">
<div>
<p className="font-medium">原图:</p>
<img src={result.urls.main} alt="原图" className="max-w-md border" />
</div>
<div>
<p className="font-medium">缩略图:</p>
<img src={result.urls.thumbnail} alt="缩略图" className="border" />
</div>
</div>
</div>
)}
</div>
</div>
)
}步骤 4:配置 Supabase Storage
- 访问 Supabase 项目 → Storage
- 创建新 bucket:
images - 设置为 Public(允许公开访问)
- 设置 CORS 允许你的域名
API 设计最佳实践
1. RESTful 风格
使用标准的 HTTP 方法:
// app/api/users/route.ts
// 获取所有用户
export async function GET(request: NextRequest) {
// ...
}
// 创建新用户
export async function POST(request: NextRequest) {
// ...
}
// 更新用户
export async function PUT(request: NextRequest) {
// ...
}
// 删除用户
export async function DELETE(request: NextRequest) {
// ...
}2. 统一返回格式
成功响应:
{
"success": true,
"data": { ... },
"message": "操作成功"
}错误响应:
{
"success": false,
"error": "错误信息",
"code": "ERROR_CODE"
}封装一个工具函数 - 创建 lib/api-response.ts:
import { NextResponse } from 'next/server'
export function apiSuccess(data: any, message?: string) {
return NextResponse.json({
success: true,
data,
message
})
}
export function apiError(error: string, code?: string, status = 400) {
return NextResponse.json(
{
success: false,
error,
code
},
{ status }
)
}使用示例:
import { apiSuccess, apiError } from '@/lib/api-response'
export async function POST(request: NextRequest) {
try {
// 验证失败
if (!data.email) {
return apiError('邮箱必填', 'EMAIL_REQUIRED')
}
// 业务逻辑...
// 成功
return apiSuccess(result, '创建成功')
} catch (error) {
return apiError('服务器错误', 'SERVER_ERROR', 500)
}
}3. 参数验证
推荐使用 Zod 做验证 - 安装:
pnpm install zod使用示例:
import { z } from 'zod'
import { apiError, apiSuccess } from '@/lib/api-response'
// 定义验证规则
const ContactSchema = z.object({
name: z.string().min(2, '姓名至少 2 个字符'),
email: z.string().email('邮箱格式错误'),
message: z.string().min(10, '留言至少 10 个字符')
})
export async function POST(request: NextRequest) {
try {
const body = await request.json()
// 验证参数
const result = ContactSchema.safeParse(body)
if (!result.success) {
return apiError(
result.error.errors[0].message,
'VALIDATION_ERROR'
)
}
const { name, email, message } = result.data
// 业务逻辑...
return apiSuccess({ id: 123 }, '提交成功')
} catch (error) {
return apiError('服务器错误', 'SERVER_ERROR', 500)
}
}4. 错误处理
分层处理错误:
export async function POST(request: NextRequest) {
try {
// 1. 参数验证错误
if (!data.email) {
return apiError('邮箱必填', 'EMAIL_REQUIRED', 400)
}
// 2. 业务逻辑错误
const user = await findUser(data.email)
if (user) {
return apiError('邮箱已存在', 'EMAIL_EXISTS', 409)
}
// 3. 第三方服务错误
try {
await sendEmail(data.email)
} catch (emailError) {
console.error('邮件发送失败:', emailError)
// 邮件失败不影响主流程,记录日志即可
}
return apiSuccess(newUser)
} catch (error) {
// 4. 未知错误
console.error('未知错误:', error)
return apiError('服务器错误', 'SERVER_ERROR', 500)
}
}5. 日志记录
创建日志工具 - lib/logger.ts:
export const logger = {
info: (message: string, data?: any) => {
console.log(`[INFO] ${new Date().toISOString()} - ${message}`, data || '')
},
error: (message: string, error?: any) => {
console.error(`[ERROR] ${new Date().toISOString()} - ${message}`, error || '')
},
warn: (message: string, data?: any) => {
console.warn(`[WARN] ${new Date().toISOString()} - ${message}`, data || '')
}
}使用:
import { logger } from '@/lib/logger'
export async function POST(request: NextRequest) {
logger.info('收到新的联系表单', { ip: request.ip })
try {
// ...
logger.info('表单提交成功', { email: data.email })
return apiSuccess(result)
} catch (error) {
logger.error('表单提交失败', error)
return apiError('服务器错误', 'SERVER_ERROR', 500)
}
}安全注意事项
API 安全检查清单:
-
✅ 验证所有输入
- 使用 Zod 等工具验证参数
- 防止 SQL 注入、XSS 攻击
-
✅ API Key 放服务端
- 绝不在客户端暴露敏感密钥
- 使用环境变量(不带
NEXT_PUBLIC_前缀)
-
✅ 限流保护
- 防止暴力破解、DDoS 攻击
- 可以用
upstash/ratelimit
-
✅ 身份认证
- 需要登录的接口要验证 Token
- 使用 NextAuth.js 或 Supabase Auth
-
✅ CORS 配置
- 只允许信任的域名访问
-
✅ 日志记录
- 记录所有错误和异常操作
- 不要在日志里记录密码、Token 等敏感信息
限流保护示例
防止用户疯狂调用你的 API:
安装依赖
pnpm install @upstash/ratelimit @upstash/redis配置 Upstash
- 访问 https://upstash.com/ 注册
- 创建 Redis 数据库
- 复制
UPSTASH_REDIS_REST_URL和UPSTASH_REDIS_REST_TOKEN - 添加到
.env.local
创建限流工具
创建 lib/rate-limit.ts:
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
// 创建限流器:每 60 秒最多 10 次请求
export const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '60 s'),
analytics: true,
})在 API 中使用
import { ratelimit } from '@/lib/rate-limit'
import { apiError } from '@/lib/api-response'
export async function POST(request: NextRequest) {
// 限流检查(用 IP 地址作为标识)
const ip = request.ip ?? '127.0.0.1'
const { success, limit, remaining } = await ratelimit.limit(ip)
if (!success) {
return apiError(
`请求过于频繁,请 ${Math.ceil((limit - Date.now()) / 1000)} 秒后重试`,
'RATE_LIMIT_EXCEEDED',
429
)
}
// 正常处理请求...
return NextResponse.json({
success: true,
rateLimit: {
limit: limit,
remaining: remaining
}
})
}常见问题
1. API Route 和 Server Component 的区别?
| 特性 | API Route | Server Component |
|---|---|---|
| 用途 | 提供 API 接口给前端调用 | 直接在服务端渲染页面 |
| 调用方式 | fetch('/api/xxx') | 直接写逻辑,不需要 fetch |
| 适用场景 | 前端需要动态调用、表单提交 | 页面初始加载、SEO 优化 |
何时用 API Route?
- 表单提交
- 文件上传
- 需要前端动态调用
- 第三方 webhook 回调
何时用 Server Component?
- 页面初始数据加载
- SEO 优化
- 不需要前端交互的数据
2. 如何处理大文件上传?
默认 Next.js 限制请求体大小为 1MB。需要修改配置:
next.config.mjs:
export default {
api: {
bodyParser: {
sizeLimit: '10mb', // 设置为 10MB
},
},
}对于超大文件(如视频),建议:
- 使用分片上传
- 或直接从前端上传到云存储(如 Supabase Storage、AWS S3)
3. 如何测试 API?
方法 1:使用 curl
curl -X POST http://localhost:3000/api/contact \
-H "Content-Type: application/json" \
-d '{"name":"Test","email":"[email protected]","message":"Hello"}'方法 2:使用 Postman
- 下载 Postman
- 创建新请求
- 设置方法(GET/POST)和 URL
- 设置 Headers 和 Body
- 发送请求查看结果
方法 3:使用 VS Code REST Client 扩展
创建 test.http:
### 测试联系表单
POST http://localhost:3000/api/contact
Content-Type: application/json
{
"name": "Test User",
"email": "[email protected]",
"message": "This is a test message"
}
### 测试获取用户
GET http://localhost:3000/api/users点击「Send Request」即可测试。
快速实践建议
Week 1:基础 API
- 做一个简单的 GET API 返回当前时间
- 做一个 POST API 接收数据并返回
Week 2:表单处理
- 实现联系表单(保存到数据库)
- 添加邮件通知功能
Week 3:文件上传
- 实现基础图片上传
- 添加图片压缩功能
Week 4:集成与优化
- 添加参数验证(Zod)
- 添加限流保护
- 统一错误处理
学习顺序:
- 先做通一个最简单的 API(Hello World)
- 再做表单提交(涉及数据库)
- 再做文件上传(稍微复杂)
- 最后加上验证、限流、日志等完善功能
不要一开始就追求完美,先跑通,再优化!
下一节:5.1 简单网站 - 用完整项目把前后端能力串起来!