Todo 应用是学习全栈开发的"Hello World",但它能完美地串联起现代 Web 开发的所有核心概念。我们将构建一个具备增删改查、实时更新等功能的单页面应用。

技术选型
- Next.js 15: 用于构建服务器渲染的 React 应用。
- TypeScript: 增加类型检查,提升代码质量。
- Tailwind CSS: 用于快速构建响应式界面。
- Drizzle ORM: 作为 ORM 管理数据库。
- PostgreSQL: 作为数据库存储 Todo 项。
- tRPC: 用于构建类型安全的 API。
- Zod: 用于验证和解析 API 请求参数。
- @tanstack/react-query: 用于管理客户端数据缓存和状态。
开发环境
- Node.js 18+: 确保安装最新版本的 Node.js。
- npm: 或使用 Yarn/PNPM 作为包管理器。
- PostgreSQL: 安装并运行 PostgreSQL 数据库。
- Drizzle ORM: 全局安装 Drizzle ORM 命令行工具。
用 Docker 起本地 PG(可选):
bash
docker run --name pg13 -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=app -p 5432:5432 -d postgres:13
连接串(示例):postgres://postgres:postgres@localhost:5432/app
项目结构
lua
.
├── drizzle
│ ├── meta
│ │ ├── 0000_snapshot.json
│ │ └── _journal.json
│ └── 0000_lively_gravity.sql
├── src
│ ├── app
│ │ ├── api/trpc/[trpc]
│ │ │ └── route.ts
│ │ ├── globals.css
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ └── trpc-provider.tsx
│ ├── server
│ │ ├── db
│ │ │ ├── index.ts
│ │ │ └── schema.ts
│ │ ├── routers
│ │ │ └── todo.ts
│ │ ├── root.ts
│ │ └── trpc.ts
│ └── trpc
│ └── react.ts
├── README.md
├── biome.json
├── drizzle.config.ts
├── next.config.ts
├── package-lock.json
├── package.json
├── pnpm-lock.yaml
├── postcss.config.mjs
└── tsconfig.json
项目初始化
bash
npx create-next-app@latest todo --ts
cd todo
选择 App Router、ESLint、Tailwind。
安装与配置 PostgreSQL、Drizzle 及相关依赖
bash
pnpm add drizzle-orm drizzle-kit pg zod @trpc/server @trpc/client @trpc/react-query @tanstack/react-query superjson
新增开发脚本(package.json)
json
{
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",
"lint": "biome check",
"format": "biome format --write",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
}
}
环境变量(.env):
bash
DATABASE_URL=postgres://postgres:postgres@localhost:5432/app
NODE_ENV=development
Drizzle 配置(drizzle.config.ts):
typescript
import type { Config } from "drizzle-kit";
export default {
schema: "./src/server/db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
} satisfies Config;
数据库连接(src/server/db/index.ts):
typescript
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export const db = drizzle(pool);
数据表
Schema(src/server/db/schema.ts):
typescript
import { pgTable, serial, varchar, boolean, timestamp } from "drizzle-orm/pg-core";
export const todos = pgTable("todos", {
id: serial("id").primaryKey(),
title: varchar("title", { length: 200 }).notNull(),
completed: boolean("completed").notNull().default(false),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
生成并执行迁移:
bash
pnpm db:generate
pnpm db:migrate
执行后会在 drizzle/ 目录看到迁移 SQL,并把表建好。 (可选)打开可视化:
bash
pnpm db:studio
初始化 tRPC(服务器与客户端)
-
服务器端基础(src/server/trpc.ts):
typescriptimport { initTRPC } from "@trpc/server"; import { ZodError } from "zod"; const t = initTRPC.context<{}>().create({ errorFormatter({ shape, error }) { return { code: -1, message: error.cause instanceof ZodError ? error.cause.issues.map((issue) => issue.message).join("") : shape.message, data: null, }; }, }); export const publicProcedure = t.procedure; export const router = t.router;
-
tRPC 适配 Next.js App Router(src/app/api/trpc/[trpc]/route.ts):
typescriptimport { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { appRouter } from "@/server/root"; const handler = (req: Request) => { return fetchRequestHandler({ endpoint: "/api/trpc", req, router: appRouter, }); }; export { handler as GET, handler as POST };
-
tRPC todo 路由(src/server/routers/todo.ts):
typescriptimport { desc, eq } from "drizzle-orm"; import { z } from "zod"; import { db } from "@/server/db"; import { todos } from "@/server/db/schema"; import { publicProcedure, router } from "@/server/trpc"; export const todoRouter = router({ list: publicProcedure.query(async () => { return db.select().from(todos).orderBy(desc(todos.createdAt)); }), create: publicProcedure .input(z.object({ title: z.string().min(1).max(200) })) .mutation(async ({ input }) => { const [row] = await db .insert(todos) .values({ title: input.title }) .returning(); return row; }), toggle: publicProcedure .input(z.object({ id: z.number(), completed: z.boolean() })) .mutation(async ({ input }) => { const [row] = await db .update(todos) .set({ completed: input.completed }) .where(eq(todos.id, input.id)) .returning(); return row; }), remove: publicProcedure .input(z.object({ id: z.number() })) .mutation(async ({ input }) => { await db.delete(todos).where(eq(todos.id, input.id)); return { ok: true }; }), });
-
tRPC 根接口(src/server/root.ts):
typescriptimport { todoRouter } from "./routers/todo"; import { router } from "./trpc"; export const appRouter = router({ todo: todoRouter, }); export type AppRouter = typeof appRouter;
-
客户端(src/trpc/react.ts):
typescriptimport { createTRPCReact } from "@trpc/react-query"; import type { AppRouter } from "@/server/root"; export const trpc = createTRPCReact<AppRouter>();
-
创建 tRPC Provider src/app/trpc-provider.tsx
typescript"use client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { httpBatchLink } from "@trpc/client"; import { type ReactNode, useState } from "react"; import { trpc } from "@/trpc/react"; export function TRPCProviders({ children }: { children: ReactNode }) { const [queryClient] = useState(() => new QueryClient()); const [trpcClient] = useState(() => trpc.createClient({ links: [ httpBatchLink({ url: "/api/trpc", }), ], }), ); return ( <trpc.Provider client={trpcClient} queryClient={queryClient}> <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> </trpc.Provider> ); }
-
在 app/layout.tsx 中挂载 Provider:
tsximport type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { TRPCProviders } from "./trpc-provider"; const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"], }); const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"], }); export const metadata: Metadata = { title: "Create Next App", description: "Generated by create next app", }; export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( <html lang="en"> <body className={`${geistSans.variable} ${geistMono.variable} antialiased`} > <TRPCProviders>{children}</TRPCProviders> </body> </html> ); }
Todo CRUD 端到端打通
-
前端页面(src/app/page.tsx):
tsx"use client"; import { useState } from "react"; import { trpc } from "@/trpc/react"; export default function HomePage() { const utils = trpc.useUtils(); const [title, setTitle] = useState(""); const { data: todos = [], isLoading } = trpc.todo.list.useQuery(); const createTodo = trpc.todo.create.useMutation({ onSuccess: () => utils.todo.list.invalidate(), }); const toggleTodo = trpc.todo.toggle.useMutation({ onSuccess: () => utils.todo.list.invalidate(), }); const removeTodo = trpc.todo.remove.useMutation({ onSuccess: () => utils.todo.list.invalidate(), }); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!title.trim()) return; createTodo.mutate({ title }); setTitle(""); }; return ( <main className="mx-auto max-w-xl p-6 space-y-4"> <h1 className="text-2xl font-bold">tRPC × Drizzle × Next.js Todo</h1> <form className="flex gap-2" onSubmit={handleSubmit}> <input className="flex-1 border rounded px-3 py-2" placeholder="添加待办..." value={title} onChange={(e) => setTitle(e.target.value)} /> <button className="px-4 py-2 rounded bg-black text-white disabled:opacity-50" type="submit" disabled={createTodo.isPending} > {createTodo.isPending ? "添加中..." : "添加"} </button> </form> {isLoading ? ( <p>加载中...</p> ) : ( <ul className="space-y-2"> {todos.map((t) => ( <li key={t.id} className="flex items-center gap-3 border p-2 rounded" > <input type="checkbox" checked={t.completed} onChange={(e) => toggleTodo.mutate({ id: t.id, completed: e.target.checked }) } /> <span className={t.completed ? "line-through text-gray-500" : ""}> {t.title} </span> <button className="ml-auto text-red-600" onClick={() => removeTodo.mutate({ id: t.id })} > 删除 </button> </li> ))} </ul> )} </main> ); }