Server Actions — formularze bez endpointów API

Jak używać Server Actions w Next.js 16 bez magicznego myślenia? Formularze, useActionState, walidacja, bezpieczeństwo i momenty, w których Route Handlers nadal są lepsze.

Opublikowano

1 września 2025 12:54

Czytanie

5 min czytania

Aktualizacja

15 kwietnia 2026 11:52

Server Actions to jedna z najmocniejszych funkcji App Router, ale też jedna z najczęściej upraszczanych w tutorialach. Pozwalają wykonywać kod na serwerze bezpośrednio z komponentów, często bez pisania osobnych endpointów API, czyli Application Programming Interface, definiuje sposób komunikacji między aplikacjami lub modułami. dla mutacji w obrębie tej samej aplikacji.

Brzmi jak magia? Tylko do momentu, w którym wchodzą walidacja, autoryzacja, rewalidacja cache i współpraca z klientem. Jeśli dopiero zaczynasz z Next.js, sprawdź najpierw jak skonfigurować projekt od zera. W tym artykule pokażę, jak Server Actions działają, kiedy ich używać i gdzie kończy się wygoda, a zaczynają realne trade-offy.

Krótka odpowiedź: Server Actions to asynchroniczne funkcje wykonywane na serwerze, które możesz wywoływać bezpośrednio z komponentów React bez pisania osobnych endpointów API. Pozwalają obsługiwać formularze, mutacje danych i walidację po stronie serwera z wbudowaną rewalidacją cache i progressive enhancement. Warto ich używać do standardowych operacji CRUD to skrót od Create, Read, Update, Delete, czyli podstawowych operacji wykonywanych na danych. wewnątrz jednej aplikacji Next.js, ale nie zastępują Route Handlers tam, gdzie potrzebujesz publicznego API lub pełnej kontroli nad HTTP.

Jeśli masz już wybrany kierunek i chcesz pełny przykład formularza z walidacją klient/serwer, zobacz React Hook Form + Zod w Next.js — walidacja formularzy z Server Actions.

Czym są Server Actions?

Server Actions to funkcje asynchroniczne wykonywane na serwerze, które możesz wywoływać bezpośrednio z komponentów React — zarówno Server Components, jak i Client Components.

Code
// app/contact/page.tsx
async function submitForm(formData: FormData) {
  'use server'
  
  const email = formData.get('email')
  const message = formData.get('message')
  
  await db.message.create({
    data: { email, message },
  })
}
 
export default function ContactPage() {
  return (
    <form action={submitForm}>
      <input type="email" name="email" required />
      <textarea name="message" required />
      <button type="submit">Wyślij</button>
    </form>
  )
}

Zwróć uwagę: 'use server' na początku funkcji. Ta dyrektywa mówi Next.js, że funkcja ma się wykonać na serwerze.

Jak to działa pod spodem?

Gdy użytkownik klika "Wyślij":

  1. Przeglądarka wysyła żądanie POST do specjalnego endpointu Next.js
  2. Next.js deserializuje FormData i wywołuje funkcję submitForm
  3. Funkcja wykonuje się na serwerze (dostęp do bazy, plików, env)
  4. Wynik wraca do przeglądarki
  5. React automatycznie rewaliduje dane na stronie

Nie musisz pisać API route, konfigurować CORS, ani zarządzać serializacją.

Definiowanie Server Actions

Masz dwa sposoby:

1. Inline w komponencie

Code
export default function Page() {
  async function handleSubmit(formData: FormData) {
    'use server'
    // kod serwera
  }
 
  return <form action={handleSubmit}>...</form>
}

2. W osobnym pliku (zalecane)

Code
// app/actions.ts
'use server'
 
export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  const content = formData.get('content') as string
  
  const post = await db.post.create({
    data: { title, content },
  })
  
  return post
}
 
export async function deletePost(id: string) {
  await db.post.delete({ where: { id } })
}
Code
// app/posts/new/page.tsx
import { createPost } from '@/app/actions'
 
export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" />
      <textarea name="content" />
      <button type="submit">Utwórz</button>
    </form>
  )
}

Plik z 'use server' na górze eksportuje tylko Server Actions. Żadna inna funkcja z tego pliku nie trafi do klienta.

Użycie z Client Components

Server Actions działają też w Client Components:

Code
// components/DeleteButton.tsx
'use client'
 
import { deletePost } from '@/app/actions'
import { useTransition } from 'react'
 
export function DeleteButton({ postId }: { postId: string }) {
  const [isPending, startTransition] = useTransition()
 
  const handleDelete = () => {
    startTransition(async () => {
      await deletePost(postId)
    })
  }
 
  return (
    <button onClick={handleDelete} disabled={isPending}>
      {isPending ? 'Usuwanie...' : 'Usuń'}
    </button>
  )
}

