•17 min read

tRPC v11 với Next.js 15: An toàn kiểu dữ liệu từ đầu đến cuối, Server Actions & TanStack Query v5

tRPC v11 với Next.js 15: An toàn kiểu dữ liệu từ đầu đến cuối, Server Actions & TanStack Query v5

tRPC v11, kết hợp với App Router và Server Actions của Next.js 15, mang đến một framework mạnh mẽ để xây dựng các ứng dụng TypeScript full-stack. Hướng dẫn này trình bày chi tiết một phương pháp kiến trúc tận dụng tRPC cho các tương tác API an toàn kiểu, tích hợp Server Actions cho các mẫu mutation cụ thể và điều phối việc tìm nạp dữ liệu với TanStack Query v5. Mục tiêu là đạt được tính an toàn kiểu end-to-end tại thời điểm biên dịch mà không cần đến các công cụ tạo schema như Protobuf hay GraphQL.

Audio Briefing
0:00 / 0:00

Tổng quan kiến trúc

Kiến trúc được đề xuất kết hợp API kiểu RPC của tRPC với Next.js Server Actions. tRPC xử lý việc tìm nạp dữ liệu phức tạp, cập nhật thời gian thực (thông qua subscriptions, mặc dù không được đề cập rộng rãi ở đây) và các mutation đa năng. Server Actions được sử dụng cho các cập nhật UI lạc quan trên các mutation đơn giản, gửi biểu mẫu và các trường hợp mà việc thao tác dữ liệu trực tiếp phía máy chủ mà không cần một vòng lặp API đầy đủ là có lợi. TanStack Query v5 quản lý bộ nhớ đệm phía client, xác thực lại và đồng bộ hóa.

Các thành phần cốt lõi

  1. tRPC Server: Được định nghĩa trong các tuyến API của Next.js (ví dụ: app/api/trpc/[trpc]/route.ts), hiển thị các thủ tục an toàn kiểu.
  2. tRPC Client: Được cấu hình cho các thành phần React, tích hợp với TanStack Query.
  3. Next.js Server Actions: Các hàm được đánh dấu use server để thực thi trực tiếp phía máy chủ từ các thành phần client.
  4. TanStack Query v5: Lớp tìm nạp và lưu trữ dữ liệu phía client.
  5. Zod: Xác thực schema cho đầu vào tRPC và payload của Server Action.

So sánh kiến trúc: tRPC vs. Server Actions

Tính năngThủ tục tRPCNext.js Server Actions
Gọi thực thiHTTP (POST/GET), kiểu RPCGọi hàm trực tiếp (kiểu RPC)
An toàn kiểuEnd-to-end, tại thời điểm biên dịchEnd-to-end, tại thời điểm biên dịch
Gộp nhómTự động (HTTP)Không có gộp nhóm tự nhiên
Bộ nhớ đệmTanStack QueryReact Cache, revalidatePath, revalidateTag
Xử lý lỗiLỗi HTTP tiêu chuẩn, tRPC transformerstry/catch trên client, useFormStatus
MiddlewareTích hợp sẵn (auth, rate-limit)Custom wrappers, next/cache
Trường hợp sử dụngCác truy vấn phức tạp, mutation, subscriptions, API tổng quátGửi biểu mẫu, cập nhật lạc quan, mutation đơn giản
MạngYêu cầu/Phản hồi HTTPGọi hàm được tuần tự hóa qua HTTP
Advertisement

Thiết lập tRPC Server

Máy chủ tRPC được định nghĩa trong app/api/trpc/[trpc]/route.ts. Tệp này đóng vai trò là điểm vào cho tất cả các yêu cầu tRPC.

// src/server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { ZodError } from 'zod';
import superjson from 'superjson';
import { type NextRequest } from 'next/server';

interface CreateContextOptions {
  headers: Headers;
  // Add any other context properties you need, e.g., session
  userId?: string;
}

/**
 * This is the actual context you'll use in your router. It will be used to process every request
 * that goes through your tRPC router.
 */
export const createTRPCContext = async (opts: CreateContextOptions) => {
  // Simulate authentication or session retrieval
  const userId = opts.headers.get('x-user-id') || undefined; // Example: get from header
  return {
    headers: opts.headers,
    userId,
  };
};

/**
 * Initializer for tRPC.
 * @see https://trpc.io/docs/v11/server/initialization
 */
const t = initTRPC.context<typeof createTRPCContext>().transformer(superjson).create({
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError:
          error.code === 'BAD_REQUEST' && error.cause instanceof ZodError
            ? error.cause.flatten()
            : null,
      },
    };
  },
});

