Wprowadzenie: Rewolucja w obsłudze mutacji danych
Server Actions to — bez przesady — jedna z najbardziej przełomowych funkcji Next.js App Router. Zmienia ona sposób, w jaki budujemy aplikacje full-stack w React, i to dość radykalnie. Zamiast tworzyć osobne endpointy API do obsługi formularzy, aktualizacji bazy danych czy wysyłania e-maili, definiujemy asynchroniczne funkcje serwerowe bezpośrednio w kodzie React. Framework zajmuje się całą komunikacją HTTP za nas.
Brzmi za pięknie? W pewnym sensie tak.
Bo za tą elegancją kryje się kilka poważnych pułapek bezpieczeństwa, o których każdy programista musi wiedzieć. Każda funkcja oznaczona dyrektywą 'use server' staje się publicznym endpointem HTTP POST — dostępnym dla każdego, kto zna jej identyfikator. Bez walidacji, uwierzytelniania i autoryzacji dosłownie otwieramy drzwi dla atakujących.
W tym artykule przejdziemy przez Server Actions od A do Z — od podstaw, przez integrację z hookami React 19 (useActionState i useFormStatus), walidację z Zod, wzorce bezpieczeństwa, rewalidację cache, aż po zaawansowane narzędzia typu next-safe-action. Każdy wzorzec zilustruję praktycznym kodem TypeScript, gotowym do wrzucenia na produkcję.
Jeśli czytałeś nasz poprzedni artykuł o Middleware i Edge Runtime — traktuj ten materiał jako naturalne uzupełnienie. Middleware obsługuje żądania przed renderowaniem, a Server Actions zajmują się mutacjami danych po interakcji użytkownika. Razem tworzą kompletny model przetwarzania żądań w Next.js App Router.
Podstawy Server Actions: Dyrektywa 'use server'
Server Actions definiujemy za pomocą dyrektywy 'use server', która mówi Next.js, że dana funkcja powinna być wykonywana wyłącznie na serwerze. Są dwa główne sposoby ich definiowania — i warto znać oba.
Sposób 1: Dedykowany plik z akcjami
Rekomendowany wzorzec to tworzenie osobnych plików z dyrektywą 'use server' na poziomie modułu. Wszystkie eksportowane funkcje z takiego pliku automatycznie stają się Server Actions:
// app/actions/user.ts
'use server'
import { db } from '@/lib/db'
import { z } from 'zod'
const CreateUserSchema = z.object({
name: z.string().min(2, 'Imię musi mieć co najmniej 2 znaki'),
email: z.string().email('Nieprawidłowy adres e-mail'),
role: z.enum(['user', 'admin']).default('user'),
})
export async function createUser(formData: FormData) {
const rawData = {
name: formData.get('name'),
email: formData.get('email'),
role: formData.get('role'),
}
const validated = CreateUserSchema.safeParse(rawData)
if (!validated.success) {
return {
errors: validated.error.flatten().fieldErrors,
message: 'Nie udało się utworzyć użytkownika.',
}
}
const user = await db.user.create({
data: validated.data,
})
return { success: true, userId: user.id }
}
Sposób 2: Inline w Server Components
Dla prostszych przypadków możemy definiować Server Actions bezpośrednio wewnątrz Server Components, używając dyrektywy 'use server' na poziomie funkcji:
// app/posts/page.tsx (Server Component)
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
export default async function PostsPage() {
const posts = await db.post.findMany()
async function deletePost(postId: string) {
'use server'
await db.post.delete({ where: { id: postId } })
revalidatePath('/posts')
}
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
{post.title}
<form action={deletePost.bind(null, post.id)}>
<button type="submit">Usuń</button>
</form>
</li>
))}
</ul>
)
}
Ważna uwaga: definiując Server Action inline, tworzymy domknięcie (closure), które przechwytuje zmienne z zakresu komponentu. Next.js co prawda szyfruje te wartości przed wysłaniem do klienta, ale z perspektywy bezpieczeństwa — szczerze mówiąc — lepiej trzymać akcje w osobnych plikach. Mamy wtedy pełną kontrolę nad tym, jakie dane przepływają między klientem a serwerem.
Progresywne ulepszanie: Formularze działające bez JavaScript
Jedna z najfajniejszych zalet Server Actions to progresywne ulepszanie (progressive enhancement). Formularze wykorzystujące Server Actions działają nawet wtedy, gdy JavaScript w przeglądarce jest wyłączony albo jeszcze się nie załadował. To ma ogromne znaczenie dla użytkowników na wolnych połączeniach czy starszych urządzeniach.
Wystarczy przekazać Server Action jako wartość atrybutu action w formularzu:
// app/contact/page.tsx
import { sendMessage } from '@/app/actions/contact'
export default function ContactPage() {
return (
<form action={sendMessage}>
<label htmlFor="name">Imię i nazwisko</label>
<input id="name" name="name" type="text" required />
<label htmlFor="email">Adres e-mail</label>
<input id="email" name="email" type="email" required />
<label htmlFor="message">Wiadomość</label>
<textarea id="message" name="message" required />
<button type="submit">Wyślij wiadomość</button>
</form>
)
}
Gdy JavaScript jest wyłączony — formularz wykona standardowe żądanie POST. Gdy JavaScript jest dostępny — React przechwyci submit i wykona żądanie asynchronicznie, bez przeładowania strony. Najlepsza z obu opcji, nie trzeba wybierać.
React 19 Hooks: useActionState i useFormStatus
React 19 wprowadził dwa kluczowe hooki do pracy z Server Actions: useActionState do zarządzania stanem formularza i useFormStatus do śledzenia stanu wysyłania. Razem tworzą naprawdę solidny system obsługi formularzy z wbudowanymi stanami ładowania i błędów.
useActionState — stan formularza i wynik akcji
Hook useActionState (z pakietu react) zastępuje starszy useFormState z react-dom. Koncepcyjnie działa jak useReducer, tyle że z możliwością wykonywania efektów ubocznych w reducerze. Zwraca trzy wartości: aktualny stan, funkcję akcji do przekazania formularzowi oraz boolean pending wskazujący, czy akcja jest w trakcie wykonywania.
Istotna zmiana w sygnaturze — gdy używamy useActionState, Server Action musi przyjmować poprzedni stan jako pierwszy argument:
// app/actions/auth.ts
'use server'
import { z } from 'zod'
const SignupSchema = z.object({
name: z.string().min(2, 'Imię musi mieć co najmniej 2 znaki'),
email: z.string().email('Podaj prawidłowy adres e-mail'),
password: z.string().min(8, 'Hasło musi mieć co najmniej 8 znaków'),
})
export type SignupState = {
errors?: {
name?: string[]
email?: string[]
password?: string[]
}
message?: string
success?: boolean
}
export async function signup(
prevState: SignupState,
formData: FormData
): Promise<SignupState> {
const validated = SignupSchema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
password: formData.get('password'),
})
if (!validated.success) {
return {
errors: validated.error.flatten().fieldErrors,
message: 'Formularz zawiera błędy.',
}
}
// Sprawdź, czy użytkownik już istnieje
const existingUser = await db.user.findUnique({
where: { email: validated.data.email },
})
if (existingUser) {
return {
errors: { email: ['Ten adres e-mail jest już zarejestrowany'] },
message: 'Rejestracja nie powiodła się.',
}
}
// Utwórz użytkownika
await db.user.create({ data: validated.data })
return { success: true, message: 'Konto zostało utworzone!' }
}
A komponent kliencki korzystający z tego hooka wygląda tak:
// app/signup/signup-form.tsx
'use client'
import { useActionState } from 'react'
import { signup, type SignupState } from '@/app/actions/auth'
import { SubmitButton } from '@/components/submit-button'
const initialState: SignupState = {}
export function SignupForm() {
const [state, formAction, pending] = useActionState(signup, initialState)
return (
<form action={formAction}>
<div>
<label htmlFor="name">Imię</label>
<input id="name" name="name" type="text" />
{state.errors?.name && (
<p className="text-red-500">{state.errors.name[0]}</p>
)}
</div>
<div>
<label htmlFor="email">E-mail</label>
<input id="email" name="email" type="email" />
{state.errors?.email && (
<p className="text-red-500">{state.errors.email[0]}</p>
)}
</div>
<div>
<label htmlFor="password">Hasło</label>
<input id="password" name="password" type="password" />
{state.errors?.password && (
<p className="text-red-500">{state.errors.password[0]}</p>
)}
</div>
{state.message && (
<p className={state.success ? 'text-green-500' : 'text-red-500'}>
{state.message}
</p>
)}
<button type="submit" disabled={pending}>
{pending ? 'Rejestrowanie...' : 'Zarejestruj się'}
</button>
</form>
)
}
useFormStatus — wskaźnik ładowania w przycisku
Hook useFormStatus (z pakietu react-dom) służy do budowania przycisków z automatycznym wskaźnikiem ładowania. Jest tu jedna kluczowa zasada, o której łatwo zapomnieć: musi być użyty w komponencie zagnieżdżonym wewnątrz elementu <form>:
// components/submit-button.tsx
'use client'
import { useFormStatus } from 'react-dom'
interface SubmitButtonProps {
label?: string
loadingLabel?: string
}
export function SubmitButton({
label = 'Wyślij',
loadingLabel = 'Wysyłanie...',
}: SubmitButtonProps) {
const { pending } = useFormStatus()
return (
<button
type="submit"
disabled={pending}
className={pending ? 'opacity-50 cursor-not-allowed' : ''}
>
{pending ? loadingLabel : label}
</button>
)
}
W React 19 useFormStatus zwraca też dodatkowe właściwości: data (dane formularza), method (metoda HTTP) i action (referencja do akcji). Dzięki temu można budować bardziej rozbudowane wskaźniki stanu — choć w praktyce najczęściej wystarczy sam pending.
Kiedy użyć którego hooka?
Zasada jest prosta. useActionState stosujemy, gdy potrzebujemy dostępu do wyniku akcji — stanu formularza, błędów walidacji, komunikatów sukcesu. useFormStatus stosujemy, gdy potrzebujemy jedynie wskaźnika ładowania w przycisku submit.
W praktyce? Często używamy obu jednocześnie — useActionState na poziomie formularza, a useFormStatus w wydzielonym komponencie przycisku. To sprawdzone combo.
Walidacja danych z biblioteką Zod
Walidacja danych to absolutna podstawa bezpiecznych Server Actions. I nie ma tu żadnych wyjątków. Klient może wysłać dosłownie cokolwiek — niezależnie od tego, jakie ograniczenia nałożyliśmy w HTML czy w JavaScripcie po stronie przeglądarki. Dlatego każda Server Action musi walidować dane wejściowe po stronie serwera.
Biblioteka Zod stała się de facto standardem walidacji w ekosystemie Next.js (i szczerze, trudno się dziwić — jest naprawdę wygodna). Pozwala definiować schematy z pełnym wsparciem TypeScript i automatycznym generowaniem typów:
// lib/validations/product.ts
import { z } from 'zod'
export const ProductSchema = z.object({
name: z
.string()
.min(3, 'Nazwa produktu musi mieć co najmniej 3 znaki')
.max(100, 'Nazwa produktu nie może przekraczać 100 znaków'),
description: z
.string()
.min(10, 'Opis musi mieć co najmniej 10 znaków')
.max(2000, 'Opis nie może przekraczać 2000 znaków'),
price: z
.number()
.positive('Cena musi być większa od zera')
.max(999999.99, 'Cena nie może przekraczać 999 999,99 zł'),
category: z.enum(['electronics', 'clothing', 'books', 'home'], {
errorMap: () => ({ message: 'Wybierz prawidłową kategorię' }),
}),
inStock: z.boolean().default(true),
tags: z
.array(z.string().min(1))
.min(1, 'Dodaj co najmniej jeden tag')
.max(10, 'Maksymalnie 10 tagów'),
})
export type ProductInput = z.infer<typeof ProductSchema>
// app/actions/product.ts
'use server'
import { ProductSchema } from '@/lib/validations/product'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
import { auth } from '@/lib/auth'
export async function createProduct(prevState: any, formData: FormData) {
// 1. Uwierzytelnienie
const session = await auth()
if (!session?.user) {
return { message: 'Musisz być zalogowany.', errors: {} }
}
// 2. Parsowanie danych z FormData
const rawData = {
name: formData.get('name') as string,
description: formData.get('description') as string,
price: parseFloat(formData.get('price') as string),
category: formData.get('category') as string,
inStock: formData.get('inStock') === 'on',
tags: (formData.get('tags') as string)?.split(',').map((t) => t.trim()),
}
// 3. Walidacja z Zod
const validated = ProductSchema.safeParse(rawData)
if (!validated.success) {
return {
message: 'Formularz zawiera błędy walidacji.',
errors: validated.error.flatten().fieldErrors,
}
}
// 4. Autoryzacja — czy użytkownik ma prawo tworzyć produkty?
if (session.user.role !== 'admin' && session.user.role !== 'seller') {
return { message: 'Brak uprawnień do tworzenia produktów.', errors: {} }
}
// 5. Operacja na bazie danych
try {
await db.product.create({
data: {
...validated.data,
authorId: session.user.id,
},
})
} catch (error) {
return { message: 'Wystąpił błąd serwera. Spróbuj ponownie.', errors: {} }
}
// 6. Rewalidacja cache
revalidatePath('/products')
return { success: true, message: 'Produkt został utworzony!' }
}
Zwróć uwagę na kolejność operacji: najpierw uwierzytelnianie, potem walidacja, następnie autoryzacja, a dopiero na końcu logika biznesowa. Ten wzorzec warto sobie wbić w nawyk — powinien stać się Twoim standardem przy pisaniu każdej Server Action.
Bezpieczeństwo Server Actions: Co musisz wiedzieć
To najważniejsza sekcja tego artykułu. Naprawdę.
Server Actions wyglądają jak zwykłe funkcje TypeScript, ale w rzeczywistości to publiczne endpointy HTTP. Każdy, kto przechwytuje ruch sieciowy Twojej aplikacji (albo po prostu otworzy DevTools w przeglądarce), może poznać identyfikatory akcji i wywołać je bezpośrednio z dowolnymi danymi.
Wbudowane mechanizmy ochrony Next.js
Next.js 15+ oferuje kilka wbudowanych mechanizmów bezpieczeństwa:
- Szyfrowane identyfikatory akcji — Next.js generuje niedeterministyczne, szyfrowane ID dla każdej Server Action. Te identyfikatory są ponownie obliczane przy kolejnych buildach, co utrudnia ich przewidzenie.
- Eliminacja martwego kodu — nieużywane Server Actions są automatycznie usuwane z paczki klienckiej.
- Ochrona CSRF — Server Actions akceptują wyłącznie żądania POST. Next.js porównuje nagłówek
Originz nagłówkiemHost(lubX-Forwarded-Host). Jeśli się nie zgadzają — żądanie leci do kosza. - Szyfrowanie domknięć — gdy Server Action jest zdefiniowana inline, przechwycone zmienne są szyfrowane kluczem prywatnym generowanym przy każdym buildzie.
Te mechanizmy stanowią pierwszą linię obrony, ale — i to jest kluczowe — nie są wystarczające. To w gruncie rzeczy bezpieczeństwo przez ukrycie (security by obscurity). Resztą musi zająć się programista.
Wzorzec bezpiecznej Server Action
Każda Server Action chroniąca wrażliwe dane powinna implementować pięć warstw ochrony. Wiem, brzmi jak dużo, ale po kilku implementacjach wchodzi to w nawyk:
// app/actions/secure-action.ts
'use server'
import { auth } from '@/lib/auth'
import { z } from 'zod'
import { rateLimit } from '@/lib/rate-limit'
import { headers } from 'next/headers'
const UpdateProfileSchema = z.object({
userId: z.string().uuid(),
displayName: z.string().min(2).max(50),
bio: z.string().max(500).optional(),
})
export async function updateProfile(prevState: any, formData: FormData) {
// WARSTWA 1: Rate limiting
const headersList = await headers()
const ip = headersList.get('x-forwarded-for') ?? 'unknown'
const rateLimitResult = await rateLimit(ip, {
maxRequests: 10,
windowMs: 60_000, // 10 żądań na minutę
})
if (!rateLimitResult.success) {
return { message: 'Zbyt wiele żądań. Spróbuj za chwilę.' }
}
// WARSTWA 2: Uwierzytelnianie
const session = await auth()
if (!session?.user?.id) {
return { message: 'Sesja wygasła. Zaloguj się ponownie.' }
}
// WARSTWA 3: Walidacja danych wejściowych
const validated = UpdateProfileSchema.safeParse({
userId: formData.get('userId'),
displayName: formData.get('displayName'),
bio: formData.get('bio'),
})
if (!validated.success) {
return {
message: 'Nieprawidłowe dane.',
errors: validated.error.flatten().fieldErrors,
}
}
// WARSTWA 4: Autoryzacja — użytkownik może edytować TYLKO swój profil
if (validated.data.userId !== session.user.id) {
return { message: 'Nie masz uprawnień do edycji tego profilu.' }
}
// WARSTWA 5: Operacja z obsługą błędów
try {
await db.user.update({
where: { id: validated.data.userId },
data: {
displayName: validated.data.displayName,
bio: validated.data.bio,
},
})
return { success: true, message: 'Profil zaktualizowany.' }
} catch (error) {
// NIGDY nie ujawniaj szczegółów błędu klientowi w produkcji
console.error('Błąd aktualizacji profilu:', error)
return { message: 'Wystąpił nieoczekiwany błąd.' }
}
}
Klucz szyfrowania w środowiskach wieloinstancyjnych
Domyślnie Next.js generuje nowy klucz szyfrowania przy każdym buildzie. Problem pojawia się w środowiskach z wieloma instancjami (Kubernetes, skalowanie horyzontalne) — każda instancja może mieć inny klucz, co prowadzi do frustrujących błędów dekrypcji. Rozwiązanie jest proste:
# .env.production
# Klucz musi być zakodowany w base64, o długości 16, 24 lub 32 bajtów
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=your-base64-encoded-32-byte-key
Dzięki temu wszystkie instancje korzystają z tego samego klucza szyfrowania, a klucz jest trwały między buildami.
Rewalidacja cache po mutacjach
Po każdej mutacji danych musimy powiedzieć Next.js, że dane w cache są nieaktualne. Framework daje nam dwa mechanizmy: revalidatePath (rewalidacja po ścieżce) i revalidateTag (rewalidacja po tagu). Oba mają swoje miejsce.
revalidatePath — odświeżanie po ścieżce
Najprostszy sposób — unieważnia cache dla konkretnej ścieżki URL:
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
export async function updatePost(postId: string, formData: FormData) {
// ... walidacja i aktualizacja w bazie danych ...
// Rewaliduj stronę pojedynczego postu
revalidatePath(`/posts/${postId}`)
// Rewaliduj również listę postów
revalidatePath('/posts')
// Opcjonalnie: rewaliduj cały layout
revalidatePath('/posts', 'layout')
// Przekieruj — ZAWSZE po revalidatePath, nie przed!
redirect(`/posts/${postId}`)
}
Ta kolejność jest ważna i łatwo ją pomylić: zawsze wywołuj revalidatePath przed redirect. Jeśli zrobisz odwrotnie, rewalidacja nie zostanie wykonana, bo redirect przerywa dalsze wykonywanie funkcji. Sam kiedyś na tym straciłem sporo czasu debugując.
revalidateTag — granularna rewalidacja
Bardziej precyzyjny mechanizm, który pozwala oznaczać dane tagami i unieważniać je selektywnie. Jest szczególnie przydatny, gdy te same dane pojawiają się na wielu stronach:
// lib/data/posts.ts
import { unstable_cache } from 'next/cache'
export const getPostsByAuthor = unstable_cache(
async (authorId: string) => {
return db.post.findMany({ where: { authorId } })
},
['posts-by-author'],
{ tags: ['posts'], revalidate: 3600 }
)
export const getPostById = unstable_cache(
async (postId: string) => {
return db.post.findUnique({ where: { id: postId } })
},
['post-detail'],
{ tags: ['posts', `post-${postId}`] }
)
// app/actions/posts.ts
'use server'
import { revalidateTag } from 'next/cache'
export async function deletePost(postId: string) {
await db.post.delete({ where: { id: postId } })
// Unieważnij cache tylko dla tego konkretnego posta
revalidateTag(`post-${postId}`)
// Unieważnij również ogólną listę postów
revalidateTag('posts')
}
Tagi są zdecydowanie wydajniejsze niż revalidatePath, bo unieważniają tylko te dane, które faktycznie się zmieniły — a nie całą stronę. W większych projektach ta różnica jest odczuwalna.
Biblioteka next-safe-action: Bezpieczne akcje bez powtarzania kodu
next-safe-action to biblioteka, która rozwiązuje jeden z najbardziej irytujących problemów Server Actions — ciągłe powtarzanie kodu bezpieczeństwa. Zamiast pisać uwierzytelnianie, walidację i obsługę błędów w każdej akcji od zera, definiujemy klienta akcji z pipeline'em middleware:
// lib/safe-action.ts
import { createSafeActionClient } from 'next-safe-action'
import { auth } from '@/lib/auth'
// Bazowy klient — bez uwierzytelniania (do akcji publicznych)
export const publicAction = createSafeActionClient({
handleServerError(error) {
console.error('Błąd Server Action:', error)
return 'Wystąpił nieoczekiwany błąd.'
},
})
// Klient z uwierzytelnianiem — dla akcji chronionych
export const authAction = publicAction.use(async ({ next }) => {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Nie jesteś zalogowany.')
}
// Przekaż dane sesji do kolejnych middleware i do samej akcji
return next({
ctx: {
userId: session.user.id,
userRole: session.user.role,
},
})
})
// Klient z uprawnieniami admina
export const adminAction = authAction.use(async ({ ctx, next }) => {
if (ctx.userRole !== 'admin') {
throw new Error('Wymagane uprawnienia administratora.')
}
return next({ ctx })
})
Teraz tworzenie bezpiecznych akcji staje się proste i (co ważniejsze) deklaratywne:
// app/actions/products.ts
'use server'
import { authAction, adminAction } from '@/lib/safe-action'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
// Akcja wymagająca zalogowania
export const addToCart = authAction
.schema(
z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive().max(99),
})
)
.action(async ({ parsedInput, ctx }) => {
const { productId, quantity } = parsedInput
const { userId } = ctx
await db.cart.upsert({
where: { userId_productId: { userId, productId } },
create: { userId, productId, quantity },
update: { quantity: { increment: quantity } },
})
revalidatePath('/cart')
return { success: true }
})
// Akcja wymagająca uprawnień administratora
export const deleteProduct = adminAction
.schema(z.object({ productId: z.string().uuid() }))
.action(async ({ parsedInput }) => {
await db.product.delete({
where: { id: parsedInput.productId },
})
revalidatePath('/products')
return { success: true, message: 'Produkt usunięty.' }
})
Po stronie klienta korzystamy z dedykowanych hooków next-safe-action:
// components/add-to-cart-button.tsx
'use client'
import { useAction } from 'next-safe-action/hooks'
import { addToCart } from '@/app/actions/products'
export function AddToCartButton({ productId }: { productId: string }) {
const { execute, status, result } = useAction(addToCart)
return (
<button
onClick={() => execute({ productId, quantity: 1 })}
disabled={status === 'executing'}
>
{status === 'executing' ? 'Dodawanie...' : 'Dodaj do koszyka'}
{result?.serverError && (
<span className="text-red-500">{result.serverError}</span>
)}
</button>
)
}
Największa zaleta tego podejścia? Autoryzacja jest deklaratywna i niemożliwa do pominięcia. Gdy używasz authAction, sesja jest zawsze sprawdzana. Bez wyjątków. Nie da się „zapomnieć" o sprawdzeniu uprawnień.
Zaawansowane wzorce: Optimistic Updates i obsługa błędów
Aktualizacje optymistyczne
Aktualizacje optymistyczne to wzorzec, w którym UI jest natychmiast aktualizowane przed potwierdzeniem z serwera. Jeśli serwer zwróci błąd, UI automatycznie cofa się do poprzedniego stanu. Brzmi ryzykownie? W praktyce to znacząco poprawia postrzeganą szybkość aplikacji i użytkownicy to uwielbiają:
// components/like-button.tsx
'use client'
import { useOptimistic, useTransition } from 'react'
import { toggleLike } from '@/app/actions/posts'
interface LikeButtonProps {
postId: string
initialLikes: number
isLiked: boolean
}
export function LikeButton({ postId, initialLikes, isLiked }: LikeButtonProps) {
const [isPending, startTransition] = useTransition()
const [optimisticState, setOptimisticState] = useOptimistic(
{ likes: initialLikes, isLiked },
(currentState) => ({
likes: currentState.isLiked
? currentState.likes - 1
: currentState.likes + 1,
isLiked: !currentState.isLiked,
})
)
const handleClick = () => {
startTransition(async () => {
setOptimisticState(null) // wartość nie ma znaczenia, liczy się reducer
await toggleLike(postId)
})
}
return (
<button onClick={handleClick} disabled={isPending}>
{optimisticState.isLiked ? '❤️' : '🤍'} {optimisticState.likes}
</button>
)
}
Centralna obsługa błędów z error boundaries
Server Actions mogą rzucać wyjątki, które zostaną przechwycone przez najbliższy komponent error.tsx. To pozwala na elegancką obsługę nieoczekiwanych błędów bez zaśmiecania każdej akcji blokami try-catch:
// app/dashboard/error.tsx
'use client'
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="p-4 bg-red-50 rounded-lg">
<h2>Coś poszło nie tak</h2>
<p>Przepraszamy, wystąpił nieoczekiwany błąd.</p>
<p className="text-sm text-gray-500">
Identyfikator błędu: {error.digest}
</p>
<button onClick={reset}>Spróbuj ponownie</button>
</div>
)
}
Nigdy nie ujawniaj klientowi szczegółowych komunikatów błędów z serwera w produkcji. Właściwość error.digest to bezpieczny hash, który pozwala skorelować błąd z logami serwera — bez ujawniania wrażliwych informacji typu ścieżki plików czy nazwy tabel.
Najczęstsze błędy i pułapki
Na koniec — omówmy najczęstsze błędy, na które natykają się programiści pracujący z Server Actions. Uniknięcie tych pułapek zaoszczędzi Ci naprawdę sporo godzin debugowania.
Pułapka 1: Używanie Server Actions do pobierania danych
Server Actions używają żądań POST i nie mogą być cachowane. Do pobierania danych zawsze używaj Server Components z fetch lub bibliotekami ORM. To częsty błąd u osób przechodzących z tradycyjnych frameworków:
// ❌ ŹLE — Server Action do pobierania danych
'use server'
export async function getProducts() {
return db.product.findMany() // POST, brak cache
}
// ✅ DOBRZE — Server Component z bezpośrednim dostępem do bazy
// app/products/page.tsx (Server Component)
export default async function ProductsPage() {
const products = await db.product.findMany() // cachowalne
return <ProductList products={products} />
}
Pułapka 2: Poleganie wyłącznie na walidacji klienckiej
Atrybuty HTML (required, type="email") i walidacja Zod po stronie klienta służą wyłącznie UX. Każdy może je ominąć, wysyłając żądanie HTTP bezpośrednio — np. przez cURL czy Postmana. Walidacja serwerowa jest obowiązkowa, bez dyskusji.
Pułapka 3: Mutacje w trakcie renderowania
Next.js jawnie zabrania wykonywania mutacji (zapisywanie cookies, rewalidacja cache, operacje bazodanowe) w trakcie renderowania Server Components. Mutacje powinny zachodzić wyłącznie w Server Actions, Route Handlers lub API Routes:
// ❌ ŹLE — mutacja w trakcie renderowania
export default async function Page({ searchParams }: { searchParams: { logout?: string } }) {
const params = await searchParams
if (params.logout) {
await signOut() // NIGDY tak nie rób!
}
return <div>...</div>
}
// ✅ DOBRZE — mutacja w Server Action
'use server'
export async function logoutAction() {
await signOut()
redirect('/login')
}
Pułapka 4: Mylenie uwierzytelniania z autoryzacją
To klasyk. Uwierzytelnianie (authentication) odpowiada na pytanie „kim jesteś?". Autoryzacja (authorization) — „co możesz zrobić?". Wielu programistów sprawdza tylko, czy użytkownik jest zalogowany, ale kompletnie pomija weryfikację uprawnień:
// ❌ NIEPEŁNE — tylko uwierzytelnianie
export async function deleteComment(commentId: string) {
'use server'
const session = await auth()
if (!session) throw new Error('Niezalogowany')
await db.comment.delete({ where: { id: commentId } })
}
// ✅ KOMPLETNE — uwierzytelnianie + autoryzacja
export async function deleteComment(commentId: string) {
'use server'
const session = await auth()
if (!session) throw new Error('Niezalogowany')
const comment = await db.comment.findUnique({
where: { id: commentId },
})
// Sprawdź, czy użytkownik jest autorem komentarza LUB administratorem
if (comment?.authorId !== session.user.id && session.user.role !== 'admin') {
throw new Error('Brak uprawnień')
}
await db.comment.delete({ where: { id: commentId } })
revalidatePath('/comments')
}
Pułapka 5: Ujawnianie wrażliwych danych w błędach
W produkcji nigdy nie zwracaj surowych komunikatów błędów do klienta. Mogą zawierać ścieżki plików, nazwy tabel, stack trace'y — mnóstwo informacji przydatnych dla atakujących. Loguj szczegóły po stronie serwera, a klientowi zwracaj jedynie ogólny komunikat z identyfikatorem błędu.
Audyt bezpieczeństwa: Lista kontrolna
Zanim wdrożysz aplikację na produkcję, przejdź przez tę listę kontrolną dla każdej Server Action w projekcie. Serio — zrób to, nawet jeśli wydaje Ci się, że „na pewno wszystko jest OK":
- Walidacja danych wejściowych — czy każda akcja waliduje dane za pomocą Zod lub innej biblioteki?
- Uwierzytelnianie — czy akcje chroniące dane sprawdzają sesję użytkownika?
- Autoryzacja — czy akcje weryfikują nie tylko tożsamość, ale również uprawnienia?
- Rate limiting — czy wrażliwe akcje (logowanie, rejestracja, resetowanie hasła) mają limity żądań?
- Obsługa błędów — czy akcje nie ujawniają wrażliwych informacji w komunikatach błędów?
- Rewalidacja cache — czy po mutacjach cache jest prawidłowo unieważniany?
- Klucz szyfrowania — czy w środowiskach wieloinstancyjnych ustawiono
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY?
Żeby systematycznie sprawdzić wszystkie Server Actions w projekcie, wyszukaj w kodzie dyrektywę 'use server' i dla każdego znalezionego pliku zweryfikuj powyższe punkty. Lepiej poświęcić godzinę na audyt niż potem gasić pożar na produkcji.
Podsumowanie
Server Actions w Next.js App Router to potężne narzędzie, które eliminuje potrzebę ręcznego tworzenia endpointów API do mutacji danych. Dzięki integracji z hookami React 19 — useActionState i useFormStatus — budowanie formularzy z walidacją, stanami ładowania i obsługą błędów staje się intuicyjne.
Ale nie zapominaj o bezpieczeństwie.
Najważniejsze wnioski z tego artykułu:
- Server Actions to publiczne endpointy HTTP — traktuj je z takim samym rygorem bezpieczeństwa jak tradycyjne API.
- Walidacja po stronie serwera z Zod jest obowiązkowa — walidacja kliencka to tylko UX.
- Pięć warstw ochrony: rate limiting, uwierzytelnianie, walidacja, autoryzacja, obsługa błędów.
useActionStatedo zarządzania stanem formularza,useFormStatusdo wskaźników ładowania.revalidatePathirevalidateTagdo odświeżania danych po mutacjach.next-safe-actioneliminuje powtarzalność kodu bezpieczeństwa dzięki pipeline'owi middleware.- Nie używaj Server Actions do pobierania danych — od tego są Server Components.
Server Actions, w połączeniu z Middleware (który omówiliśmy w poprzednim artykule), tworzą kompletny model przetwarzania żądań w Next.js. Middleware filtruje żądania na wejściu, Server Components renderują dane, a Server Actions obsługują mutacje. Opanowanie tych trzech mechanizmów to solidny fundament do budowania bezpiecznych aplikacji full-stack w ekosystemie Next.js.