Kinh nghiệm
6 phút đọc15 tháng 6, 2026359

Data Fetching trong Next.js: Server Actions, Cache và Revalidation

Từ server fetch, 'use cache', Server Actions đến TanStack Query tất cả patterns bạn cần để xử lý data đúng cách trong Next.js.

N

Nguyễn Nhật Long

@nguyennhatlong1303

Data Fetching trong Next.js: Từ Server Actions đến TanStack Query

Một trong những thứ mình thấy anh em hay bị lẫn lộn nhất khi chuyển từ React thuần sang Next.js chính là chuyện fetch data. Không phải vì nó khó, mà vì Next.js cho quá nhiều lựa chọn: fetch trong Server Component, Server Actions, client-side với TanStack Query... Mỗi cái có use case riêng, và nếu không nắm rõ thì rất dễ dùng sai chỗ, dẫn đến performance kém hoặc UX tệ.

Bài này mình sẽ đi qua từng layer một, theo đúng thứ tự bạn nên nghĩ đến khi thiết kế data fetching cho một feature.

Fetch thẳng trong Server Components đơn giản nhưng cần làm đúng

Cách cơ bản nhất, và thực ra cũng là cách bạn nên dùng đầu tiên trước khi nghĩ đến gì phức tạp hơn:

TSX
1// app/blog/page.tsx
2export default async function BlogPage() {
3 const posts = await fetch('https://api.example.com/posts').then(r => r.json())
4
5 return <PostList posts={posts} />
6}

Server Component là async function bình thường, await thoải mái, không cần useEffect hay loading state gì cả. Clean hơn rất nhiều.

Nhưng có một cái bẫy mà mình thấy khá nhiều người mắc phải waterfall fetch. Kiểu như:

TSX
1// ❌ Sai sequential, chậm không cần thiết
2const posts = await fetchPosts()
3const categories = await fetchCategories()
4const author = await fetchAuthor()

Ba cái này hoàn toàn độc lập nhau, nhưng bạn đang chờ từng cái xong mới fetch cái tiếp theo. Thay vào đó:

TSX
1// ✅ Đúng parallel, nhanh hơn đáng kể
2const [posts, categories, author] = await Promise.all([
3 fetchPosts(),
4 fetchCategories(),
5 fetchAuthor()
6])

Theo kinh nghiệm của mình, cái này thôi đã cải thiện được Time to First Byte khá rõ rệt trong nhiều trường hợp.

Một thứ nữa cần biết là React.cache() dùng để deduplicate request trong cùng một request cycle:

TSX
1import { cache } from 'react'
2
3export const getPost = cache(async (id: string) => {
4 return db.post.findUnique({ where: { id } })
5})

Nếu trong cùng một server render, nhiều component khác nhau đều gọi getPost('123'), React chỉ thực sự query DB một lần. Rất hữu ích khi bạn có component tree phức tạp.

'use cache' Cache thông minh hơn

Next.js 15 giới thiệu 'use cache' directive, và mình thấy cái này khá hay ở chỗ nó cho phép cache ở cả component level lẫn function level, với control rất chi tiết.

TSX
1// Cache toàn bộ output của component
2async function PostList() {
3 'use cache'
4 cacheLife({ revalidate: 60 }) // fresh trong 60 giây
5
6 const posts = await db.post.findMany()
7 return <ul>{posts.map(p => <PostItem key={p.id} post={p} />)}</ul>
8}
TSX
1// Hoặc cache ở function level
2async function getPosts() {
3 'use cache'
4 cacheLife({ revalidate: 300 })
5 cacheTag('posts') // tag này dùng để invalidate on-demand
6
7 return db.post.findMany()
8}

cacheTag là thứ mình dùng nhiều nhất bạn tag data với một cái tên, rồi khi cần invalidate, gọi revalidateTag('posts') là xong. Không cần biết data đó đang được cache ở đâu, bao nhiêu chỗ.

per-request dedup' connected by arrow to bottom box 'DB Query raw query', with side labels showing 'cross-request cache' and 'per-request deduplication', dark background #1a1a2e, green accent colors #00d4aa, flat modern design with subtle grid)

Server Actions Mutation đúng cách

Server Actions là cách Next.js xử lý mutation (create, update, delete). Thay vì tạo API route rồi fetch từ client, bạn viết thẳng function chạy trên server:

TSX
1// actions/post.ts
2'use server'
3
4import { revalidateTag } from 'next/cache'
5
6export async function createPost(formData: FormData) {
7 const title = formData.get('title') as string
8 const content = formData.get('content') as string
9
10 await db.post.create({ data: { title, content } })
11
12 // Invalidate cache sau khi tạo post mới
13 revalidateTag('posts')
14}

Rồi dùng trong form:

TSX
1import { createPost } from '@/actions/post'
2
3export default function NewPostForm() {
4 return (
5 <form action={createPost}>
6 <input name="title" placeholder="Tiêu đề" />
7 <textarea name="content" placeholder="Nội dung" />
8 <button type="submit">Đăng bài</button>
9 </form>
10 )
11}

