Uvod u Server Actions
Ako ste ikada izgradili full-stack aplikaciju u Next.js-u, znate koliko dosadan može biti proces stvaranja API ruta za svaku pojedinu mutaciju podataka. Doslovno — obrazac za kontakt? API ruta. Brisanje komentara? Još jedna API ruta. I tako u nedogled.
E pa, Server Actions u Next.js 15 drastično mijenjaju tu priču. Ova značajka vam omogućuje da pozivate serverski kod izravno iz korisničkog sučelja, bez ikakvih zasebnih API ruta. U kombinaciji s React Server Components arhitekturom i App Routerom, Server Actions transformiraju način na koji gradimo web aplikacije — i, iskreno, kad ih jednom počnete koristiti, teško se vratiti na stari način rada.
U ovom vodiču detaljno ćemo proći kroz sve što trebate znati: kako Server Actions funkcioniraju, kako ih implementirati za rad s bazama podataka, kako upravljati obrascima s naprednom validacijom, te koje su najbolje prakse za produkcijsku primjenu.
Što su Server Actions i zašto su važni?
Server Actions su asinkrone funkcije koje se izvršavaju isključivo na serveru. Definirane su pomoću direktive "use server" i mogu se pozivati iz Server ili Client komponenti. Ključna prednost? Eliminacija potrebe za ručnim stvaranjem API krajnjih točaka za svaku mutaciju podataka.
Prije pojave Server Actions, tipičan tijek rada za spremanje podataka u bazu izgledao je otprilike ovako:
- Korisnik ispuni obrazac na klijentskoj strani
- JavaScript pošalje POST zahtjev na API rutu
- API ruta obradi zahtjev i komunicira s bazom podataka
- Odgovor se vrati klijentu koji ažurira sučelje
S Server Actions, cijeli se proces dramatično pojednostavljuje:
- Korisnik ispuni obrazac
- Server Action se pozove izravno — obradi podatke i komunicira s bazom
- Sučelje se automatski ažurira kroz revalidaciju
Tri koraka umjesto četiri. Manje koda, manje datoteka, manje brige.
Progresivno poboljšanje
Jedna od stvari koja mi se stvarno sviđa kod Server Actions jest podrška za progresivno poboljšanje. Kada ih koristite s HTML <form> elementom putem atributa action, obrazac će funkcionirati čak i ako se JavaScript ne učita na klijentskoj strani. To znači da vaše aplikacije ostaju funkcionalne u svim uvjetima — od sporih mrežnih veza do korisnika koji su onemogućili JavaScript.
U praksi, ovo je ogroman plus za pristupačnost i pouzdanost.
Osnovno postavljanje Server Actions
Postoje dva glavna načina za definiranje Server Actions: unutar zasebne datoteke s direktivom "use server" na vrhu, ili inline unutar Server Component funkcije. Pogledajmo oba pristupa.
Pristup sa zasebnom datotekom
Za veće projekte, preporučujem držanje Server Actions u zasebnim datotekama. Ovo poboljšava organizaciju koda, olakšava testiranje i omogućuje ponovnu upotrebu — a vjerujte mi, na većim projektima ćete biti zahvalni na toj organiziranosti:
// app/actions/user-actions.ts
"use server"
import { db } from "@/lib/database"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
import { z } from "zod"
const UserSchema = z.object({
name: z.string().min(2, "Ime mora imati najmanje 2 znaka"),
email: z.string().email("Unesite valjanu email adresu"),
role: z.enum(["admin", "user", "editor"]),
})
export async function createUser(formData: FormData) {
const rawData = {
name: formData.get("name"),
email: formData.get("email"),
role: formData.get("role"),
}
const validatedData = UserSchema.safeParse(rawData)
if (!validatedData.success) {
return {
errors: validatedData.error.flatten().fieldErrors,
message: "Greška pri validaciji podataka.",
}
}
try {
await db.user.create({
data: validatedData.data,
})
} catch (error) {
return {
message: "Greška prilikom stvaranja korisnika u bazi podataka.",
}
}
revalidatePath("/korisnici")
redirect("/korisnici")
}
Inline pristup
Za jednostavnije slučajeve, Server Actions se mogu definirati inline unutar Server Component-a. Ovo je zgodno za brze, jednokratne akcije:
// app/posts/page.tsx
import { db } from "@/lib/database"
import { revalidatePath } from "next/cache"
export default async function PostsPage() {
const posts = await db.post.findMany()
async function deletePost(formData: FormData) {
"use server"
const id = formData.get("id") as string
await db.post.delete({ where: { id } })
revalidatePath("/posts")
}
return (
<div>
{posts.map((post) => (
<div key={post.id}>
<h3>{post.title}</h3>
<form action={deletePost}>
<input type="hidden" name="id" value={post.id} />
<button type="submit">Obriši</button>
</form>
</div>
))}
</div>
)
}
Integracija s bazom podataka: Prisma ORM
U praksi, Server Actions najčešće komuniciraju s bazom podataka putem ORM alata. Prisma je i dalje jedan od najpopularnijih izbora u Next.js ekosustavu, i to s dobrim razlogom — njezin API je intuitivan i tipski siguran.
Konfiguracija Prisma klijenta
Prvo, trebamo pravilno konfigurirati Prisma klijent. Ovo je onaj mali ali važan detalj koji mnogi zaborave — bez njega ćete u razvojnom okruženju završiti s gomilom nepotrebnih instanci baze podataka:
// lib/database.ts
import { PrismaClient } from "@prisma/client"
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const db = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = db
}
Prisma shema
Definirajmo primjer sheme za blog aplikaciju s korisnicima, objavama i komentarima:
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
name String
email String @unique
role Role @default(USER)
posts Post[]
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Post {
id String @id @default(cuid())
title String
content String
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId String
comments Comment[]
categories Category[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Comment {
id String @id @default(cuid())
content String
author User @relation(fields: [authorId], references: [id])
authorId String
post Post @relation(fields: [postId], references: [id])
postId String
createdAt DateTime @default(now())
}
model Category {
id String @id @default(cuid())
name String @unique
slug String @unique
posts Post[]
}
enum Role {
USER
ADMIN
EDITOR
}
CRUD operacije putem Server Actions
Sad dolazimo do srži stvari. Imamo postavljenu bazu podataka, pa implementirajmo kompletne CRUD (Create, Read, Update, Delete) operacije koristeći Server Actions.
Stvaranje zapisa
Evo kako izgleda stvaranje novog blog posta s validacijom, autentifikacijom i pravilnom obradom grešaka:
// app/actions/post-actions.ts
"use server"
import { db } from "@/lib/database"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
import { z } from "zod"
import { auth } from "@/lib/auth"
const PostSchema = z.object({
title: z.string()
.min(3, "Naslov mora imati najmanje 3 znaka")
.max(200, "Naslov ne smije biti dulji od 200 znakova"),
content: z.string()
.min(50, "Sadržaj mora imati najmanje 50 znakova"),
categoryIds: z.array(z.string()).optional(),
published: z.boolean().default(false),
})
export type PostActionState = {
errors?: {
title?: string[]
content?: string[]
categoryIds?: string[]
}
message?: string
success?: boolean
}
export async function createPost(
prevState: PostActionState,
formData: FormData
): Promise<PostActionState> {
// Provjera autentifikacije
const session = await auth()
if (!session?.user?.id) {
return { message: "Morate biti prijavljeni." }
}
const rawData = {
title: formData.get("title"),
content: formData.get("content"),
categoryIds: formData.getAll("categoryIds"),
published: formData.get("published") === "on",
}
const validatedData = PostSchema.safeParse(rawData)
if (!validatedData.success) {
return {
errors: validatedData.error.flatten().fieldErrors,
message: "Provjerite unesene podatke.",
}
}
try {
const post = await db.post.create({
data: {
title: validatedData.data.title,
content: validatedData.data.content,
published: validatedData.data.published,
authorId: session.user.id,
categories: validatedData.data.categoryIds
? {
connect: validatedData.data.categoryIds.map((id) => ({ id })),
}
: undefined,
},
})
revalidatePath("/objave")
return { success: true, message: "Objava uspješno stvorena!" }
} catch (error) {
return {
message: "Došlo je do greške pri stvaranju objave.",
}
}
}
Ažuriranje zapisa
Ažuriranje je slično, ali s jednom bitnom razlikom — moramo provjeriti vlasništvo nad objavom. Ne želimo da netko drugi uređuje vaše postove (osim ako niste to predvidjeli):
// app/actions/post-actions.ts (nastavak)
export async function updatePost(
id: string,
prevState: PostActionState,
formData: FormData
): Promise<PostActionState> {
const session = await auth()
if (!session?.user?.id) {
return { message: "Morate biti prijavljeni." }
}
// Provjera vlasništva nad objavom
const existingPost = await db.post.findUnique({
where: { id },
select: { authorId: true },
})
if (!existingPost) {
return { message: "Objava nije pronađena." }
}
if (existingPost.authorId !== session.user.id) {
return { message: "Nemate ovlasti za uređivanje ove objave." }
}
const rawData = {
title: formData.get("title"),
content: formData.get("content"),
published: formData.get("published") === "on",
}
const validatedData = PostSchema.omit({ categoryIds: true }).safeParse(rawData)
if (!validatedData.success) {
return {
errors: validatedData.error.flatten().fieldErrors,
message: "Provjerite unesene podatke.",
}
}
try {
await db.post.update({
where: { id },
data: validatedData.data,
})
revalidatePath("/objave")
revalidatePath(`/objave/${id}`)
return { success: true, message: "Objava uspješno ažurirana!" }
} catch (error) {
return {
message: "Došlo je do greške pri ažuriranju objave.",
}
}
}
Brisanje zapisa
Brisanje je najjednostavnija operacija, ali i dalje zahtijeva provjeru ovlasti:
// app/actions/post-actions.ts (nastavak)
export async function deletePost(id: string): Promise<PostActionState> {
const session = await auth()
if (!session?.user?.id) {
return { message: "Morate biti prijavljeni." }
}
const existingPost = await db.post.findUnique({
where: { id },
select: { authorId: true },
})
if (!existingPost) {
return { message: "Objava nije pronađena." }
}
if (
existingPost.authorId !== session.user.id &&
session.user.role !== "ADMIN"
) {
return { message: "Nemate ovlasti za brisanje ove objave." }
}
try {
await db.post.delete({ where: { id } })
revalidatePath("/objave")
return { success: true, message: "Objava uspješno obrisana!" }
} catch (error) {
return {
message: "Došlo je do greške pri brisanju objave.",
}
}
}
Upravljanje obrascima s useActionState
Ajmo sada na klijentsku stranu. React 19 uveo je hook useActionState koji je postao preporučeni obrazac za upravljanje stanjem obrazaca u kombinaciji sa Server Actions. Ovaj hook automatski upravlja stanjem učitavanja, greškama i odgovorima servera — dakle, ne morate više ručno pratiti isLoading stanja i slične stvari.
// app/objave/nova/page.tsx
"use client"
import { useActionState } from "react"
import { createPost, PostActionState } from "@/app/actions/post-actions"
const initialState: PostActionState = {
errors: {},
message: "",
}
export default function NewPostForm() {
const [state, formAction, isPending] = useActionState(
createPost,
initialState
)
return (
<form action={formAction} className="space-y-6">
<div>
<label htmlFor="title" className="block text-sm font-medium">
Naslov
</label>
<input
id="title"
name="title"
type="text"
required
className="mt-1 block w-full rounded-md border p-2"
aria-describedby="title-error"
/>
{state.errors?.title && (
<p id="title-error" className="mt-1 text-sm text-red-600">
{state.errors.title[0]}
</p>
)}
</div>
<div>
<label htmlFor="content" className="block text-sm font-medium">
Sadržaj
</label>
<textarea
id="content"
name="content"
rows={10}
required
className="mt-1 block w-full rounded-md border p-2"
aria-describedby="content-error"
/>
{state.errors?.content && (
<p id="content-error" className="mt-1 text-sm text-red-600">
{state.errors.content[0]}
</p>
)}
</div>
<div className="flex items-center gap-2">
<input
id="published"
name="published"
type="checkbox"
className="rounded"
/>
<label htmlFor="published">Objavi odmah</label>
</div>
<button
type="submit"
disabled={isPending}
className="rounded-md bg-blue-600 px-4 py-2 text-white
disabled:opacity-50"
>
{isPending ? "Stvaranje..." : "Stvori objavu"}
</button>
{state.message && (
<p className={`text-sm ${
state.success ? "text-green-600" : "text-red-600"
}`}>
{state.message}
</p>
)}
</form>
)
}
Optimistička ažuriranja s useOptimistic
Želite da vaša aplikacija bude brza? Naravno da želite. Hook useOptimistic omogućuje trenutno ažuriranje sučelja prije nego što server potvrdi operaciju. Korisnik klikne "lajk" i odmah vidi promjenu — nema čekanja na server. Ako nešto pođe po krivu, React automatski vraća stanje na prethodno.
Ovo je posebno korisno za akcije poput označavanja lajkom ili dodavanja komentara.
// components/PostLikeButton.tsx
"use client"
import { useOptimistic, useTransition } from "react"
import { togglePostLike } from "@/app/actions/post-actions"
interface PostLikeButtonProps {
postId: string
initialLikeCount: number
isLikedByUser: boolean
}
export function PostLikeButton({
postId,
initialLikeCount,
isLikedByUser,
}: PostLikeButtonProps) {
const [isPending, startTransition] = useTransition()
const [optimisticState, setOptimisticState] = useOptimistic(
{ likeCount: initialLikeCount, isLiked: isLikedByUser },
(currentState) => ({
likeCount: currentState.isLiked
? currentState.likeCount - 1
: currentState.likeCount + 1,
isLiked: !currentState.isLiked,
})
)
function handleClick() {
startTransition(async () => {
setOptimisticState(null)
await togglePostLike(postId)
})
}
return (
<button
onClick={handleClick}
disabled={isPending}
className={`flex items-center gap-1 rounded px-3 py-1 ${
optimisticState.isLiked
? "bg-red-100 text-red-600"
: "bg-gray-100 text-gray-600"
}`}
>
{optimisticState.isLiked ? "❤️" : "🤍"}
{optimisticState.likeCount}
</button>
)
}
Tipski sigurne Server Actions s next-safe-action
Za ozbiljnije, produkcijske aplikacije, toplo preporučujem biblioteku next-safe-action. Ona dodaje sloj sigurnosti i tipske provjere oko Server Actions kroz middleware pipeline obrazac — nešto poput middleware-a u Express-u, samo za akcije.
// lib/safe-action.ts
import { createSafeActionClient } from "next-safe-action"
import { auth } from "@/lib/auth"
// Bazni klijent bez autentifikacije
export const actionClient = createSafeActionClient({
handleServerError(error) {
console.error("Action greška:", error.message)
return "Došlo je do neočekivane greške."
},
})
// Klijent s autentifikacijom
export const authActionClient = actionClient.use(async ({ next }) => {
const session = await auth()
if (!session?.user?.id) {
throw new Error("Neovlašteni pristup.")
}
return next({
ctx: {
userId: session.user.id,
userRole: session.user.role,
},
})
})
Sada možemo koristiti ovaj klijent za definiranje sigurnih akcija. Primijetite koliko je kod čišći:
// app/actions/safe-post-actions.ts
"use server"
import { authActionClient } from "@/lib/safe-action"
import { z } from "zod"
import { db } from "@/lib/database"
import { revalidatePath } from "next/cache"
const createPostSchema = z.object({
title: z.string().min(3).max(200),
content: z.string().min(50),
published: z.boolean().default(false),
})
export const createPostAction = authActionClient
.schema(createPostSchema)
.action(async ({ parsedInput, ctx }) => {
const post = await db.post.create({
data: {
...parsedInput,
authorId: ctx.userId,
},
})
revalidatePath("/objave")
return {
success: true,
postId: post.id,
}
})
Napredni obrasci: Rate Limiting i Debouncing
Ovo je dio koji mnogi preskoče, a ne bi trebali. U produkcijskim aplikacijama, morate zaštititi Server Actions od prekomjernog korištenja. Zlonamjerni korisnik (ili čak samo nestrpljivi korisnik koji klikne 50 puta) može opteretiti vaš server ako nemate nikakvu zaštitu.
Implementirajmo jednostavan in-memory rate limiting:
// lib/rate-limit.ts
const rateLimitMap = new Map<
string,
{ count: number; lastReset: number }
>()
export function rateLimit(
key: string,
limit: number = 10,
windowMs: number = 60000
): { success: boolean; remaining: number } {
const now = Date.now()
const record = rateLimitMap.get(key)
if (!record || now - record.lastReset > windowMs) {
rateLimitMap.set(key, { count: 1, lastReset: now })
return { success: true, remaining: limit - 1 }
}
if (record.count >= limit) {
return { success: false, remaining: 0 }
}
record.count++
return { success: true, remaining: limit - record.count }
}
// Korištenje u Server Action
// app/actions/protected-actions.ts
"use server"
import { rateLimit } from "@/lib/rate-limit"
import { auth } from "@/lib/auth"
import { headers } from "next/headers"
export async function protectedAction(formData: FormData) {
const session = await auth()
const headersList = await headers()
const ip = headersList.get("x-forwarded-for") ?? "unknown"
const rateLimitKey = session?.user?.id ?? ip
const { success, remaining } = rateLimit(rateLimitKey, 5, 60000)
if (!success) {
return {
error: "Previše zahtjeva. Pokušajte ponovo za minutu.",
}
}
// Nastavak s obradom...
}
Revalidacija podataka i cache strategije
Pravilna revalidacija podataka nakon mutacija je ključna za konzistentno korisničko iskustvo. Ako ovo ne napravite ispravno, korisnici će vidjeti zastarjele podatke — i onda ćete dobivati bug reportove tipa "ažurirao sam profil ali se ništa nije promijenilo".
Next.js nudi dva pristupa revalidaciji.
Revalidacija prema putanji
Funkcija revalidatePath poništava cache za određenu putanju. Ovo je najčešći i najjednostavniji pristup:
// Revalidacija specifične stranice
revalidatePath("/objave")
// Revalidacija dinamičke rute
revalidatePath(`/objave/${postId}`)
// Revalidacija layout-a i svih ugniježđenih stranica
revalidatePath("/objave", "layout")
Revalidacija prema oznaci
Za preciznije upravljanje cacheom, koristite revalidateTag u kombinaciji s fetch oznakama:
// Prilikom dohvaćanja podataka
const posts = await fetch("https://api.example.com/posts", {
next: { tags: ["posts"] },
})
// U Server Action nakon mutacije
import { revalidateTag } from "next/cache"
export async function createPost(formData: FormData) {
// ... stvaranje objave ...
revalidateTag("posts")
}
Kombiniranje s redirect
Ovo je zamka u koju sam i sam upao na početku: revalidatePath ili revalidateTag moraju se pozvati prije redirect poziva. Zašto? Zato što redirect interno baca iznimku koja prekida izvršavanje ostatka funkcije.
export async function createAndRedirect(formData: FormData) {
// ... obrada podataka ...
// Ispravno: revalidacija PRIJE preusmjeravanja
revalidatePath("/objave")
redirect("/objave")
// Neispravno: ovo se nikada neće izvršiti
// redirect("/objave")
// revalidatePath("/objave") // Nedostupan kod!
}
Sigurnosne prakse za Server Actions
Ovo je ozbiljno. Server Actions su izložene kao javne HTTP krajnje točke. Da, pročitali ste ispravno — bilo tko može poslati POST zahtjev na vaš Server Action endpoint ako zna URL. Zbog toga je sigurnost apsolutno kritična.
Uvijek validirajte na serveru
Nikada se nemojte oslanjati samo na klijentsku validaciju. Svaki Server Action mora neovisno validirati sve ulazne podatke. Klijentska validacija je samo UX poboljšanje — pravu zaštitu pruža serverska validacija:
"use server"
import { z } from "zod"
const CommentSchema = z.object({
content: z
.string()
.min(1, "Komentar ne smije biti prazan")
.max(2000, "Komentar je predugačak")
.transform((val) => val.trim()),
postId: z.string().cuid("Nevažeći ID objave"),
})
export async function addComment(formData: FormData) {
const result = CommentSchema.safeParse({
content: formData.get("content"),
postId: formData.get("postId"),
})
if (!result.success) {
return { errors: result.error.flatten().fieldErrors }
}
// Sigurno koristiti result.data
}
Provjera autorizacije
Ovo je česta greška: programeri provjere je li korisnik prijavljen, ali ne provjere ima li taj korisnik pravo na akciju koju pokušava izvršiti. Autentifikacija i autorizacija nisu ista stvar:
"use server"
export async function updateUserRole(
targetUserId: string,
newRole: string
) {
const session = await auth()
// 1. Provjera autentifikacije
if (!session?.user?.id) {
throw new Error("Neovlašteni pristup")
}
// 2. Provjera autorizacije - samo admini mogu mijenjati uloge
if (session.user.role !== "ADMIN") {
throw new Error("Nedovoljna ovlaštenja")
}
// 3. Provjera da korisnik ne mijenja vlastitu ulogu
if (targetUserId === session.user.id) {
throw new Error("Ne možete mijenjati vlastitu ulogu")
}
// 4. Validacija ulaznih podataka
if (!["USER", "EDITOR", "ADMIN"].includes(newRole)) {
throw new Error("Nevažeća uloga")
}
await db.user.update({
where: { id: targetUserId },
data: { role: newRole as Role },
})
revalidatePath("/admin/korisnici")
}
Server Actions vs. API Route Handlers: Kada koristiti što?
Ovo je pitanje koje se stalno pojavljuje, pa hajdemo to jednom zauvijek razjasniti.
Koristite Server Actions za:
- Mutacije podataka iz obrazaca — stvaranje, ažuriranje i brisanje zapisa
- Jednostavne operacije — akcije koje ne zahtijevaju složenu logiku odgovora
- Progresivno poboljšanje — kada je važno da obrasci rade bez JavaScripta
- Operacije vezane uz React komponente — revalidacija, preusmjeravanje nakon mutacije
Koristite API Route Handlers za:
- Webhookove — primanje obavijesti od vanjskih servisa
- Javne API-je — krajnje točke koje koriste treće strane
- Streaming odgovore — slanje podataka u tokovima
- Složenu logiku odgovora — prilagođeni statusi, zaglavlja, tipovi sadržaja
Jedno kritično pravilo: Nikada ne koristite Server Actions za dohvaćanje podataka. Server Actions koriste POST zahtjeve koji se ne mogu cachirati. Za čitanje podataka koristite Server Components ili Route Handlers s GET metodom.
Obrada datoteka putem Server Actions
Server Actions mogu obraditi i učitavanje datoteka putem FormData. Evo primjera sigurnog rukovanja datotekama s validacijom tipa i veličine:
"use server"
import { writeFile } from "fs/promises"
import path from "path"
import { v4 as uuidv4 } from "uuid"
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"]
const MAX_SIZE = 5 * 1024 * 1024 // 5 MB
export async function uploadImage(formData: FormData) {
const file = formData.get("image") as File | null
if (!file) {
return { error: "Datoteka nije odabrana." }
}
if (!ALLOWED_TYPES.includes(file.type)) {
return { error: "Nepodržani format datoteke. Koristite JPEG, PNG ili WebP." }
}
if (file.size > MAX_SIZE) {
return { error: "Datoteka je prevelika. Maksimalna veličina je 5 MB." }
}
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const extension = file.type.split("/")[1]
const fileName = `${uuidv4()}.${extension}`
const filePath = path.join(process.cwd(), "public", "uploads", fileName)
await writeFile(filePath, buffer)
return {
success: true,
url: `/uploads/${fileName}`,
}
}
Napomena: za produkciju biste trebali koristiti usluge za pohranu u oblaku poput AWS S3, Cloudflare R2 ili Vercel Blob Storage umjesto lokalnog datotečnog sustava. Lokalna pohrana jednostavno ne skalira — a uz to, pri svakom deploymentu na Vercel gubite lokalne datoteke.
Rukovanje greškama i error boundaries
Dobar obrazac za rukovanje greškama u Server Actions je korištenje tipiziranih povratnih vrijednosti umjesto bacanja iznimki. Na taj način klijentski kod uvijek zna što može očekivati:
// lib/action-utils.ts
type ActionResult<T = void> =
| { success: true; data: T }
| { success: false; error: string }
export function actionSuccess<T>(data: T): ActionResult<T> {
return { success: true, data }
}
export function actionError(error: string): ActionResult<never> {
return { success: false, error }
}
// Korištenje
"use server"
import { actionSuccess, actionError } from "@/lib/action-utils"
export async function safeCreatePost(formData: FormData) {
try {
const session = await auth()
if (!session) return actionError("Niste prijavljeni")
const title = formData.get("title") as string
if (!title) return actionError("Naslov je obavezan")
const post = await db.post.create({
data: { title, authorId: session.user.id },
})
revalidatePath("/objave")
return actionSuccess({ postId: post.id })
} catch (error) {
console.error("Greška pri stvaranju objave:", error)
return actionError("Neočekivana greška. Pokušajte ponovo.")
}
}
Testiranje Server Actions
Jedna od ljepota Server Actions jest da se mogu testirati kao obične asinkrone funkcije. Nema potrebe za postavljanjem HTTP servera ili slanjem stvarnih zahtjeva. Evo primjera s Vitest alatom:
// __tests__/post-actions.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest"
import { createPost } from "@/app/actions/post-actions"
// Mock ovisnosti
vi.mock("@/lib/database", () => ({
db: {
post: {
create: vi.fn(),
},
},
}))
vi.mock("@/lib/auth", () => ({
auth: vi.fn(),
}))
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
}))
describe("createPost", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("vraća grešku ako korisnik nije prijavljen", async () => {
const { auth } = await import("@/lib/auth")
vi.mocked(auth).mockResolvedValue(null)
const formData = new FormData()
formData.set("title", "Test naslov")
formData.set("content", "Test sadržaj s dovoljno znakova...")
const result = await createPost({}, formData)
expect(result.message).toBe("Morate biti prijavljeni.")
})
it("vraća greške validacije za prekratak naslov", async () => {
const { auth } = await import("@/lib/auth")
vi.mocked(auth).mockResolvedValue({
user: { id: "user-1", role: "USER" },
})
const formData = new FormData()
formData.set("title", "AB")
formData.set("content", "Kratki sadržaj")
const result = await createPost({}, formData)
expect(result.errors?.title).toBeDefined()
})
})
Paralelno izvršavanje Server Actions
Ponekad trebate pokrenuti više Server Actions istovremeno. Recimo, korisnik želi ažurirati profil i postavke obavijesti na istoj stranici — nema smisla to raditi sekvencijalno kad se te dvije operacije uopće ne preklapaju.
React 19 i Next.js 15 omogućuju elegantno upravljanje paralelnim akcijama putem useTransition hook-a:
// components/UserSettingsForm.tsx
"use client"
import { useTransition } from "react"
import { updateProfile } from "@/app/actions/profile-actions"
import { updateNotifications } from "@/app/actions/notification-actions"
export function UserSettingsForm({
profile,
notifications,
}: UserSettingsFormProps) {
const [isProfilePending, startProfileTransition] = useTransition()
const [isNotifPending, startNotifTransition] = useTransition()
async function handleSaveAll(formData: FormData) {
// Pokretanje obje akcije paralelno
startProfileTransition(async () => {
await updateProfile(formData)
})
startNotifTransition(async () => {
await updateNotifications(formData)
})
}
const isSaving = isProfilePending || isNotifPending
return (
<form action={handleSaveAll}>
<fieldset disabled={isSaving}>
<h3>Profil</h3>
<input name="displayName" defaultValue={profile.name} />
<input name="bio" defaultValue={profile.bio} />
<h3>Obavijesti</h3>
<label>
<input
type="checkbox"
name="emailNotif"
defaultChecked={notifications.email}
/>
Email obavijesti
</label>
<label>
<input
type="checkbox"
name="pushNotif"
defaultChecked={notifications.push}
/>
Push obavijesti
</label>
<button type="submit">
{isSaving ? "Spremanje..." : "Spremi sve postavke"}
</button>
</fieldset>
</form>
)
}
Batch operacije s Server Actions
Batch operacije su neizbježne u admin sučeljima. Brisanje više odabranih objava, masovno ažuriranje statusa, izvoz podataka — sve to zahtijeva pažljivu implementaciju:
// app/actions/batch-actions.ts
"use server"
import { db } from "@/lib/database"
import { revalidatePath } from "next/cache"
import { auth } from "@/lib/auth"
import { z } from "zod"
const BatchDeleteSchema = z.object({
ids: z.array(z.string().cuid()).min(1).max(50),
})
export async function batchDeletePosts(ids: string[]) {
const session = await auth()
if (!session?.user?.id || session.user.role !== "ADMIN") {
return { error: "Samo administratori mogu obrisati više objava." }
}
const validated = BatchDeleteSchema.safeParse({ ids })
if (!validated.success) {
return { error: "Nevažeći popis ID-ova." }
}
try {
const result = await db.post.deleteMany({
where: {
id: { in: validated.data.ids },
},
})
revalidatePath("/admin/objave")
return {
success: true,
deletedCount: result.count,
message: `Uspješno obrisano ${result.count} objava.`,
}
} catch (error) {
return { error: "Greška pri brisanju objava." }
}
}
const BatchStatusSchema = z.object({
ids: z.array(z.string().cuid()).min(1).max(100),
published: z.boolean(),
})
export async function batchUpdatePostStatus(
ids: string[],
published: boolean
) {
const session = await auth()
if (!session?.user?.id) {
return { error: "Morate biti prijavljeni." }
}
const validated = BatchStatusSchema.safeParse({ ids, published })
if (!validated.success) {
return { error: "Nevažeći ulazni podatci." }
}
try {
// Samo ažuriraj objave koje pripadaju korisniku
// (ili sve ako je admin)
const whereClause =
session.user.role === "ADMIN"
? { id: { in: validated.data.ids } }
: {
id: { in: validated.data.ids },
authorId: session.user.id,
}
const result = await db.post.updateMany({
where: whereClause,
data: { published: validated.data.published },
})
revalidatePath("/objave")
revalidatePath("/admin/objave")
const statusText = published ? "objavljeno" : "povučeno iz objave"
return {
success: true,
updatedCount: result.count,
message: `Uspješno ${statusText} ${result.count} objava.`,
}
} catch (error) {
return { error: "Greška pri ažuriranju statusa objava." }
}
}
Alternativa: Drizzle ORM integracija
Prisma nije jedina opcija. Drizzle ORM postaje sve popularniji među Next.js programerima, i to s razlogom — lagani je, tipski siguran i generira SQL upite bez teške apstrakcije. Ako vam je važna bundle veličina ili jednostavno preferirate biti bliže SQL-u, Drizzle je odličan izbor.
// lib/drizzle-db.ts
import { drizzle } from "drizzle-orm/vercel-postgres"
import { sql } from "@vercel/postgres"
import * as schema from "./schema"
export const db = drizzle(sql, { schema })
// lib/schema.ts
import {
pgTable,
text,
timestamp,
boolean,
pgEnum,
} from "drizzle-orm/pg-core"
export const roleEnum = pgEnum("role", ["USER", "ADMIN", "EDITOR"])
export const users = pgTable("users", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
role: roleEnum("role").default("USER").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
})
export const posts = pgTable("posts", {
id: text("id").primaryKey(),
title: text("title").notNull(),
content: text("content").notNull(),
published: boolean("published").default(false).notNull(),
authorId: text("author_id")
.references(() => users.id)
.notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
})
Server Action s Drizzle-om izgleda ovako:
// app/actions/drizzle-post-actions.ts
"use server"
import { db } from "@/lib/drizzle-db"
import { posts } from "@/lib/schema"
import { eq } from "drizzle-orm"
import { revalidatePath } from "next/cache"
import { nanoid } from "nanoid"
export async function createDrizzlePost(formData: FormData) {
const title = formData.get("title") as string
const content = formData.get("content") as string
await db.insert(posts).values({
id: nanoid(),
title,
content,
authorId: "current-user-id",
})
revalidatePath("/objave")
}
export async function getDrizzlePosts() {
return db.select().from(posts).where(eq(posts.published, true))
}
Drizzle pruža nekoliko konkretnih prednosti: SQL upiti su transparentni i lako se debugiraju, tipska sigurnost je izvedena izravno iz sheme tablice, a bundle veličina je znatno manja jer Drizzle ne zahtijeva generiranje klijenta kao Prisma. Za projekte gdje je performans prioritet, to može biti presudno.
Zaključak i preporuke
Server Actions u Next.js 15 stvarno su promijenili igru za full-stack React razvoj. Manje boilerplate koda, manje datoteka, manje konfiguracije — a više fokusa na ono što zapravo gradite.
Evo ključnih preporuka za produkcijsku primjenu:
- Organizacija: Držite Server Actions u zasebnim datotekama grupiranima po domenama (npr.
user-actions.ts,post-actions.ts) - Validacija: Uvijek koristite Zod ili sličnu biblioteku za validaciju ulaznih podataka na serveru
- Sigurnost: Provjeravajte autentifikaciju i autorizaciju u svakom Server Action-u — bez iznimke
- Tipovi: Definirajte eksplicitne tipove za stanje akcija i povratne vrijednosti
- Revalidacija: Pozovite
revalidatePathilirevalidateTagprijeredirect - Testiranje: Testirajte Server Actions kao obične asinkrone funkcije s mockiranim ovisnostima
- Rate limiting: Implementirajte zaštitu od prekomjernog korištenja — ovo nije opcija, nego nužnost
- Tipska sigurnost: Razmotrite
next-safe-actionza dodatni sloj sigurnosti s middleware podrškom
S pravilnom implementacijom, Server Actions omogućuju razvoj robusnih, sigurnih i performantnih web aplikacija. A najbolji dio? Jednom kad savladate ove obrasce, razvoj postaje znatno brži i ugodniji.