/**
 * Reusable middleware that enforces users are logged in.
 */
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.userId) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({
    ctx: {
      // Infers a new type for the context with `userId` now being non-nullable
      userId: ctx.userId,
    },
  });
});

/**
 * Export reusable router and procedure helpers
 */
export const createTRPCRouter = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
// src/server/routers/_app.ts
import { z } from 'zod';
import { createTRPCRouter, publicProcedure, protectedProcedure } from '../trpc';

// Example: Simulate a database
const posts: { id: string; title: string; content: string; authorId: string }[] = [];

export const appRouter = createTRPCRouter({
  post: createTRPCRouter({
    getById: publicProcedure
      .input(z.object({ id: z.string() }))
      .query(({ input }) => {
        const post = posts.find(p => p.id === input.id);
        if (!post) {
          throw new TRPCError({ code: 'NOT_FOUND', message: 'Post not found' });
        }
        return post;
      }),
    create: protectedProcedure
      .input(z.object({ title: z.string().min(1), content: z.string().min(1) }))
      .mutation(({ input, ctx }) => {
        const newPost = {
          id: Math.random().toString(36).substring(2, 9),
          title: input.title,
          content: input.content,
          authorId: ctx.userId, // userId is guaranteed to be present by protectedProcedure
        };
        posts.push(newPost);
        return newPost;
      }),
    getAll: publicProcedure
      .query(() => {
        return posts;
      }),
  }),
  user: createTRPCRouter({
    getProfile: protectedProcedure
      .query(({ ctx }) => {
        // In a real app, fetch user profile from DB using ctx.userId
        return { id: ctx.userId, name: `User ${ctx.userId}` };
      }),
  }),
});

// Export type definition of API
export type AppRouter = typeof appRouter;
// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers/_app';
import { createTRPCContext } from '@/server/trpc';
import { type NextRequest } from 'next/server';

/**
 * This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
 * handling a HTTP request (e.g., when you make requests from the client).
 */
const createContext = async (req: NextRequest) => {
  return createTRPCContext({
    headers: req.headers,
  });
};

const handler = (req: NextRequest) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext: () => createContext(req),
    onError({ error, path }) {
      console.error(`❌ tRPC failed on ${path ?? '<no-path>'}: ${error.message}`);
    },
  });

export { handler as GET, handler as POST };

Thiết lập tRPC Client với TanStack Query v5

Thiết lập client bao gồm việc tạo một thể hiện client tRPC và tích hợp nó với QueryClientProvider của TanStack Query. Hydration rất quan trọng đối với các trang được render phía máy chủ (SSR) hoặc được tạo tĩnh (SSG).

// src/trpc/react.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink, loggerLink } from '@trpc/client';
import { createTRPCReact } from '@trpc/react-query';
import { useState } from 'react';
import superjson from 'superjson';

import { type AppRouter } from '@/server/routers/_app';

const createQueryClient = () => new QueryClient({
  defaultOptions: {
    queries: {
      // With SSR, we usually want to set some default staleTime
      // above 0 to avoid refetching on first mount.
      staleTime: 60 * 1000,
    },
  },
});

let clientQueryClient: QueryClient | undefined = undefined;
const getQueryClient = () => {
  if (typeof window === 'undefined') {
    // Server: always make a new query client
    return createQueryClient();
  } else {
    // Browser: make a new query client if we don't already have one
    // This is to make sure we use the same query client across the entire browser session
    return (clientQueryClient ??= createQueryClient());
  }
};

export const api = createTRPCReact<AppRouter>();

export function TRPCReactProvider(props: { children: React.ReactNode }) {
  const queryClient = getQueryClient();

  const [trpcClient] = useState(() =>
    api.createClient({
      links: [
        loggerLink({
          enabled: (op) =>
            process.env.NODE_ENV === 'development' ||
            (op.direction === 'down' && op.result instanceof Error),
        }),
        httpBatchLink({
          url: '/api/trpc', // Relative path for Next.js API routes
          transformer: superjson,
        }),
      ],
    }),
  );

  return (
    <QueryClientProvider client={queryClient}>
      <api.Provider client={trpcClient} queryClient={queryClient}>
        {props.children}
      </api.Provider>
    </QueryClientProvider>
  );
}
// src/app/layout.tsx
import { TRPCReactProvider } from '@/trpc/react';
import './globals.css';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <TRPCReactProvider>{children}</TRPCReactProvider>
      </body>
    </html>
  );
}

Tích hợp Server Actions

