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.
Nguyễn Nhật Long
@nguyennhatlong1303
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:
1// app/blog/page.tsx2export default async function BlogPage() {3 const posts = await fetch('https://api.example.com/posts').then(r => r.json())45 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ư:
1// ❌ Sai sequential, chậm không cần thiết2const 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 đó:
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:
1import { cache } from 'react'23export 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.
1// Cache toàn bộ output của component2async function PostList() {3 'use cache'4 cacheLife({ revalidate: 60 }) // fresh trong 60 giây56 const posts = await db.post.findMany()7 return <ul>{posts.map(p => <PostItem key={p.id} post={p} />)}</ul>8}
1// Hoặc cache ở function level2async function getPosts() {3 'use cache'4 cacheLife({ revalidate: 300 })5 cacheTag('posts') // tag này dùng để invalidate on-demand67 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:
1// actions/post.ts2'use server'34import { revalidateTag } from 'next/cache'56export async function createPost(formData: FormData) {7 const title = formData.get('title') as string8 const content = formData.get('content') as string910 await db.post.create({ data: { title, content } })1112 // Invalidate cache sau khi tạo post mới13 revalidateTag('posts')14}
Rồi dùng trong form:
1import { createPost } from '@/actions/post'23export 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:
1'use client'23import { deletePost } from '@/actions/post'45export function DeleteButton({ postId }: { postId: string }) {6 return (7 <button onClick={() => deletePost(postId)}>8 Xóa9 </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áp | Cú pháp | Khi 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ợp | Cả hai | Khi 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ể:
1'use client'23import { useQuery } from '@tanstack/react-query'45// Search autocomplete cần fetch lại mỗi khi user gõ6export function SearchBox() {7 const [query, setQuery] = useState('')89 const { data: results } = useQuery({10 queryKey: ['search', query],11 queryFn: () => searchPosts(query),12 staleTime: 30_000, // cache 30 giây13 enabled: query.length > 214 })1516 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}
Và useMutation với optimistic update cái này tạo ra UX rất mượt:
1const likeMutation = useMutation({2 mutationFn: (postId: string) => likePost(postId),3 onMutate: async (postId) => {4 // Update UI ngay lập tức, không chờ server5 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 + 110 }))11 return { previous }12 },13 onError: (err, postId, context) => {14 // Rollback nếu lỗi15 queryClient.setQueryData(['post', postId], context.previous)16 }17})
Tóm lại use case của TanStack Query:
| Feature | Dù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:
1async function getPosts() {2 'use cache'3 // Time-based: tự động revalidate sau 5 phút4 cacheLife({ revalidate: 300 })5 // Tag-based: có thể invalidate on-demand bất cứ lúc nào6 cacheTag('posts')78 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: 3600hoặc lâu hơn - Blog, news:
revalidate: 300+revalidateTagsau 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.
Nguyễn Nhật Long
@nguyennhatlong1303Nguyễ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è!