useTransition pozwala śledzić stan ładowania bez blokowania UI, czyli User Interface, to wizualna i interakcyjna warstwa produktu..

useActionState — stan formularza

W aktualnym React i Next.js do obsługi wyniku akcji najczęściej użyjesz useActionState. Starsze materiały często pokazują useFormState, ale dziś lepiej trzymać się nowszego API:

Code
// app/actions.ts
'use server'
 
type State = {
  message: string
  success: boolean
} | null
 
export async function subscribe(
  prevState: State,
  formData: FormData
): Promise<State> {
  const email = formData.get('email') as string
 
  if (!email.includes('@')) {
    return { message: 'Nieprawidłowy email', success: false }
  }
 
  await db.subscriber.create({ data: { email } })
  
  return { message: 'Zapisano!', success: true }
}
Code
// components/SubscribeForm.tsx
'use client'
 
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { subscribe } from '@/app/actions'
 
function SubmitButton() {
  const { pending } = useFormStatus()
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Zapisywanie...' : 'Zapisz się'}
    </button>
  )
}
 
export function SubscribeForm() {
  const [state, formAction] = useActionState(subscribe, null)
 
  return (
    <form action={formAction}>
      <input type="email" name="email" required />
      <SubmitButton />
      
      {state?.message && (
        <p className={state.success ? 'text-green-600' : 'text-red-600'}>
          {state.message}
        </p>
      )}
    </form>
  )
}

Walidacja danych

Server Actions to idealne miejsce na walidację. Polecam Zod:

Code
// app/actions.ts
'use server'
 
import { z } from 'zod'
 
const ContactSchema = z.object({
  name: z.string().min(2, 'Imię musi mieć min. 2 znaki'),
  email: z.string().email('Nieprawidłowy email'),
  message: z.string().min(10, 'Wiadomość musi mieć min. 10 znaków'),
})
 
type State = {
  errors?: {
    name?: string[]
    email?: string[]
    message?: string[]
  }
  message?: string
  success: boolean
}
 
export async function sendMessage(
  prevState: State,
  formData: FormData
): Promise<State> {
  const validatedFields = ContactSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    message: formData.get('message'),
  })
 
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      success: false,
    }
  }
 
  const { name, email, message } = validatedFields.data
 
  try {
    await sendEmail({ to: 'contact@example.com', from: email, name, message })
    return { message: 'Wiadomość wysłana!', success: true }
  } catch (error) {
    return { message: 'Błąd wysyłania', success: false }
  }
}
Code
// components/ContactForm.tsx
'use client'
 
import { useActionState } from 'react'
import { sendMessage } from '@/app/actions'
 
export function ContactForm() {
  const [state, formAction] = useActionState(sendMessage, { success: false })
 
  return (
    <form action={formAction}>
      <div>
        <input name="name" placeholder="Imię" />
        {state.errors?.name && (
          <p className="text-red-500 text-sm">{state.errors.name[0]}</p>
        )}
      </div>
      
      <div>
        <input name="email" type="email" placeholder="Email" />
        {state.errors?.email && (
          <p className="text-red-500 text-sm">{state.errors.email[0]}</p>
        )}
      </div>
      
      <div>
        <textarea name="message" placeholder="Wiadomość" />
        {state.errors?.message && (
          <p className="text-red-500 text-sm">{state.errors.message[0]}</p>
        )}
      </div>
      
      <button type="submit">Wyślij</button>
      
      {state.message && (
        <p className={state.success ? 'text-green-600' : 'text-red-600'}>
          {state.message}
        </p>
      )}
    </form>
  )
}

Rewalidacja danych po akcji

Po mutacji często chcesz odświeżyć dane na stronie:

Code
// app/actions.ts
'use server'
 
import { revalidatePath, revalidateTag } from 'next/cache'
 
export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  
  await db.post.create({ data: { title } })
  
  // Odśwież konkretną ścieżkę
  revalidatePath('/posts')
  
  // Lub odśwież po tagu
  revalidateTag('posts', 'max')
}

Jeśli wywołujesz mutację w Server Action i chcesz natychmiast zobaczyć własny zapis, sprawdź też updateTag(). revalidateTag(..., 'max') działa bardziej w modelu stale-while-revalidate.

Przekierowania

Code
// app/actions.ts
'use server'
 
import { redirect } from 'next/navigation'
 
export async function createPost(formData: FormData) {
  const post = await db.post.create({
    data: { title: formData.get('title') as string },
  })
  
  redirect(`/posts/${post.id}`)
}

redirect musi być wywołany poza blokiem try/catch — rzuca specjalny error, który Next.js przechwytuje.

Optymistyczne aktualizacje

Dla lepszego UX, czyli User Experience, opisuje całe doświadczenie użytkownika podczas korzystania z produktu. możesz aktualizować UI przed zakończeniem akcji:

Code
'use client'
 
import { useOptimistic } from 'react'
import { likePost } from '@/app/actions'
 
export function LikeButton({ postId, initialLikes }: { 
  postId: string
  initialLikes: number 
}) {
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    initialLikes,
    (state, increment: number) => state + increment
  )
 
  const handleLike = async () => {
    addOptimisticLike(1) // natychmiast +1
    await likePost(postId)   // potem prawdziwa akcja
  }
 
  return (
    <button onClick={handleLike}>
      ❤️ {optimisticLikes}
    </button>
  )
}

Jeśli akcja się nie powiedzie, React automatycznie cofnie optymistyczną zmianę.

Progressive Enhancement

Formularze z Server Actions działają nawet bez JavaScript:

Code
export default function SearchPage() {
  async function search(formData: FormData) {
    'use server'
    const query = formData.get('q')
    redirect(`/search?q=${query}`)
  }
 
  return (
    <form action={search}>
      <input name="q" placeholder="Szukaj..." />
      <button type="submit">Szukaj</button>
    </form>
  )
}

Bez JS: formularz wysyła tradycyjny POST, serwer przetwarza i przekierowuje. Z JS: Next.js przechwytuje submit, wykonuje akcję asynchronicznie, aktualizuje UI.

Bezpieczeństwo

Server Actions są bezpieczne, ale pamiętaj o podstawach:

1. Zawsze waliduj dane

Code
// ❌ Źle — brak walidacji
export async function updateUser(formData: FormData) {
  'use server'
  await db.user.update({
    where: { id: formData.get('id') },
    data: { role: formData.get('role') }, // użytkownik może ustawić role: 'admin'!
  })
}
 
// ✅ Dobrze — walidacja + autoryzacja
export async function updateUser(formData: FormData) {
  'use server'
  const session = await getSession()
  if (!session?.user) throw new Error('Unauthorized')
  
  const data = UserUpdateSchema.parse({
    name: formData.get('name'),
    // nie przyjmuj 'role' z formularza!
  })
  
  await db.user.update({
    where: { id: session.user.id }, // używaj ID z sesji, nie z formularza
    data,
  })
}

2. Formularz w UI nie jest autoryzacją

To, że przycisk renderujesz tylko zalogowanemu użytkownikowi, nie oznacza jeszcze bezpieczeństwa. Server Action musi sama weryfikować sesję i uprawnienia, bo request może zostać odtworzony poza normalnym flow interfejsu.

3. Sprawdzaj uprawnienia

Code
export async function deletePost(postId: string) {
  'use server'
  
  const session = await getSession()
  if (!session?.user) throw new Error('Unauthorized')
  
  const post = await db.post.findUnique({ where: { id: postId } })
  
  if (post?.authorId !== session.user.id) {
    throw new Error('Forbidden')
  }
  
  await db.post.delete({ where: { id: postId } })
}

4. Rate limiting — sprawdź szeroki przewodnik Upstash Redis w Next.js — sesje, cache, rate limiting i liczniki

Code
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
import { headers } from 'next/headers'
 
const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, '1 m'), // 5 żądań na minutę
})
 
export async function submitForm(formData: FormData) {
  'use server'
 
  const ip = (await headers()).get('x-forwarded-for') ?? 'anonymous'
  const { success } = await ratelimit.limit(ip)
  
  if (!success) {
    return { error: 'Zbyt wiele żądań. Spróbuj później.' }
  }
  
  // ... reszta logiki
}

Kiedy używać Server Actions?

Używaj Server Actions dla:

  • Formularzy (kontakt, logowanie, rejestracja)
  • Mutacji danych (CRUD)
  • Prostych akcji (like, follow, delete)
  • Gdy chcesz progressive enhancement

Rozważ API Routes gdy:

  • Potrzebujesz endpointu dla zewnętrznych klientów
  • Integrujesz z webhookami
  • Potrzebujesz pełnej kontroli nad HTTP (headers, status codes)
  • Budujesz publiczne API
  • Chcesz oddzielić transport HTTP od logiki domenowej

FAQ

Czym różnią się Server Actions od Route Handlers w Next.js?

Server Actions to funkcje serwerowe wywoływane bezpośrednio z komponentów React — głównie do mutacji danych i obsługi formularzy wewnątrz jednej aplikacji. Route Handlers (dawniej API Routes) tworzą pełnoprawne endpointy HTTP z kontrolą nad nagłówkami, statusami i metodami — odpowiednie dla publicznych API, webhooków i klientów zewnętrznych.

Czy Server Actions są bezpieczne?

Tak, ale bezpieczeństwo trzeba aktywnie zapewnić. Każda Server Action musi samodzielnie weryfikować sesję i uprawnienia, walidować dane wejściowe (np. przez Zod) oraz chronić przed nadużyciami przez rate limiting. Sam fakt, że przycisk jest widoczny tylko dla zalogowanego użytkownika, nie zabezpiecza akcji.

