Next.js + tRPC:独立开发者的最佳实践技术栈

Published on
12 mins read

作为独立开发者,选择合适的技术栈是项目成功的关键。在经历了多次技术选型的纠结后,我发现 Next.js + tRPC 这个组合堪称独立开发的黄金搭档。本文会系统地聊聊:为什么这个技术栈如此适合独立开发者,以及如何在实际项目中落地。


为什么 Next.js + tRPC 是独立开发的理想选择?

1. 端到端类型安全

传统的前后端分离开发中,API 接口的类型定义往往是最大的痛点,而 tRPC 用“从类型出发的 API 设计”很好地解决了这个问题:

// 后端 API 定义
export const userRouter = router({
  getById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      return await db.user.findUnique({
        where: { id: input.id },
      });
    }),

  create: publicProcedure
    .input(
      z.object({
        name: z.string().min(2),
        email: z.string().email(),
      }),
    )
    .mutation(async ({ input }) => {
      return await db.user.create({
        data: input,
      });
    }),
});

// 前端调用,完全类型安全
const CreateUser = () => {
  const createUser = trpc.user.create.useMutation();

  const handleSubmit = (data: { name: string; email: string }) => {
    // TypeScript 会检查类型,IDE 有完整提示
    createUser.mutate(data);
  };
};

关键收益:

  • 不再需要手写 OpenAPI / Swagger 文档
  • 类型从后端“一路流”到前端组件
  • 重构字段时,TS 编译器会帮你找出所有受影响的调用点

2. 开发效率极高

  • 无需额外 API 层样板代码:直接在 tRPC router 中写业务逻辑
  • 实时类型反馈:后端 schema 一改,前端 IDE 立刻红线提醒
  • 一套思维模型:统一用 TypeScript + Zod 建模,而不是在多种 DSL 间来回切换

3. 极佳的 DX(开发者体验)

// 一个完整的 tRPC 查询,包含加载状态和错误处理
const ProfilePage = () => {
  const { data: user, isLoading, error } = trpc.user.getProfile.useQuery()

  if (isLoading) return <Spinner />
  if (error) return <ErrorMessage error={error.message} />

  return <UserProfile user={user} />
}

你可以把“数据获取 + 状态管理 + 错误处理”都收敛到一套熟悉的 React Query 模型里,对独立开发者非常友好。


技术栈整体架构

核心框架层

{
  "dependencies": {
    "next": "^14.0.0",
    "@trpc/server": "^10.45.0",
    "@trpc/client": "^10.45.0",
    "@trpc/react-query": "^10.45.0",
    "@trpc/next": "^10.45.0"
  }
}

数据库 & ORM:Prisma + PostgreSQL

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String?
  posts     Post[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Post {
  id        String   @id @default(cuid())
  title     String
  content   String?
  published Boolean  @default(false)
  author    User     @relation(fields: [authorId], references: [id])
  authorId  String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

验证层:Zod

Zod 负责运行时校验和类型推导:

import { z } from "zod";

export const createPostSchema = z.object({
  title: z.string().min(1, "标题不能为空").max(100, "标题过长"),
  content: z.string().min(10, "内容至少10个字符"),
  published: z.boolean().default(false),
});

export type CreatePostInput = z.infer<typeof createPostSchema>;

身份验证:NextAuth.js

// lib/auth.ts
import NextAuth from 'next-auth'
import GoogleProvider from 'next-auth/providers/google'
import { PrismaAdapter } from '@next-auth/prisma-adapter'
import { prisma } from './prisma'

export const authOptions = {
  adapter: PrismaAdapter(prisma),
  providers: [
    GoogleProvider({
      clientId:
process.env.GOOGLE
_CLIENT_ID!,
      clientSecret:
process.env.GOOGLE
_CLIENT_SECRET!,
    }),
  ],
  callbacks: {
    session: ({ session, user }) => ({
      ...session,
      user: {
        ...session.user,
        id:
user.id
,
      },
    }),
  },
}

export default NextAuth(authOptions)

推荐项目结构

my-app/
├── src/
│   ├── app/                  # Next.js 13+ App Router
│   │   ├── api/trpc/[trpc]/  # tRPC API 路由
│   │   ├── (auth)/           # 认证相关页面
│   │   └── dashboard/        # 受保护的页面
│   ├── server/               # 后端逻辑
│   │   ├── api/              # tRPC 路由定义
│   │   │   ├── routers/
│   │   │   │   ├── user.ts
│   │   │   │   └── post.ts
│   │   │   └── root.ts
│   │   ├── auth.ts           # 认证配置
│   │   └── db.ts             # 数据库连接
│   ├── lib/                  # 工具函数
│   │   ├── utils.ts
│   │   └── trpc.ts           # tRPC 客户端配置
│   └── components/           # React 组件
├── prisma/
│   ├── schema.prisma
│   └── migrations/
└── public/

实战案例:构建一个博客系统

1. tRPC 路由定义

// src/server/api/routers/post.ts
import { z } from "zod";
import { createTRPCRouter, publicProcedure, protectedProcedure } from "../trpc";
import { createPostSchema } from "@/lib/schemas";
import { TRPCError } from "@trpc/server";

export const postRouter = createTRPCRouter({
  // 获取文章列表(公开)
  getAll: publicProcedure
    .input(
      z.object({
        limit: z.number().min(1).max(100).default(10),
        cursor: z.string().optional(),
        published: z.boolean().default(true),
      }),
    )
    .query(async ({ ctx, input }) => {
      const posts = await ctx.prisma.post.findMany({
        take: input.limit + 1,
        cursor: input.cursor ? { id: input.cursor } : undefined,
        where: { published: input.published },
        include: { author: { select: { name: true, email: true } } },
        orderBy: { createdAt: "desc" },
      });

      let nextCursor: string | undefined = undefined;
      if (posts.length > input.limit) {
        const nextItem = posts.pop();
        nextCursor = nextItem!.id;
      }

      return { posts, nextCursor };
    }),

  // 获取单篇文章
  getById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ ctx, input }) => {
      const post = await ctx.prisma.post.findUnique({
        where: { id: input.id },
        include: { author: { select: { name: true, email: true } } },
      });

      if (!post) {
        throw new TRPCError({
          code: "NOT_FOUND",
          message: "文章不存在",
        });
      }

      return post;
    }),

  // 创建文章(需要认证)
  create: protectedProcedure
    .input(createPostSchema)
    .mutation(async ({ ctx, input }) => {
      return await ctx.prisma.post.create({
        data: {
          ...input,
          authorId: ctx.session.user.id,
        },
      });
    }),

  // 更新文章
  update: protectedProcedure
    .input(
      z.object({
        id: z.string(),
        data: createPostSchema.partial(),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      const post = await ctx.prisma.post.findUnique({
        where: { id: input.id },
      });

      if (!post || post.authorId !== ctx.session.user.id) {
        throw new TRPCError({
          code: "FORBIDDEN",
          message: "无权限操作",
        });
      }

      return await ctx.prisma.post.update({
        where: { id: input.id },
        data: input.data,
      });
    }),
});

2. 前端文章列表组件

// components/PostList.tsx
import { trpc } from '@/lib/trpc'

export const PostList = () => {
  const {
    data,
    isLoading,
    error,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } =
trpc.post
.getAll.useInfiniteQuery(
    { limit: 10 },
    {
      getNextPageParam: lastPage => lastPage.nextCursor,
    },
  )

  if (isLoading) return <div>加载中...</div>
  if (error) return <div>错误: {error.message}</div>

  return (
    <div className="space-y-6">
      {data?.
pages.map
((page, i) => (
        <div key={i} className="space-y-4">
          {
page.posts.map
(post => (
            <article key={
post.id
} className="border rounded-lg p-6">
              <h2 className="text-xl font-bold">{post.title}</h2>
              <p className="text-gray-600 mt-2">{post.content}</p>
              <div className="text-sm text-gray-500 mt-4">
                作者: {
post.author.name
} | 发布时间:{' '}
                {new Date(post.createdAt).toLocaleDateString()}
              </div>
            </article>
          ))}
        </div>
      ))}

      {hasNextPage && (
        <button
          onClick={() => fetchNextPage()}
          disabled={isFetchingNextPage}
          className="w-full py-2 px-4 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
        >
          {isFetchingNextPage ? '加载中...' : '加载更多'}
        </button>
      )}
    </div>
  )
}

3. 创建文章表单(结合 Zod + React Hook Form)

// components/CreatePostForm.tsx
import { trpc } from '@/lib/trpc'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { createPostSchema, type CreatePostInput } from '@/lib/schemas'

export const CreatePostForm = () => {
  const utils = trpc.useContext()

  const createPost =
trpc.post
.create.useMutation({
    onSuccess: () => {
      // 重新获取文章列表

utils.post
.getAll.invalidate()
      reset()
    },
    onError: error => {
      console.error('创建失败:', error.message)
    },
  })

  const {
    register,
    handleSubmit,
    reset,
    formState: { errors, isSubmitting },
  } = useForm<CreatePostInput>({
    resolver: zodResolver(createPostSchema),
  })

  const onSubmit = (data: CreatePostInput) => {
    createPost.mutate(data)
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <div>
        <label className="block text-sm font-medium">标题</label>
        <input
          {...register('title')}
          className="mt-1 block w-full border rounded-md px-3 py-2"
          placeholder="请输入文章标题"
        />
        {errors.title && (
          <p className="text-red-500 text-sm mt-1">{errors.title.message}</p>
        )}
      </div>

      <div>
        <label className="block text-sm font-medium">内容</label>
        <textarea
          {...register('content')}
          rows={10}
          className="mt-1 block w-full border rounded-md px-3 py-2"
          placeholder="请输入文章内容"
        />
        {errors.content && (
          <p className="text-red-500 text-sm mt-1">{errors.content.message}</p>
        )}
      </div>

      <div className="flex items-center">
        <input
          {...register('published')}
          type="checkbox"
          className="mr-2"
        />
        <label className="text-sm">立即发布</label>
      </div>

      <button
        type="submit"
        disabled={isSubmitting || createPost.isLoading}
        className="w-full py-2 px-4 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
      >
        {isSubmitting || createPost.isLoading ? '发布中...' : '发布文章'}
      </button>
    </form>
  )
}