Server Actions có thể được sử dụng cho các mutation có lợi từ tương tác trực tiếp với máy chủ, đặc biệt đối với các biểu mẫu hoặc cập nhật lạc quan.

// src/app/actions.ts
'use server';

import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

// Simulate a database for Server Actions
const serverActionPosts: { id: string; title: string; content: string }[] = [];

const createPostSchema = z.object({
  title: z.string().min(1, 'Title is required'),
  content: z.string().min(1, 'Content is required'),
});

export async function createPostAction(prevState: any, formData: FormData) {
  const rawFormData = {
    title: formData.get('title'),
    content: formData.get('content'),
  };

  const validatedFields = createPostSchema.safeParse(rawFormData);

  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: 'Failed to create post.',
    };
  }

  const { title, content } = validatedFields.data;

  // Simulate DB operation
  const newPost = {
    id: Math.random().toString(36).substring(2, 9),
    title,
    content,
  };
  serverActionPosts.push(newPost);

  // Revalidate paths to show new data
  revalidatePath('/server-actions-posts');
  redirect('/server-actions-posts'); // Redirect after successful creation

  return { message: 'Post created successfully.' };
}

export async function getPostsAction() {
  // Simulate fetching posts
  return serverActionPosts;
}
Advertisement

Tìm nạp & Thay đổi dữ liệu kết hợp

Kết hợp tRPC và Server Actions.

// src/app/page.tsx
import { api } from '@/trpc/react';
import { createPostAction, getPostsAction } from './actions';
import { PostForm } from './post-form'; // A client component for the form

export default async function HomePage() {
  // Fetch posts using tRPC on the server (SSR)
  const trpcPosts = await api.ssr.post.getAll.fetch();

  // Fetch posts using Server Action on the server (SSR)
  const serverActionPosts = await getPostsAction();

  return (
    <main className="p-4">
      <h1 className="text-2xl font-bold mb-4">Full-Stack Type Safety with tRPC & Server Actions</h1>

      <section className="mb-8">
        <h2 className="text-xl font-semibold mb-2">tRPC Posts (SSR)</h2>
        <ul className="list-disc pl-5">
          {trpcPosts.map((post) => (
            <li key={post.id}>
              <strong>{post.title}</strong> by {post.authorId}
              <p className="text-sm text-gray-600">{post.content}</p>
            </li>
          ))}
        </ul>
      </section>

      <section className="mb-8">
        <h2 className="text-xl font-semibold mb-2">Server Action Posts (SSR)</h2>
        <ul className="list-disc pl-5">
          {serverActionPosts.map((post) => (
            <li key={post.id}>
              <strong>{post.title}</strong>
              <p className="text-sm text-gray-600">{post.content}</p>
            </li>
          ))}
        </ul>
      </section>

      <section className="mb-8">
        <h2 className="text-xl font-semibold mb-2">Create Post (tRPC Mutation - Client)</h2>
        <CreateTRPCPostForm />
      </section>

      <section>
        <h2 className="text-xl font-semibold mb-2">Create Post (Server Action - Client)</h2>
        <PostForm /> {/* This component uses `useFormState` and `createPostAction` */}
      </section>
    </main>
  );
}

// Client component for tRPC mutation
function CreateTRPCPostForm() {
  const createPost = api.post.create.useMutation({
    onSuccess: () => {
      // Invalidate cache to refetch all posts after successful creation
      api.post.getAll.invalidate();
      alert('tRPC Post created!');
    },
    onError: (error) => {
      alert(`Error creating tRPC post: ${error.message}`);
    },
  });

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    const title = formData.get('title') as string;
    const content = formData.get('content') as string;
    createPost.mutate({ title, content });
  };

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <div>
        <label htmlFor="trpc-title" className="block text-sm font-medium text-gray-700">Title</label>
        <input type="text" id="trpc-title" name="title" required className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" />
      </div>
      <div>
        <label htmlFor="trpc-content" className="block text-sm font-medium text-gray-700">Content</label>
        <textarea id="trpc-content" name="content" required className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"></textarea>
      </div>
      <button type="submit" disabled={createPost.isLoading} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50">
        {createPost.isLoading ? 'Creating...' : 'Create tRPC Post'}
      </button>
    </form>
  );
}
// src/app/post-form.tsx
'use client';

import { useFormState, useFormStatus } from 'react-dom';
import { createPostAction } from './actions';

const initialState = {
  message: '',
  errors: undefined,
};