Jak obsłużyć stan ładowania i błędy formularza w Server Actions?

Używaj hooka useActionState z React do śledzenia stanu akcji i zwracania komunikatów o błędach. Do obsługi stanu przycisku submit podczas wysyłania formularza służy useFormStatus z react-dom. Oba hooki działają razem i pozwalają budować responsywne formularze bez osobnego zarządzania stanem.

Czy formularze z Server Actions działają bez JavaScript?

Tak — to jedna z kluczowych zalet. Gdy JavaScript nie jest dostępny, formularz wysyła tradycyjny POST, a serwer przetwarza dane i wykonuje przekierowanie. Gdy JS jest aktywny, Next.js przechwytuje submit i wykonuje akcję asynchronicznie bez pełnego przeładowania strony (progressive enhancement).

Jak rewalidować cache po Server Action?

Po wykonaniu mutacji wywołaj revalidatePath('/sciezka'), żeby odświeżyć konkretną stronę, albo revalidateTag('tag', 'max'), żeby unieważnić wszystkie zasoby z danym tagiem cache w modelu stale-while-revalidate. Gdy chcesz natychmiast odczytać świeży wynik po własnej mutacji, rozważ też updateTag(). Funkcje importujesz z next/cache i wywołujesz bezpośrednio wewnątrz Server Action.

Czy można używać Server Actions w Client Components?

Tak. Importujesz Server Action z pliku z dyrektywą 'use server' i wywołujesz ją w handlerze lub wewnątrz useTransition. Hook useTransition pozwala śledzić, czy akcja jest w toku, bez blokowania interfejsu użytkownika.

Kiedy Server Actions nie są dobrym wyborem?

Gdy potrzebujesz endpointu dla zewnętrznych klientów (inne aplikacje, mobile), integrujesz webhookowy callback, potrzebujesz pełnej kontroli nad odpowiedzią HTTP (nagłówki, kody statusu) lub budujesz publiczne API konsumowane przez wiele frontendów — w tych przypadkach lepszym wyborem są Route Handlers.

Podsumowanie

Server Actions mocno upraszczają formularze i mutacje w Next.js, ale nie są "magicznie lepsze" w każdej sytuacji:

  • Brak boilerplate'u API routes
  • Automatyczna serializacja FormData
  • Wbudowana rewalidacja cache
  • Progressive enhancement out of the box
  • Typowanie end-to-end z TypeScript

To nie zastępuje Route Handlers ani publicznych endpointów HTTP. Dla większości formularzy i mutacji wewnątrz jednej aplikacji są jednak prostszym punktem startu niż ręczne budowanie warstwy API od zera.

Źródła i dokumentacja


Chcesz poznać więcej o Next.js? Przeczytaj o pobieraniu danych z fetch, cache i rewalidacją lub sprawdź Prisma + Next.js — fullstack tutorial.

Pracuję z tym zawodowo.

Jeśli chcesz przełożyć ten temat na lepszą architekturę frontendu, uporządkować React lub Next.js i podnieść jakość pracy zespołu, skontaktuj się ze mną. Pomagam zamieniać wiedzę z artykułów w praktyczne decyzje technologiczne.

O autorze

Maciej Sala

Maciej Sala — project manager i frontendowiec z doświadczeniem w marketingu internetowym. Na co dzień pracuję z Reactem, Next.js i TypeScriptem, łącząc perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat związany z branżą gier wideo jako project manager i game designer.

Absolwent historii na Uniwersytecie Jagiellońskim i studiów podyplomowych z marketingu internetowego na Akademii Górniczo-Hutniczej w Krakowie. Poza pracą trenuje na siłowni, maluje figurki i realizuje własne projekty.

Biblioteka wiedzy

Czytaj dalej

Zobacz więcej wpisów
Anthropic uderza w Figmę i Adobe — oto Claude Design

Anthropic uderza w Figmę i Adobe — oto Claude Design

Anthropic wypuścił właśnie narzędzie AI do tworzenia stron, landing page'ów i prezentacji z promptu. Oto co wiemy o Claude Design i Opus 4.7 — i co to oznacza dla developerów.

Maciej Sala

Maciej Sala

Founder Strivelab

Astro.js vs Next.js — które narzędzie wybrać w 2026 roku?

Astro.js vs Next.js — które narzędzie wybrać w 2026 roku?

Fachowe porównanie Astro.js i Next.js z perspektywy developera pracującego na co dzień w Next.js. Architektura, wydajność, SEO, DX, koszty i konkretne use case — z benchmarkami i przykładami kodu.

Maciej Sala

Maciej Sala

Founder Strivelab