Cái mình thích nhất ở Server Actions là progressive enhancement form này hoạt động được ngay cả khi JavaScript chưa load xong, vì nó submit như một HTML form bình thường. Đây là thứ mà SPA thuần không làm được.

Ngoài form, bạn cũng có thể gọi Server Action từ event handler:

TSX
1'use client'
2
3import { deletePost } from '@/actions/post'
4
5export function DeleteButton({ postId }: { postId: string }) {
6 return (
7 <button onClick={() => deletePost(postId)}>
8 Xóa
9 </button>
10 )
11}

Về revalidation sau mutation, có ba cách chính:

Mình thường prefer revalidateTag hơn vì nó granular hơn và không bị coupling với URL structure.

Phương phápCú phápKhi nào dùng
Tag-based`revalidateTag('posts')`Invalidate tất cả data có tag này, bất kể ở page nào
Path-based`revalidatePath('/blog')`Revalidate một page cụ thể
Kết hợpCả haiKhi mutation ảnh hưởng nhiều page và nhiều data

Client-side Fetching với TanStack Query Khi nào thực sự cần

Anh em lưu ý: không phải mọi thứ đều cần TanStack Query. Server Components + Server Actions xử lý được phần lớn use case rồi. TanStack Query shine trong những tình huống cụ thể:

TSX
1'use client'
2
3import { useQuery } from '@tanstack/react-query'
4
5// Search autocomplete cần fetch lại mỗi khi user gõ
6export function SearchBox() {
7 const [query, setQuery] = useState('')
8
9 const { data: results } = useQuery({
10 queryKey: ['search', query],
11 queryFn: () => searchPosts(query),
12 staleTime: 30_000, // cache 30 giây
13 enabled: query.length > 2
14 })
15
16 return (
17 <div>
18 <input onChange={e => setQuery(e.target.value)} />
19 {results?.map(r => <SearchResult key={r.id} result={r} />)}
20 </div>
21 )
22}

useMutation với optimistic update cái này tạo ra UX rất mượt:

TSX
1const likeMutation = useMutation({
2 mutationFn: (postId: string) => likePost(postId),
3 onMutate: async (postId) => {
4 // Update UI ngay lập tức, không chờ server
5 await queryClient.cancelQueries({ queryKey: ['post', postId] })
6 const previous = queryClient.getQueryData(['post', postId])
7 queryClient.setQueryData(['post', postId], old => ({
8 ...old,
9 likes: old.likes + 1
10 }))
11 return { previous }
12 },
13 onError: (err, postId, context) => {
14 // Rollback nếu lỗi
15 queryClient.setQueryData(['post', postId], context.previous)
16 }
17})

Tóm lại use case của TanStack Query:

FeatureDùng TanStack Query không?
Blog post list❌ Server Component là đủ
Search autocomplete✅ Cần real-time response
Infinite scroll✅ `useInfiniteQuery` rất tiện
Dashboard analytics✅ Nếu cần polling/realtime
Form submission❌ Server Action + `useMutation` tùy
User profile page❌ Server Component

Revalidation Strategy Chọn cái nào cho phù hợp

Cuối cùng, một điểm mình thấy nhiều người bỏ qua là việc kết hợp các revalidation strategy với nhau. Không phải lúc nào cũng chỉ dùng một cái:

TSX
1async function getPosts() {
2 'use cache'
3 // Time-based: tự động revalidate sau 5 phút
4 cacheLife({ revalidate: 300 })
5 // Tag-based: có thể invalidate on-demand bất cứ lúc nào
6 cacheTag('posts')
7
8 return db.post.findMany({ orderBy: { createdAt: 'desc' } })
9}

Cách này cho bạn best of both worlds: data tự refresh định kỳ (phòng trường hợp có update từ nguồn khác), nhưng cũng có thể invalidate ngay lập tức khi user tạo/sửa/xóa post.

Theo kinh nghiệm của mình thì công thức này hoạt động tốt cho hầu hết các trường hợp:

  • Static content (landing page, docs): revalidate: 3600 hoặc lâu hơn
  • Blog, news: revalidate: 300 + revalidateTag sau mutation
  • User-specific data: không cache ở server, dùng TanStack Query ở client
  • Realtime data: TanStack Query với polling hoặc WebSocket

Next.js data fetching nhìn có vẻ nhiều thứ, nhưng thực ra logic rất rõ ràng một khi bạn hiểu từng layer làm gì. Server Components cho static/semi-static data, Server Actions cho mutation, TanStack Query cho interactive client-side stuff. Ba cái này không cạnh tranh nhau chúng complement nhau.

NN

Nguyễn Nhật Long

@nguyennhatlong1303

Nguyễn Nhật Long is a Senior Frontend Engineer and Frontend Team Leader with 7 years of experience building real-time fintech platforms. Specializing in React, Next.js, TypeScript, and React Native, shipping 10+ products across Web, Mobile, Telegram Mini-Apps, and Web3.

Thấy hay? Chia sẻ cho bạn bè!

Data Fetching trong Next.js: Server Actions, Cache và Revalidation — Stacklog