第 7 章:全栈开发实战¶
学习时间:6 小时 | 难度:⭐⭐⭐⭐ 高级 | 前置知识:第 6 章,React 基础
本章概览¶
本章使用 Next.js 15 App Router + tRPC v11 + Drizzle ORM 构建端到端类型安全的全栈应用。整个项目从数据库到前端 UI,全程零 any,类型错误在编码阶段 100% 可见。
学习目标:
- 掌握 Next.js 15 App Router 的 Server Components 与 Server Actions
- 使用 tRPC v11 实现前后端共享类型(零 API 文档维护)
- 掌握 Drizzle ORM 的类型推断与迁移
- 理解 React Server Components 的数据流
- 构建带认证的完整 CRUD 应用
7.1 Next.js 15 App Router 核心¶
Bash
pnpm create next-app@latest fullstack-app \
--typescript --tailwind --app --src-dir \
--import-alias "@/*"
cd fullstack-app
pnpm add drizzle-orm @neondatabase/serverless
pnpm add drizzle-kit -D
pnpm add @trpc/server @trpc/client @trpc/react-query @trpc/next
pnpm add @tanstack/react-query zod
pnpm add next-auth @auth/drizzle-adapter
App Router 目录结构¶
Text Only
src/
├── app/ # Next.js App Router
│ ├── layout.tsx # 根布局(Server Component)
│ ├── page.tsx # 首页
│ ├── (auth)/ # 路由组(不影响 URL)
│ │ ├── login/page.tsx
│ │ └── register/page.tsx
│ ├── dashboard/
│ │ ├── layout.tsx # 仪表盘布局(需要登录)
│ │ ├── page.tsx
│ │ └── posts/
│ │ ├── page.tsx # 帖子列表
│ │ └── [id]/page.tsx # 帖子详情
│ └── api/
│ ├── trpc/[trpc]/route.ts # tRPC HTTP 处理器
│ └── auth/[...nextauth]/route.ts
├── server/
│ ├── db/
│ │ ├── schema.ts # Drizzle Schema
│ │ └── index.ts # DB 连接
│ ├── trpc/
│ │ ├── init.ts # tRPC 初始化
│ │ ├── routers/ # 路由定义
│ │ │ ├── post.ts
│ │ │ └── user.ts
│ │ └── root.ts # 根路由(合并所有子路由)
│ └── auth.ts # NextAuth 配置
├── trpc/
│ ├── server.ts # Server-side helper
│ └── client.ts # Client-side helper
└── components/
├── ui/ # shadcn/ui 组件
└── posts/ # 业务组件
7.2 Drizzle ORM Schema¶
TypeScript
// src/server/db/schema.ts
import { pgTable, text, timestamp, boolean, uuid, pgEnum } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
// 枚举类型
export const roleEnum = pgEnum("role", ["user", "admin"]);
// 用户表
export const users = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: timestamp("email_verified"),
image: text("image"),
role: roleEnum("role").notNull().default("user"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().$onUpdate(() => new Date()),
});
// 帖子表
export const posts = pgTable("posts", {
id: uuid("id").primaryKey().defaultRandom(),
title: text("title").notNull(),
content: text("content").notNull(),
published: boolean("published").notNull().default(false),
authorId: uuid("author_id").notNull().references(() => users.id, { onDelete: "cascade" }),
publishedAt: timestamp("published_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().$onUpdate(() => new Date()),
});
// 定义关联关系(用于 Drizzle 的 query API)
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, { fields: [posts.authorId], references: [users.id] }),
}));
// 从 schema 推断 TypeScript 类型
export type User = typeof users.$inferSelect; // SELECT 时的类型
export type NewUser = typeof users.$inferInsert; // INSERT 时的类型
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;
// src/server/db/index.ts
import { drizzle } from "drizzle-orm/neon-http";
import { neon } from "@neondatabase/serverless";
import * as schema from "./schema";
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql, { schema });
7.3 tRPC v11 — 端到端类型安全 API¶
tRPC 的核心思想:API 不是 HTTP 接口,而是 TypeScript 函数调用。
TypeScript
// src/server/trpc/init.ts — tRPC 初始化
import { initTRPC, TRPCError } from "@trpc/server";
import { auth } from "../auth";
import { db } from "../db";
import { ZodError } from "zod";
import superjson from "superjson";
// Context:每次请求都会创建,包含认证信息和数据库连接
type Context = {
db: typeof db;
session: Awaited<ReturnType<typeof auth>> | null;
};
export async function createContext(): Promise<Context> {
const session = await auth();
return { db, session };
}
const t = initTRPC.context<Context>().create({
transformer: superjson, // 支持 Date、Map、Set 等类型序列化
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
// 导出构建块
export const createTRPCRouter = t.router;
export const publicProcedure = t.procedure;
// 需要认证的 procedure
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({ ctx: { ...ctx, session: ctx.session } }); // session 不再是 null
});
// 需要管理员的 procedure
export const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.session.user.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN", message: "Admin only" });
}
return next({ ctx });
});
TypeScript
// src/server/trpc/routers/post.ts — 帖子路由
import { createTRPCRouter, publicProcedure, protectedProcedure } from "../init";
import { z } from "zod";
import { posts } from "../../db/schema";
import { eq, desc, and, like } from "drizzle-orm";
export const postRouter = createTRPCRouter({
// 查询所有已发布帖子(公开)
list: publicProcedure
.input(z.object({
page: z.number().int().positive().default(1),
limit: z.number().int().positive().max(50).default(10),
search: z.string().optional(),
}))
.query(async ({ ctx, input }) => {
const { page, limit, search } = input;
const offset = (page - 1) * limit;
const where = and(
eq(posts.published, true),
search ? like(posts.title, `%${search}%`) : undefined,
);
const [data, countResult] = await Promise.all([
ctx.db.query.posts.findMany({
where,
with: { author: { columns: { name: true, image: true } } },
limit,
offset,
orderBy: [desc(posts.publishedAt)],
}),
ctx.db.select({ count: sql<number>`count(*)::int` })
.from(posts).where(where),
]);
return {
posts: data,
total: countResult[0]?.count ?? 0,
page,
};
}),
// 获取单篇帖子(公开)
byId: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const post = await ctx.db.query.posts.findFirst({
where: eq(posts.id, input.id),
with: { author: { columns: { name: true, image: true, id: true } } },
});
if (!post) throw new TRPCError({ code: "NOT_FOUND" });
return post;
}),
// 创建帖子(需要登录)
create: protectedProcedure
.input(z.object({
title: z.string().min(5).max(200),
content: z.string().min(20),
}))
.mutation(async ({ ctx, input }) => {
const [post] = await ctx.db.insert(posts).values({
...input,
authorId: ctx.session.user.id,
}).returning();
return post;
}),
// 发布/取消发布(只有作者可以操作)
togglePublish: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const post = await ctx.db.query.posts.findFirst({
where: and(eq(posts.id, input.id), eq(posts.authorId, ctx.session.user.id)),
});
if (!post) throw new TRPCError({ code: "NOT_FOUND" });
const [updated] = await ctx.db.update(posts)
.set({
published: !post.published,
publishedAt: !post.published ? new Date() : null,
})
.where(eq(posts.id, input.id))
.returning();
return updated;
}),
});
7.4 Server Components 与 Server Actions¶
TypeScript
// src/app/dashboard/posts/page.tsx — Server Component(默认)
import { HydrateClient, trpc } from "@/trpc/server";
import { PostList } from "@/components/posts/PostList";
import { Suspense } from "react";
export default async function PostsPage({
searchParams,
}: {
searchParams: Promise<{ page?: string; search?: string }>;
}) {
const { page = "1", search } = await searchParams;
// 在服务器上预取数据(避免客户端瀑布请求)
void trpc.post.list.prefetch({ page: Number(page), search });
return (
<HydrateClient>
<div className="container py-8">
<h1 className="text-3xl font-bold mb-6">我的帖子</h1>
<Suspense fallback={<PostsListSkeleton />}>
{/* PostList 是 Client Component,使用 tRPC hooks */}
<PostList page={Number(page)} search={search} />
</Suspense>
</div>
</HydrateClient>
);
}
// src/components/posts/PostList.tsx — Client Component
"use client";
import { trpc } from "@/trpc/client";
interface PostListProps {
page: number;
search?: string;
}
export function PostList({ page, search }: PostListProps) {
// 完整的类型推断!data 的类型来自 postRouter.list 的返回值
const { data, isLoading } = trpc.post.list.useQuery({ page, search });
const togglePublish = trpc.post.togglePublish.useMutation({
onSuccess: () => {
// 刷新列表
utils.post.list.invalidate();
},
});
const utils = trpc.useUtils();
if (isLoading) return <div>Loading...</div>;
return (
<ul className="space-y-4">
{data?.posts.map((post) => (
<li key={post.id} className="p-4 border rounded-lg">
<h2 className="font-semibold">{post.title}</h2>
<p className="text-sm text-gray-500">by {post.author.name}</p>
<button
onClick={() => togglePublish.mutate({ id: post.id })}
className="mt-2 text-sm text-blue-600 hover:text-blue-800"
>
{post.published ? "取消发布" : "发布"}
</button>
</li>
))}
</ul>
);
}
// Server Actions:表单提交不需要 API 路由
// src/app/dashboard/posts/new/page.tsx
"use server";
import { auth } from "@/server/auth";
import { db } from "@/server/db";
import { posts } from "@/server/db/schema";
import { redirect } from "next/navigation";
import { z } from "zod";
const CreatePostAction = z.object({
title: z.string().min(5).max(200),
content: z.string().min(20),
});
export async function createPost(formData: FormData) {
"use server"; // 标记为 Server Action
const session = await auth();
if (!session?.user) redirect("/login");
const result = CreatePostAction.safeParse({
title: formData.get("title"),
content: formData.get("content"),
});
if (!result.success) {
return { error: result.error.flatten() };
}
await db.insert(posts).values({
...result.data,
authorId: session.user.id,
});
redirect("/dashboard/posts");
}
📌 本章小结¶
| 技术 | 作用 |
|---|---|
| Next.js 15 App Router | Server Components + Server Actions,减少客户端 JS |
| tRPC v11 | 前后端共享 TypeScript 类型,零 REST 文档 |
| Drizzle ORM | 类型安全数据库操作,自动推断 Schema 类型 |
| superjson | Date/Map/Set 等类型跨网络序列化 |
Next.js 15 · tRPC v11 · Drizzle ORM · 2025