Въведение: Защо Server Actions променят играта
Ако сте писали Next.js приложения преди Server Actions, знаете колко разочароващо може да бъде — създавате API Route в app/api/, пишете fetch заявка от клиента, обработвате грешки на две места, после ръчно ревалидирате кеша. И всичко това за една единствена мутация. Честно казано, усещането беше малко... тромаво.
Server Actions в Next.js 15 и React 19 промениха тази ситуация фундаментално. Те са асинхронни функции, които се изпълняват на сървъра, но могат да бъдат извикани директно от клиентски компоненти — без ръчни API маршрути, без fetch заявки, без сериализация и десериализация на данни. По същество мутациите стават толкова естествени, колкото извикването на обикновена функция.
Три неща ги правят наистина специални:
- Прогресивно подобрение (Progressive Enhancement) — Формулярите работят дори без JavaScript в браузъра, защото Server Actions се базират на стандартни HTML form submissions. Това е голям плюс за достъпността.
- Типова безопасност (Type Safety) — Типовете на входните и изходните данни се споделят между клиент и сървър без допълнителна конфигурация.
- Колокация (Colocation) — Логиката за мутация живее близо до компонента, който я използва, а не някъде в отделна API директория.
В това ръководство ще минем през всичко — от основите до напредналите модели. Ще разгледаме валидация с Zod, типово-безопасни действия с next-safe-action, оптимистични актуализации, ревалидация на кеша и най-добрите практики за сигурност през 2026 г.
Хайде да започваме.
Основи на Server Actions
Директивата 'use server'
Server Actions се дефинират чрез директивата 'use server'. Можете да я поставите на две места — на ниво файл (за да маркира всички експортирани функции като Server Actions) или на ниво функция (за inline дефиниция вътре в Server Component).
Когато поставите 'use server' в началото на файл, всяка експортирана функция от него автоматично става Server Action:
// app/actions/user.ts
'use server'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
// Всяка експортирана функция е Server Action
export async function createUser(formData: FormData) {
const name = formData.get('name') as string
const email = formData.get('email') as string
await db.user.create({
data: { name, email }
})
revalidatePath('/users')
}
export async function deleteUser(userId: string) {
await db.user.delete({
where: { id: userId }
})
revalidatePath('/users')
}
Алтернативно, можете да дефинирате Server Action директно вътре в Server Component с inline директива. Понякога е по-удобно, ако действието е специфично само за този компонент:
// app/users/page.tsx — Server Component
export default async function UsersPage() {
const users = await db.user.findMany()
// Inline Server Action — работи само в Server Components
async function handleDelete(formData: FormData) {
'use server'
const userId = formData.get('userId') as string
await db.user.delete({ where: { id: userId } })
revalidatePath('/users')
}
return (
<ul>
{users.map(user => (
<li key={user.id}>
{user.name}
<form action={handleDelete}>
<input type="hidden" name="userId" value={user.id} />
<button type="submit">Изтрий</button>
</form>
</li>
))}
</ul>
)
}
Как работят Server Actions под повърхността
Тук нещата стават интересни. Когато дефинирате Server Action, Next.js генерира уникален криптиран идентификатор за всяка функция. Този идентификатор се вгражда в клиентския бъндъл. Когато действието бъде извикано, браузърът изпраща POST заявка към текущия URL с идентификатора в специален хедър. Сървърът декриптира идентификатора, намира съответната функция и я изпълнява.
Какво означава това на практика?
- Server Actions никога не разкриват сървърен код на клиента — само идентификаторът се изпраща.
- Автоматично се добавя CSRF защита чрез проверка на Origin хедъра.
- Аргументите се сериализират чрез React Serialization Protocol, който поддържа FormData, Date, Map, Set и други типове.
Конвенции и организация
Препоръчителната конвенция за 2026 г. е да организирате Server Actions в отделни файлове вътре в директория app/actions/ или lib/actions/. Повярвайте ми — когато проектът порасне, ще се радвате, че сте ги структурирали от самото начало:
// Структура на проекта
app/
actions/
user.ts // Действия за потребители
post.ts // Действия за публикации
auth.ts // Действия за автентикация
(dashboard)/
settings/
page.tsx // Импортира от actions/user.ts
Формуляри и useActionState
React 19 въведе хука useActionState (заместващ предишния useFormState), който предоставя елегантен начин за обработка на формуляри с Server Actions. Той управлява състоянието на формуляра — pending индикатори, валидационни грешки, обратна връзка към потребителя. Всичко в един хук.
Пълен пример за формуляр с валидация
Нека изградим пълен формуляр за създаване на публикация. Ще включим обработка на грешки и прогресивно подобрение:
// app/actions/post.ts
'use server'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
// Дефинираме типа на състоянието
export type CreatePostState = {
errors?: {
title?: string[]
content?: string[]
_form?: string[] // Грешки на ниво формуляр
}
success?: boolean
message?: string
}
export async function createPost(
prevState: CreatePostState,
formData: FormData
): Promise<CreatePostState> {
const title = formData.get('title') as string
const content = formData.get('content') as string
// Ръчна валидация (по-долу ще видим Zod)
const errors: CreatePostState['errors'] = {}
if (!title || title.length < 3) {
errors.title = ['Заглавието трябва да е поне 3 символа']
}
if (title && title.length > 200) {
errors.title = ['Заглавието не може да надвишава 200 символа']
}
if (!content || content.length < 10) {
errors.content = ['Съдържанието трябва да е поне 10 символа']
}
if (Object.keys(errors).length > 0) {
return { errors }
}
try {
await db.post.create({
data: { title, content }
})
revalidatePath('/posts')
return { success: true, message: 'Публикацията е създадена успешно!' }
} catch (error) {
return {
errors: {
_form: ['Възникна грешка при създаването. Моля, опитайте отново.']
}
}
}
}
// app/posts/new/create-post-form.tsx
'use client'
import { useActionState } from 'react'
import { createPost, type CreatePostState } from '@/app/actions/post'
const initialState: CreatePostState = {}
export function CreatePostForm() {
const [state, formAction, isPending] = useActionState(
createPost,
initialState
)
return (
<form action={formAction} className="space-y-4">
{/* Грешка на ниво формуляр */}
{state.errors?._form && (
<div className="bg-red-50 border border-red-200 p-3 rounded">
{state.errors._form.map((error, i) => (
<p key={i} className="text-red-600">{error}</p>
))}
</div>
)}
{/* Съобщение за успех */}
{state.success && (
<div className="bg-green-50 border border-green-200 p-3 rounded">
<p className="text-green-600">{state.message}</p>
</div>
)}
<div>
<label htmlFor="title">Заглавие</label>
<input
id="title"
name="title"
type="text"
className="w-full border rounded p-2"
aria-describedby="title-error"
/>
{state.errors?.title && (
<p id="title-error" className="text-red-500 text-sm mt-1">
{state.errors.title[0]}
</p>
)}
</div>
<div>
<label htmlFor="content">Съдържание</label>
<textarea
id="content"
name="content"
rows={6}
className="w-full border rounded p-2"
aria-describedby="content-error"
/>
{state.errors?.content && (
<p id="content-error" className="text-red-500 text-sm mt-1">
{state.errors.content[0]}
</p>
)}
</div>
<button
type="submit"
disabled={isPending}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
>
{isPending ? 'Създаване...' : 'Създай публикация'}
</button>
</form>
)
}
Ключовото предимство тук? Формулярът работи без JavaScript. Ако JS не е зареден, данните все пак отиват към сървъра чрез стандартна HTML форма. А когато JavaScript е наличен, изпращането е асинхронно и isPending ви дава зареждащ индикатор. Най-доброто от двата свята.
Валидация с Zod
Zod е на практика стандартната библиотека за валидация на схеми в Next.js екосистемата. Тя ви дава runtime валидация с автоматично извличане на TypeScript типове — нещо, за което преди трябваше да пишете всичко два пъти. Комбинацията на Zod със Server Actions гарантира, че невалидни данни просто няма как да достигнат до базата данни.
Дефиниране на Zod схеми и интеграция със Server Actions
// lib/validations/post.ts
import { z } from 'zod'
// Zod схема с български съобщения за грешки
export const createPostSchema = z.object({
title: z
.string()
.min(3, 'Заглавието трябва да е поне 3 символа')
.max(200, 'Заглавието не може да надвишава 200 символа')
.trim(),
content: z
.string()
.min(10, 'Съдържанието трябва да е поне 10 символа')
.max(10000, 'Съдържанието не може да надвишава 10 000 символа')
.trim(),
categoryId: z
.string()
.uuid('Невалиден идентификатор на категория')
.optional(),
tags: z
.array(z.string().min(1).max(50))
.max(10, 'Можете да добавите максимум 10 тага')
.default([]),
published: z
.boolean()
.default(false),
})
// Извличане на TypeScript тип от схемата
export type CreatePostInput = z.infer<typeof createPostSchema>
// app/actions/post.ts
'use server'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
import { createPostSchema } from '@/lib/validations/post'
export type PostActionState = {
errors?: Record<string, string[]>
success?: boolean
message?: string
}
export async function createPost(
prevState: PostActionState,
formData: FormData
): Promise<PostActionState> {
// Парсиране и валидация с Zod
const rawData = {
title: formData.get('title'),
content: formData.get('content'),
categoryId: formData.get('categoryId') || undefined,
tags: formData.getAll('tags'),
published: formData.get('published') === 'on',
}
const result = createPostSchema.safeParse(rawData)
if (!result.success) {
// Преобразуваме Zod грешките в структуриран формат
return {
errors: result.error.flatten().fieldErrors as Record<string, string[]>,
}
}
// result.data е типизиран и валидиран
const { title, content, categoryId, tags, published } = result.data
try {
await db.post.create({
data: {
title,
content,
categoryId,
tags,
published,
},
})
revalidatePath('/posts')
return { success: true, message: 'Публикацията е създадена!' }
} catch (error) {
return {
errors: { _form: ['Грешка при запис в базата данни'] },
}
}
}
Една важна подробност: методът safeParse е ключов тук. За разлика от parse, той не хвърля изключение при невалидни данни, а връща обект с success: false и структурирани грешки. Методът flatten() след това ги преобразува в плоски масиви — удобни за показване под всяко поле на формуляра.
Типово-безопасни Server Actions с next-safe-action
Окей, тук нещата стават наистина интересни. Библиотеката next-safe-action издига Server Actions на друго ниво — добавя middleware тръбопровод, автоматична Zod валидация, типова безопасност от край до край и разширена обработка на грешки. По моето мнение, тя е де факто стандартът за продукционни Next.js приложения през 2026 г.
Настройка на action клиент с middleware
// lib/safe-action.ts
import { createSafeActionClient } from 'next-safe-action'
import { auth } from '@/lib/auth'
import { headers } from 'next/headers'
import { RateLimiter } from '@/lib/rate-limiter'
// Базов клиент — за публични действия
export const actionClient = createSafeActionClient({
// Глобална обработка на грешки
handleServerError(error) {
console.error('Server Action грешка:', error.message)
// Никога не разкривайте вътрешни грешки на клиента
if (error instanceof Error) {
return error.message
}
return 'Възникна неочаквана грешка'
},
})
// Клиент с автентикация — за защитени действия
export const authActionClient = actionClient.use(async ({ next }) => {
const session = await auth()
if (!session?.user) {
throw new Error('Трябва да сте влезли в акаунта си')
}
// Предаваме данните за потребителя надолу по веригата
return next({
ctx: {
userId: session.user.id,
userRole: session.user.role,
},
})
})
// Клиент с rate limiting — за чувствителни операции
const rateLimiter = new RateLimiter({
maxRequests: 10,
windowMs: 60_000, // 10 заявки на минута
})
export const rateLimitedAuthClient = authActionClient.use(
async ({ next, ctx }) => {
const headersList = await headers()
const ip = headersList.get('x-forwarded-for') ?? 'unknown'
const { success } = await rateLimiter.check(ip)
if (!success) {
throw new Error('Твърде много заявки. Моля, изчакайте.')
}
return next({ ctx })
}
)
Виждате ли колко елегантно е? Създавате различни нива — публичен клиент, автентикиран клиент, клиент с rate limiting — и всяко ниво добавя по една допълнителна проверка.
Създаване на типово-безопасни действия
// app/actions/post.safe.ts
'use server'
import { authActionClient } from '@/lib/safe-action'
import { createPostSchema } from '@/lib/validations/post'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
// Действието автоматично валидира входа и проверява автентикацията
export const createPostAction = authActionClient
.schema(createPostSchema)
.action(async ({ parsedInput, ctx }) => {
// parsedInput е вече валидиран и типизиран
// ctx съдържа userId и userRole от middleware
const post = await db.post.create({
data: {
...parsedInput,
authorId: ctx.userId,
},
})
revalidatePath('/posts')
return { post }
})
// Действие за изтриване с проверка на собственост
const deletePostSchema = z.object({
postId: z.string().uuid('Невалиден идентификатор на публикация'),
})
export const deletePostAction = authActionClient
.schema(deletePostSchema)
.action(async ({ parsedInput, ctx }) => {
// Проверяваме дали потребителят е собственик
const post = await db.post.findUnique({
where: { id: parsedInput.postId },
select: { authorId: true },
})
if (!post) {
throw new Error('Публикацията не е намерена')
}
if (post.authorId !== ctx.userId && ctx.userRole !== 'ADMIN') {
throw new Error('Нямате право да изтриете тази публикация')
}
await db.post.delete({
where: { id: parsedInput.postId },
})
revalidatePath('/posts')
return { deleted: true }
})
Използване в клиентски компонент
// app/posts/new/page.tsx
'use client'
import { useAction } from 'next-safe-action/hooks'
import { createPostAction } from '@/app/actions/post.safe'
export default function NewPostPage() {
const { execute, result, isPending, hasSucceeded } = useAction(
createPostAction,
{
onSuccess: ({ data }) => {
console.log('Публикацията е създадена:', data?.post)
},
onError: ({ error }) => {
console.error('Грешка:', error.serverError)
},
}
)
function handleSubmit(formData: FormData) {
execute({
title: formData.get('title') as string,
content: formData.get('content') as string,
tags: formData.getAll('tags') as string[],
published: formData.get('published') === 'on',
})
}
return (
<form action={handleSubmit} className="space-y-4">
{/* Валидационни грешки от Zod */}
{result.validationErrors && (
<div className="text-red-500">
{Object.entries(result.validationErrors).map(([field, errors]) => (
<p key={field}>{field}: {errors?.join(', ')}</p>
))}
</div>
)}
{/* Сървърна грешка */}
{result.serverError && (
<p className="text-red-500">{result.serverError}</p>
)}
{hasSucceeded && (
<p className="text-green-500">Публикацията е създадена успешно!</p>
)}
<input name="title" placeholder="Заглавие" className="border p-2 w-full" />
<textarea name="content" placeholder="Съдържание" className="border p-2 w-full" />
<button disabled={isPending} className="bg-blue-600 text-white px-4 py-2">
{isPending ? 'Запазване...' : 'Създай'}
</button>
</form>
)
}
Композируемият middleware модел е изключително мощен. Можете да създавате различни вериги за различни нива на достъп — публичен клиент, автентикиран клиент, администраторски клиент — всеки с допълнителни проверки отгоре.
Оптимистични актуализации с useOptimistic
Оптимистичните актуализации са от нещата, които правят разликата между „добро" и „страхотно" потребителско преживяване. Идеята е проста: обновяваме интерфейса незабавно, преди сървърът да потвърди операцията. Ако нещо се обърка, промените се автоматично отменят. React 19 ни дава хука useOptimistic точно за този модел.
Практически пример: Бутон за харесване
Нека да видим класически пример — бутон за „лайк":
// app/actions/like.ts
'use server'
import { db } from '@/lib/db'
import { auth } from '@/lib/auth'
import { revalidatePath } from 'next/cache'
export async function toggleLike(postId: string) {
const session = await auth()
if (!session?.user) throw new Error('Не сте автентикирани')
const existingLike = await db.like.findUnique({
where: {
userId_postId: {
userId: session.user.id,
postId,
},
},
})
if (existingLike) {
await db.like.delete({ where: { id: existingLike.id } })
} else {
await db.like.create({
data: { userId: session.user.id, postId },
})
}
revalidatePath('/posts')
}
// components/like-button.tsx
'use client'
import { useOptimistic, useTransition } from 'react'
import { toggleLike } from '@/app/actions/like'
interface LikeButtonProps {
postId: string
initialLiked: boolean
initialCount: number
}
export function LikeButton({ postId, initialLiked, initialCount }: LikeButtonProps) {
const [isPending, startTransition] = useTransition()
// useOptimistic приема текущото състояние и функция за актуализация
const [optimisticData, setOptimisticData] = useOptimistic(
{ liked: initialLiked, count: initialCount },
(current, _action: 'toggle') => ({
liked: !current.liked,
count: current.liked ? current.count - 1 : current.count + 1,
})
)
function handleClick() {
startTransition(async () => {
// Незабавно обновяваме UI оптимистично
setOptimisticData('toggle')
try {
await toggleLike(postId)
} catch (error) {
// При грешка React автоматично възстановява предишното състояние
console.error('Грешка при харесване:', error)
}
})
}
return (
<button
onClick={handleClick}
disabled={isPending}
className={`flex items-center gap-2 px-3 py-1 rounded-full transition-colors ${
optimisticData.liked
? 'bg-red-100 text-red-600'
: 'bg-gray-100 text-gray-600'
}`}
>
{optimisticData.liked ? '❤️' : '🤍'}
<span>{optimisticData.count}</span>
</button>
)
}
Оптимистичен списък със задачи
Ето и по-сложен пример — todo списък, при който елементите се появяват моментално, още преди сървърът да ги запише:
// components/todo-list.tsx
'use client'
import { useOptimistic, useRef } from 'react'
import { addTodo } from '@/app/actions/todo'
interface Todo {
id: string
text: string
completed: boolean
}
export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
const formRef = useRef<HTMLFormElement>(null)
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
initialTodos,
(state: Todo[], newTodoText: string) => [
...state,
{
// Временен идентификатор за оптимистичния елемент
id: `temp-${Date.now()}`,
text: newTodoText,
completed: false,
},
]
)
async function handleSubmit(formData: FormData) {
const text = formData.get('text') as string
if (!text.trim()) return
formRef.current?.reset()
// Оптимистично добавяме задачата
addOptimisticTodo(text)
// Извикваме Server Action
await addTodo(text)
}
return (
<div>
<form ref={formRef} action={handleSubmit} className="flex gap-2 mb-4">
<input
name="text"
placeholder="Нова задача..."
className="border rounded p-2 flex-1"
/>
<button type="submit" className="bg-blue-600 text-white px-4 rounded">
Добави
</button>
</form>
<ul className="space-y-2">
{optimisticTodos.map((todo) => (
<li
key={todo.id}
className={`p-2 rounded ${
todo.id.startsWith('temp-')
? 'opacity-60 animate-pulse' // Визуална индикация за чакащ елемент
: ''
}`}
>
{todo.text}
</li>
))}
</ul>
</div>
)
}
Забележете хитрия детайл — оптимистичните елементи получават временен идентификатор с префикс temp- и визуална индикация (намалена прозрачност и пулсираща анимация). Потребителят веднага вижда какво е добавил, а когато сървърът потвърди и страницата се ревалидира, React тихичко заменя оптимистичните данни с реалните.
Ревалидация на кеша
Server Actions обикновено променят данни, а това означава, че кешът трябва да бъде обновен след всяка мутация. Иначе потребителят ще вижда остарели данни — а няма нищо по-объркващо от това да запишеш нещо и да не го видиш на екрана. Next.js ви дава два основни инструмента за целта: revalidatePath и revalidateTag.
revalidatePath — ревалидация по път
revalidatePath маркира конкретен маршрут за ревалидация. При следващо посещение данните ще бъдат презаредени:
'use server'
import { revalidatePath } from 'next/cache'
export async function updateProfile(formData: FormData) {
await db.user.update({ /* ... */ })
// Ревалидиране на конкретна страница
revalidatePath('/profile')
// Ревалидиране на layout (засяга всички дъщерни маршрути)
revalidatePath('/dashboard', 'layout')
// Ревалидиране на всичко
revalidatePath('/', 'layout')
}
revalidateTag — ревалидация по таг
revalidateTag е по-прецизният вариант — тя ревалидира всички fetch заявки, маркирани с конкретен таг:
// При извличане на данни — маркираме с таг
const posts = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] },
})
const post = await fetch(`https://api.example.com/posts/${id}`, {
next: { tags: ['posts', `post-${id}`] },
})
// В Server Action — ревалидираме по таг
'use server'
import { revalidateTag } from 'next/cache'
export async function updatePost(postId: string, formData: FormData) {
await db.post.update({ /* ... */ })
// Ревалидираме само кеша свързан с конкретната публикация
revalidateTag(`post-${postId}`)
// Или ревалидираме целия списък с публикации
revalidateTag('posts')
}
Кога кое да ползвате? Използвайте revalidateTag, когато данните идват чрез fetch с тагове — по-прецизно е и по-ефективно. Използвайте revalidatePath, когато работите с ORM или директни заявки към базата, или просто когато искате да ревалидирате цял маршрут наведнъж.
Сигурност на Server Actions
Тук ще бъда директен: сигурността на Server Actions е критична тема, която не бива да подценявате. Да, те се изпълняват на сървъра, но са достъпни чрез публични HTTP endpoints. Всеки, който познава идентификатора на дадено действие, може да го извика.
Уроци от CVE-2025-29927
Уязвимостта CVE-2025-29927 в Next.js middleware беше доста показателна. Тя позволяваше заобикаляне на middleware авторизация чрез манипулация на вътрешни хедъри. Ключовият урок от нея е прост, но важен: никога не разчитайте само на middleware за сигурност. Всяка Server Action трябва сама да проверява правата на потребителя. Защита в дълбочина — не е клише, а необходимост.
Задължителни проверки за сигурност
// app/actions/secure-action.ts
'use server'
import { auth } from '@/lib/auth'
import { z } from 'zod'
import DOMPurify from 'isomorphic-dompurify'
const updateProfileSchema = z.object({
name: z
.string()
.min(2)
.max(100)
.transform((val) => DOMPurify.sanitize(val)), // Санитизация на входа
bio: z
.string()
.max(500)
.transform((val) => DOMPurify.sanitize(val))
.optional(),
})
export async function updateProfile(formData: FormData) {
// 1. ВИНАГИ проверявайте автентикация вътре в действието
const session = await auth()
if (!session?.user) {
throw new Error('Неоторизиран достъп')
}
// 2. ВИНАГИ валидирайте входните данни
const result = updateProfileSchema.safeParse({
name: formData.get('name'),
bio: formData.get('bio'),
})
if (!result.success) {
return { errors: result.error.flatten().fieldErrors }
}
// 3. Проверявайте авторизация (права за достъп)
const targetUserId = formData.get('userId') as string
if (targetUserId !== session.user.id && session.user.role !== 'ADMIN') {
throw new Error('Нямате право да редактирате този профил')
}
// 4. Използвайте параметризирани заявки (ORM-ите правят това автоматично)
await db.user.update({
where: { id: targetUserId },
data: result.data,
})
revalidatePath('/profile')
return { success: true }
}
Стратегия за защита в дълбочина
Ефективната сигурност изисква множество слоеве (да, знам, звучи като лекция по InfoSec, но наистина е важно):
- CSRF защита — Вградена е в Next.js чрез проверка на
Originхедъра при POST заявки. Не се изисква допълнителна конфигурация. - Автентикация — Проверявайте сесията вътре във всяко действие, не само в middleware.
- Авторизация — Проверявайте дали потребителят има право да извърши конкретната операция.
- Валидация на входа — Zod за строга валидация на всички входни данни. Без изключения.
- Санитизация — Почиствайте HTML/XSS съдържание от потребителски вход с нещо като DOMPurify.
- Rate limiting — Ограничавайте броя заявки за предотвратяване на злоупотреби.
- Логване — Записвайте критични действия за одит.
Никога не приемайте, че данните от клиента са валидни. Дори да имате перфектна клиентска валидация, сървърната валидация е задължителна. Злонамерен потребител може да заобиколи клиентската валидация за секунди чрез директни HTTP заявки.
Разширени модели
Качване на файлове със Server Actions
Server Actions поддържат нативно FormData, което прави качването на файлове доста естествено. Ето как изглежда на практика:
// app/actions/upload.ts
'use server'
import { writeFile, mkdir } from 'fs/promises'
import path from 'path'
import { auth } from '@/lib/auth'
import { z } from 'zod'
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']
const uploadSchema = z.object({
file: z
.instanceof(File)
.refine((f) => f.size <= MAX_FILE_SIZE, 'Файлът е твърде голям (макс. 5 MB)')
.refine(
(f) => ALLOWED_TYPES.includes(f.type),
'Неподдържан формат. Разрешени: JPEG, PNG, WebP'
),
alt: z.string().min(3, 'Алтернативният текст е задължителен').max(200),
})
export async function uploadImage(formData: FormData) {
const session = await auth()
if (!session?.user) throw new Error('Неоторизиран достъп')
const result = uploadSchema.safeParse({
file: formData.get('file'),
alt: formData.get('alt'),
})
if (!result.success) {
return { errors: result.error.flatten().fieldErrors }
}
const { file, alt } = result.data
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
// Генерираме уникално име на файла
const uniqueName = `${Date.now()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '_')}`
const uploadDir = path.join(process.cwd(), 'public', 'uploads')
// Създаваме директорията ако не съществува
await mkdir(uploadDir, { recursive: true })
const filePath = path.join(uploadDir, uniqueName)
await writeFile(filePath, buffer)
// Записваме метаданните в базата
const image = await db.image.create({
data: {
url: `/uploads/${uniqueName}`,
alt,
uploadedBy: session.user.id,
},
})
return { success: true, image }
}
Пренасочване след мутация
За пренасочване след успешна мутация ще ви трябва redirect от next/navigation. Има обаче един капан, за който трябва да знаете: redirect хвърля специално изключение, затова трябва да се извика извън try/catch блока:
'use server'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
let postId: string
try {
const post = await db.post.create({
data: { /* ... */ },
})
postId = post.id
} catch (error) {
return { errors: { _form: ['Грешка при създаване'] } }
}
// redirect ТРЯБВА да е извън try/catch
revalidatePath('/posts')
redirect(`/posts/${postId}`)
}
Верижни (chaining) действия
Понякога се налага да изпълните няколко свързани операции последователно. Можете спокойно да ги композирате в една Server Action:
'use server'
export async function publishPostWithNotification(postId: string) {
// Стъпка 1: Публикуваме поста
const post = await db.post.update({
where: { id: postId },
data: { published: true, publishedAt: new Date() },
})
// Стъпка 2: Изпращаме известие на абонатите
const subscribers = await db.subscription.findMany({
where: { authorId: post.authorId },
})
await Promise.all(
subscribers.map((sub) =>
sendNotification({
to: sub.email,
subject: `Нова публикация: ${post.title}`,
body: `Автор ${post.author.name} публикува нова статия.`,
})
)
)
// Стъпка 3: Ревалидираме кеша
revalidatePath('/posts')
revalidateTag(`post-${postId}`)
return { success: true }
}
Error Boundaries за Server Actions
Когато Server Action хвърли необработено изключение (вместо да върне обект с грешки), React error.tsx границата улавя грешката. Това е вашата „предпазна мрежа":
// app/posts/error.tsx
'use client'
export default function PostsError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="p-8 text-center">
<h2 className="text-xl font-bold text-red-600 mb-4">
Нещо се обърка
</h2>
<p className="text-gray-600 mb-4">
{error.message || 'Възникна неочаквана грешка при обработката.'}
</p>
<button
onClick={reset}
className="bg-blue-600 text-white px-4 py-2 rounded"
>
Опитайте отново
</button>
</div>
)
}
Добрата практика е: връщайте структурирани грешки за очаквани проблеми (невалидни данни, дублиран запис) и хвърляйте изключения за неочаквани грешки (мрежови проблеми, бъгове). Така error boundary-то ще улови само наистина неочакваните ситуации.
Паралелни мутации
Когато трябва да извършите множество независими операции, Promise.allSettled е вашият приятел. Ето пример за масово обновяване:
'use server'
export async function batchUpdatePosts(postIds: string[], status: string) {
const session = await auth()
if (!session?.user) throw new Error('Неоторизиран достъп')
// Паралелна проверка на собственост и обновяване
const results = await Promise.allSettled(
postIds.map(async (id) => {
const post = await db.post.findUnique({
where: { id },
select: { authorId: true },
})
if (post?.authorId !== session.user.id) {
throw new Error(`Нямате достъп до публикация ${id}`)
}
return db.post.update({
where: { id },
data: { status },
})
})
)
// Анализираме резултатите
const succeeded = results.filter((r) => r.status === 'fulfilled').length
const failed = results.filter((r) => r.status === 'rejected').length
revalidatePath('/posts')
return {
message: `Обновени: ${succeeded}, Неуспешни: ${failed}`,
}
}
Най-добри практики за 2026
Организация на кода
За средни и големи проекти тази структура работи добре (и от личен опит ви казвам — спестява главоболия):
// Препоръчителна структура за Server Actions
lib/
safe-action.ts // Конфигурация на action клиента
validations/
user.ts // Zod схеми за потребители
post.ts // Zod схеми за публикации
shared.ts // Споделени валидации (email, slug и т.н.)
app/
actions/
user.ts // Server Actions за потребители
post.ts // Server Actions за публикации
auth.ts // Server Actions за автентикация
Ключови принципи:
- Един файл на домейн — Групирайте действията по бизнес домейн, не по техническа функция.
- Отделете валидацията — Zod схемите в отделни файлове позволяват повторна употреба и на клиента, и на сървъра.
- Единен action клиент — Дефинирайте middleware веригите на едно място и ги преизползвайте навсякъде.
- Консистентен модел за отговори — Дефинирайте стандартен тип за успех/грешка и не се отклонявайте от него.
Тестване на Server Actions
Server Actions са обикновени async функции, което ги прави сравнително лесни за тестване. Основното предизвикателство е мокването на зависимости като база данни и автентикация — но нищо, което Vitest не може да се справи:
// __tests__/actions/post.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createPost } from '@/app/actions/post'
// Мокваме зависимостите
vi.mock('@/lib/db', () => ({
db: {
post: {
create: vi.fn(),
},
},
}))
vi.mock('@/lib/auth', () => ({
auth: vi.fn(),
}))
vi.mock('next/cache', () => ({
revalidatePath: vi.fn(),
}))
import { db } from '@/lib/db'
import { auth } from '@/lib/auth'
describe('createPost Server Action', () => {
beforeEach(() => {
vi.clearAllMocks()
// Симулираме автентикиран потребител
vi.mocked(auth).mockResolvedValue({
user: { id: 'user-1', role: 'USER' },
} as any)
})
it('създава публикация при валидни данни', async () => {
vi.mocked(db.post.create).mockResolvedValue({
id: 'post-1',
title: 'Тестово заглавие',
} as any)
const formData = new FormData()
formData.set('title', 'Тестово заглавие')
formData.set('content', 'Тестово съдържание за публикация')
const result = await createPost({}, formData)
expect(result.success).toBe(true)
expect(db.post.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
title: 'Тестово заглавие',
}),
})
)
})
it('връща грешки при невалидни данни', async () => {
const formData = new FormData()
formData.set('title', 'AB') // Твърде кратко
formData.set('content', '') // Празно
const result = await createPost({}, formData)
expect(result.errors).toBeDefined()
expect(result.errors?.title).toBeDefined()
expect(result.errors?.content).toBeDefined()
expect(db.post.create).not.toHaveBeenCalled()
})
})
Мониторинг и наблюдаемост
В продукция е важно да следите Server Actions за проблеми с производителността и грешки. Ето един модел за мониторинг чрез middleware на next-safe-action:
// lib/safe-action.ts
import { createSafeActionClient } from 'next-safe-action'
import { logger } from '@/lib/logger' // Pino, Winston и т.н.
import { metrics } from '@/lib/metrics' // Prometheus, OpenTelemetry и т.н.
export const monitoredActionClient = createSafeActionClient({
handleServerError(error) {
logger.error('Server Action неочаквана грешка', {
error: error.message,
stack: error.stack,
})
return 'Възникна грешка. Моля, опитайте отново.'
},
}).use(async ({ next, metadata }) => {
const startTime = performance.now()
try {
const result = await next()
// Записваме времето за изпълнение
const duration = performance.now() - startTime
metrics.histogram('server_action_duration_ms', duration, {
action: metadata?.actionName ?? 'unknown',
status: 'success',
})
logger.info('Server Action изпълнен', {
action: metadata?.actionName,
durationMs: Math.round(duration),
})
return result
} catch (error) {
const duration = performance.now() - startTime
metrics.histogram('server_action_duration_ms', duration, {
action: metadata?.actionName ?? 'unknown',
status: 'error',
})
throw error
}
})
Продукционни съвети
Ето обобщение на най-важните практики за Server Actions в продукция през 2026 г. Запазете си този списък някъде:
- Винаги валидирайте на сървъра — Клиентската валидация е само за UX, не за сигурност. Zod валидация в Server Action е задължителна.
- Проверявайте автентикация и авторизация вътре в действието — Не разчитайте само на middleware или layout-и.
- Използвайте next-safe-action — Елиминира повтарящ се boilerplate код и осигурява консистентна обработка на грешки.
- Ревалидирайте прецизно — Предпочитайте
revalidateTagпредrevalidatePath('/', 'layout'). Прекомерната ревалидация влошава производителността. - Обработвайте грешките грациозно — Връщайте структурирани грешки за очаквани проблеми, хвърляйте изключения за неочаквани. Никога не разкривайте вътрешни детайли на клиента.
- Добавяйте rate limiting — Особено за действия, които записват в базата, изпращат имейли или обработват файлове.
- Оптимистичните актуализации подобряват UX — Използвайте
useOptimisticза операции, при които забавянето е осезаемо. - Тествайте Server Actions — Те са обикновени функции и се тестват лесно с мокнати зависимости.
- Логвайте и наблюдавайте — Всяка Server Action в продукция трябва да бъде инструментирана с логване и метрики.
- Избягвайте тежки операции — За дълги задачи (обработка на видео, масов импорт) делегирайте към фонова опашка и връщайте незабавен отговор.
Server Actions в Next.js 15 са зряла и мощна абстракция за мутации. Комбинирани с React 19, Zod и next-safe-action, те предлагат модел на разработка, който е типово-безопасен, производителен и сигурен едновременно. Ако следвате описаните практики, вашите приложения ще бъдат готови за продукция от ден първи.