export function PostForm() {
  const [state, formAction] = useFormState(createPostAction, initialState);
  const { pending } = useFormStatus();

  return (
    <form action={formAction} className="space-y-4">
      <div>
        <label htmlFor="sa-title" className="block text-sm font-medium text-gray-700">Title</label>
        <input type="text" id="sa-title" name="title" required className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" />
        {state.errors?.title && (
          <p className="text-red-500 text-xs mt-1">{state.errors.title.join(', ')}</p>
        )}
      </div>
      <div>
        <label htmlFor="sa-content" className="block text-sm font-medium text-gray-700">Content</label>
        <textarea id="sa-content" name="content" required className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"></textarea>
        {state.errors?.content && (
          <p className="text-red-500 text-xs mt-1">{state.errors.content.join(', ')}</p>
        )}
      </div>
      <button type="submit" disabled={pending} className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50">
        {pending ? 'Creating...' : 'Create Server Action Post'}
      </button>
      {state.message && <p className="mt-2 text-sm text-green-600">{state.message}</p>}
    </form>
  );
}

Những vấn đề cần lưu ý khi triển khai & Khắc phục sự cố

  1. Lỗi tuần tự hóa với superjson:

    • Vấn đề: Bạn có thể gặp lỗi như "Cannot stringify arbitrary non-POJOs" hoặc "Cannot deserialize value" khi truyền các đối tượng phức tạp (ví dụ: Date, Map, Set) giữa client và server.
    • Nguyên nhân: tRPC sử dụng superjson theo mặc định, nhưng nếu bạn quên cấu hình nó trên cả client và server, hoặc nếu một Server Action cố gắng truyền trực tiếp một đối tượng không thể tuần tự hóa, các vấn đề sẽ phát sinh.
    • Cách khắc phục: Đảm bảo superjson được cấu hình trong initTRPC trên server và httpBatchLink trên client. Đối với Server Actions, hãy tuần tự hóa/giải tuần tự hóa rõ ràng các kiểu phức tạp hoặc đảm bảo chúng là các đối tượng JavaScript thuần túy.
  2. TRPCClientError trên phía máy chủ (SSR/RSC):

    • Vấn đề: Khi tìm nạp dữ liệu tRPC trong một Server Component (ví dụ: await api.ssr.post.getAll.fetch()), bạn có thể thấy TRPCClientError hoặc các lỗi liên quan đến mạng.
    • Nguyên nhân: Client tRPC được cấu hình cho SSR cần biết URL đầy đủ nếu nó đang thực hiện một yêu cầu bên ngoài, hoặc nó cần được cấu hình để sử dụng đường dẫn tương đối nếu đó là một tuyến API nội bộ.
    • Cách khắc phục: Đối với các tuyến API nội bộ, đảm bảo URL httpBatchLink là /api/trpc. Nếu triển khai lên môi trường giống Vercel, biến môi trường VERCEL_URL có thể được sử dụng để xây dựng một URL đầy đủ cho các cuộc gọi bên ngoài, nhưng đối với các cuộc gọi nội bộ trong Next.js, đường dẫn tương đối được ưu tiên. Thiết lập createTRPCReact xử lý điều này bằng cách cung cấp một getQueryClient riêng biệt cho server và client.
  3. Hủy bỏ bộ nhớ đệm Next.js với Server Actions:

    • Vấn đề: Sau khi một Server Action thay đổi dữ liệu, các thành phần phía client có thể không phản ánh ngay lập tức các thay đổi, hoặc revalidatePath / revalidateTag dường như không hoạt động.
    • Nguyên nhân: revalidatePath và revalidateTag chỉ làm mất hiệu lực Next.js Data Cache cho Server Components. Chúng không tự động làm mất hiệu lực bộ nhớ đệm phía client của TanStack Query.
    • Cách khắc phục: Nếu một Server Action sửa đổi dữ liệu cũng được tìm nạp bởi tRPC/TanStack Query, bạn phải tự động làm mất hiệu lực các khóa TanStack Query có liên quan trên client sau khi Server Action hoàn thành. Điều này có thể được thực hiện bằng cách gọi queryClient.invalidateQueries() trong một useEffect phía client theo dõi trạng thái của Server Action, hoặc bằng cách thực hiện một tRPC mutation sau đó làm mất hiệu lực.
  4. Ngữ cảnh xác thực trong tRPC Middleware:

    • Vấn đề: ctx.userId là undefined trong protectedProcedure mặc dù người dùng đã đăng nhập.
    • Nguyên nhân: Hàm createTRPCContext không trích xuất thông tin xác thực từ yêu cầu đến một cách chính xác.
    • Cách khắc phục: Đảm bảo createTRPCContext đọc đúng các mã thông báo xác thực (ví dụ: từ tiêu đề Authorization, cookie) và điền vào ngữ cảnh. Đối với Next.js App Router, cookie có thể được truy cập thông qua cookies().get('token') từ next/headers trong ngữ cảnh máy chủ.
  5. Lỗi xác thực Zod không được truyền đi:

    • Vấn đề: Các biểu mẫu phía client sử dụng Server Actions hoặc tRPC không hiển thị chi tiết lỗi xác thực.
    • Nguyên nhân: Trình định dạng lỗi trong tRPC hoặc việc xử lý lỗi trong Server Actions không được cấu trúc để trả về các lỗi Zod chi tiết.
    • Cách khắc phục: Đối với tRPC, đảm bảo errorFormatter trong initTRPC trích xuất chi tiết ZodError. Đối với Server Actions, safeParse và trả về validatedFields.error.flatten().fieldErrors là cách tiếp cận đúng, như đã trình bày. Thành phần client sau đó cần hiển thị các lỗi này.