性能优化最佳实践

1. 智能缓存策略

// lib/trpc.ts
import { httpBatchLink } from "@trpc/client";
import { createTRPCNext } from "@trpc/next";
import type { AppRouter } from "@/server/api/root";

export const trpc = createTRPCNext<AppRouter>({
  config() {
    return {
      links: [
        httpBatchLink({
          url: "/api/trpc",
          // 批量请求优化
          maxBatchSize: 10,
        }),
      ],
      // React Query 配置
      queryClientConfig: {
        defaultOptions: {
          queries: {
            staleTime: 5 * 60 * 1000, // 5 分钟
            cacheTime: 10 * 60 * 1000, // 10 分钟
          },
        },
      },
    };
  },
  ssr: false, // 需要时可以开启 SSR
});

2. 数据预取(SSR/ISR)

// pages/posts/index.tsx
import { createServerSideHelpers } from "@trpc/react-query/server";
import { appRouter } from "@/server/api/root";
import { createTRPCContext } from "@/server/api/trpc";

export async function getStaticProps() {
  const helpers = createServerSideHelpers({
    router: appRouter,
    ctx: await createTRPCContext({ req: null, res: null }),
  });

  // 预取数据
  await helpers.post.getAll.prefetch({ limit: 10 });

  return {
    props: {
      trpcState: helpers.dehydrate(),
    },
    revalidate: 60, // ISR
  };
}

部署与运维

1. Docker 配置

# Dockerfile
FROM node:18-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production

FROM node:18-alpine AS builder
WORKDIR /app
COPY . .
COPY --from=deps /app/node_modules ./node_modules
RUN npm run build

FROM node:18-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]

2. 环境变量管理

# .env.local
DATABASE_URL="postgresql://user:
password@localhost:5432
/myapp"
NEXTAUTH_SECRET="your-secret-key"
NEXTAUTH_URL="
http://localhost:3000
"
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"

监控与错误处理

1. 错误边界

// components/TRPCErrorBoundary.tsx
import { TRPCClientError } from '@trpc/client'
import { ErrorBoundary } from 'react-error-boundary'

export const TRPCErrorBoundary = ({ children }: { children: React.ReactNode }) => {
  return (
    <ErrorBoundary
      fallbackRender={({ error }) => {
        if (error instanceof TRPCClientError) {
          return <div>API 错误: {error.message}</div>
        }
        return <div>未知错误</div>
      }}
    >
      {children}
    </ErrorBoundary>
  )
}

2. 日志记录中间件

// server/api/trpc.ts
import { t } from "./trpc-core";

const loggerMiddleware = t.middleware(async ({ path, type, next }) => {
  const start = Date.now();
  const result = await next();
  const durationMs = Date.now() - start;

  const meta = { path, type, durationMs };

  if (result.ok) {
    console.log("✅ OK request timing:", meta);
  } else {
    console.error("❌ Non-OK request timing", meta);
  }

  return result;
});

export const createTRPCContext = async ({ req, res }: CreateContextOptions) => {
  console.log(`📡 tRPC Request: ${req?.method} ${req?.url}`);

  return {
    req,
    res,
    prisma,
    session: await getServerAuthSession({ req, res }),
  };
};

总结

Next.js + tRPC 这套技术栈,对独立开发者来说有几个非常关键的优势:

核心优势

  • 端到端类型安全:从数据库到 UI 的完整类型保护
  • 开发效率高:减少重复代码,把时间花在业务本身
  • 现代化 DX:React Query、Prisma、NextAuth 等生态联动
  • 渐进式采用:可以从小项目开始用 tRPC,一点点迁移

适用场景

  • ✅ 个人项目和 MVP
  • ✅ 小到中型团队的产品开发
  • ✅ 需要快速迭代的创业项目
  • ✅ 对类型安全和长期维护友好的项目

需要注意的点

  • 学习曲线:需要对 TypeScript 和 React Query 有基本掌握
  • 团队协作:前后端都最好使用 TypeScript,避免“类型断层”
  • 项目规模:超大体量项目可能需要拆分为多服务 / 多 BFF

如果你正在寻找一个既现代又实战友好的全栈技术栈组合,Next.js + tRPC 非常值得一试。一次建好类型模型,就能在项目全生命周期里持续获益——真正做到 “一次建模,处处类型安全”。