Các câu hỏi thường gặp

1. Tại sao phải sử dụng tRPC khi Next.js Server Actions cung cấp tính an toàn kiểu?

tRPC và Server Actions giải quyết các nhu cầu khác nhau, mặc dù có một số điểm trùng lặp. tRPC cung cấp một lớp RPC đầy đủ tính năng với các tính năng như gộp nhóm tự động, subscriptions và một hệ thống middleware mạnh mẽ để xác thực, ghi nhật ký và giới hạn tốc độ. Nó lý tưởng cho việc tìm nạp dữ liệu phức tạp, cập nhật thời gian thực và các tương tác API tổng quát. Server Actions rất xuất sắc cho việc gửi biểu mẫu, cập nhật UI lạc quan và các mutation trực tiếp phía máy chủ mà một lớp API đầy đủ có thể là quá mức cần thiết. Tính an toàn kiểu là một lợi ích chung, nhưng hệ sinh thái của tRPC (tích hợp TanStack Query, transformers) trưởng thành hơn cho việc quản lý dữ liệu phức tạp.

2. Làm thế nào để xử lý xác thực và ủy quyền với thiết lập này?

Đối với tRPC, hãy triển khai xác thực trong createTRPCContext bằng cách phân tích cú pháp tiêu đề hoặc cookie để xác định người dùng. Sau đó, sử dụng tRPC middleware (ví dụ: enforceUserIsAuthed) để bảo vệ các thủ tục. Đối với Server Actions, xác thực phải được xử lý trong chính action đó, thường là bằng cách đọc cookie hoặc dữ liệu phiên bằng cách sử dụng các tiện ích next/headers. Ủy quyền (kiểm tra xem người dùng có quyền thực hiện một hành động hay không) nên được thực hiện trong cả thủ tục tRPC và Server Actions, sau khi xác thực.

3. Tôi có thể sử dụng các truy vấn tRPC trong Server Components không?

Có, như đã trình bày trong app/page.tsx. Bạn có thể sử dụng await api.ssr.yourProcedure.fetch() trực tiếp trong một Server Component. Điều này thực hiện một cuộc gọi hàm trực tiếp trên máy chủ, bỏ qua HTTP cho các cuộc gọi nội bộ và cung cấp tính an toàn kiểu đầy đủ. Đây là một tính năng mạnh mẽ cho SSR/SSG.

4. Cách tốt nhất để quản lý các kiểu dùng chung giữa tRPC và Server Actions là gì?

Định nghĩa các schema Zod và các kiểu TypeScript của bạn trong một thư mục dùng chung (ví dụ: src/schemas hoặc src/types). Cả trình xác thực đầu vào tRPC và trình xác thực payload của Server Action sau đó có thể nhập và sử dụng các schema này, đảm bảo tính nhất quán và an toàn kiểu trên toàn ứng dụng của bạn.

5. Làm thế nào để triển khai cập nhật lạc quan với Server Actions và TanStack Query?

Mặc dù Server Actions không tích hợp trực tiếp với các cập nhật lạc quan của TanStack Query, bạn có thể kết hợp chúng. Khi một Server Action được kích hoạt, bạn có thể cập nhật thủ công bộ nhớ đệm TanStack Query bằng cách sử dụng queryClient.setQueryData trước khi Server Action hoàn thành. Nếu Server Action thất bại, bạn có thể hoàn nguyên bộ nhớ đệm. Sau một Server Action thành công, bạn thường gọi queryClient.invalidateQueries() để tìm nạp lại dữ liệu và đảm bảo tính nhất quán. Điều này đòi hỏi sự điều phối cẩn thận ở phía client.

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement