Server Actions — функции Next.js, которые выполняются на сервере, но вызываются из клиентского кода. Они решают задачу отправки данных на сервер без написания отдельного API-эндпоинта.
До Server Actions для мутации данных нужно было:
1. Создать API Route (/api/updateUser)
2. Сделать fetch('/api/updateUser', { method: 'POST', body: ... })
3. Обработать состояния загрузки, ошибок
Теперь: просто функция с 'use server'.
// app/actions.ts
'use server' // все функции этого файла — серверные
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
const content = formData.get('content') as string
// Прямая работа с БД — безопасно, код не попадает в браузер!
await db.posts.create({ data: { title, content } })
// Инвалидируем кэш страницы с постами
revalidatePath('/blog')
}
export async function deletePost(id: string) {
await db.posts.delete({ where: { id } })
revalidatePath('/blog')
}Самый простой способ использования — атрибут action у <form>:
// app/blog/new/page.tsx
import { createPost } from '../actions'
export default function NewPostPage() {
return (
<form action={createPost}> {/* Server Action как action формы! */}
<input name="title" placeholder="Заголовок" required />
<textarea name="content" placeholder="Содержание" />
<button type="submit">Опубликовать</button>
</form>
)
}
// Никакого fetch, никакого preventDefault — просто работает!'use client'
import { useFormStatus } from 'react-dom'
function SubmitButton() {
const { pending } = useFormStatus() // true пока форма отправляется
return (
<button type="submit" disabled={pending}>
{pending ? 'Сохранение...' : 'Сохранить'}
</button>
)
}
// Использование:
function MyForm() {
return (
<form action={saveData}>
<input name="value" />
<SubmitButton /> {/* SubmitButton должен быть ВНУТРИ form */}
</form>
)
}Показывает результат до ответа сервера, откатывается при ошибке:
'use client'
import { useOptimistic } from 'react'
import { likePost } from './actions'
function LikeButton({ post }) {
const [optimisticPost, addOptimisticLike] = useOptimistic(
post,
(state, newLikesCount) => ({ ...state, likes: newLikesCount })
)
async function handleLike() {
// Обновляем UI сразу — не ждём сервера
addOptimisticLike(optimisticPost.likes + 1)
// Реальный запрос к серверу
await likePost(post.id)
// Если ошибка — автоматически откат к исходному состоянию
}
return (
<button onClick={handleLike}>
❤️ {optimisticPost.likes}
</button>
)
}'use client'
import { useActionState } from 'react'
import { createPost } from './actions'
// Сервер-экшн возвращает состояние
async function createPostWithState(prevState, formData) {
'use server'
const title = formData.get('title')
if (!title) return { error: 'Заголовок обязателен', success: false }
await db.posts.create({ data: { title } })
return { error: null, success: true, message: 'Пост создан!' }
}
function CreatePostForm() {
const [state, formAction, isPending] = useActionState(createPostWithState, {
error: null,
success: false,
})
return (
<form action={formAction}>
{state.error && <p style={{ color: 'red' }}>{state.error}</p>}
{state.success && <p style={{ color: 'green' }}>{state.message}</p>}
<input name="title" />
<button disabled={isPending}>
{isPending ? 'Создание...' : 'Создать'}
</button>
</form>
)
}Server Actions выполняются на сервере, но их могут вызвать напрямую злоумышленники. Всегда проверяйте авторизацию:
'use server'
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'
export async function deletePost(id: string) {
// ВАЖНО: всегда проверяй аутентификацию!
const session = await auth()
if (!session) redirect('/login')
// ВАЖНО: проверяй права доступа к конкретному ресурсу!
const post = await db.posts.findUnique({ where: { id } })
if (post.authorId !== session.user.id) {
throw new Error('Нет прав для удаления этого поста')
}
await db.posts.delete({ where: { id } })
}'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
export async function updatePost(id, data) {
await db.posts.update({ where: { id }, data })
// Инвалидируем конкретный путь
revalidatePath('/blog/' + id)
// Или инвалидируем по тегу (более гибко)
revalidateTag('posts')
revalidateTag('post-' + id)
}Симуляция механизма Server Actions в ванильном JS: очередь действий, оптимистичные обновления и откат при ошибке
// Симулируем архитектуру Server Actions:
// очередь запросов, оптимистичный UI и rollback при ошибке.
// --- Симуляция сервера ---
const serverDB = {
posts: [
{ id: 1, title: 'Первый пост', likes: 10 },
{ id: 2, title: 'Второй пост', likes: 5 },
]
}
// Серверная функция (имитирует сетевой запрос)
function serverLikePost(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
const post = serverDB.posts.find(p => p.id === id)
if (!post) {
reject(new Error('Пост не найден'))
return
}
post.likes++
resolve({ id: post.id, likes: post.likes })
}, 600) // задержка 600мс
})
}
// --- Оптимистичный стор ---
function createOptimisticUI(initialPosts) {
let committed = initialPosts.map(p => ({ ...p })) // подтверждённые данные
let optimistic = initialPosts.map(p => ({ ...p })) // отображаемые данные
const pending = new Map() // id -> { previousValue, action }
return {
// Оптимистичное обновление: применяем сразу
optimisticUpdate(postId, updater) {
const post = optimistic.find(p => p.id === postId)
if (!post) return
// Сохраняем предыдущее значение для отката
pending.set(postId, {
previousLikes: post.likes,
timestamp: Date.now()
})
// Применяем обновление в UI немедленно
updater(post)
console.log('[UI] Оптимистично обновили пост', postId + ':', 'likes =', post.likes)
},
// Подтверждение: синхронизируем committed с сервером
confirm(postId, serverData) {
pending.delete(postId)
const committedPost = committed.find(p => p.id === postId)
const optimisticPost = optimistic.find(p => p.id === postId)
if (committedPost) committedPost.likes = serverData.likes
if (optimisticPost) optimisticPost.likes = serverData.likes
console.log('[OK] Сервер подтвердил пост', postId + ': likes =', serverData.likes)
},
// Откат при ошибке
rollback(postId, error) {
const saved = pending.get(postId)
if (!saved) return
const post = optimistic.find(p => p.id === postId)
if (post) post.likes = saved.previousLikes
pending.delete(postId)
console.log('[ERR] Откат поста', postId + ': likes вернулись к', saved.previousLikes)
console.log('[ERR] Причина:', error.message)
},
getDisplay() { return optimistic },
getPending() { return Array.from(pending.keys()) },
}
}
// --- Имитация Server Action ---
async function likePostAction(store, postId) {
// 1. Оптимистично обновляем UI
store.optimisticUpdate(postId, post => { post.likes++ })
// 2. Отправляем реальный запрос на "сервер"
try {
const result = await serverLikePost(postId)
store.confirm(postId, result)
} catch (err) {
store.rollback(postId, err)
}
}
// --- Запуск ---
async function main() {
const store = createOptimisticUI(serverDB.posts)
console.log('Начальное состояние:', store.getDisplay().map(p => p.title + ': ' + p.likes + ' лайков'))
// Лайкаем пост 1 — успех
console.log('
--- Лайк поста 1 ---')
const like1 = likePostAction(store, 1)
// Лайкаем пост 2 — успех
console.log('--- Лайк поста 2 ---')
const like2 = likePostAction(store, 2)
// Попытка лайкнуть несуществующий пост — откат
console.log('--- Лайк несуществующего поста 99 ---')
const like3 = likePostAction(store, 99)
console.log('
Пока ждём сервер, UI показывает:', store.getDisplay().map(p => p.title + ': ' + p.likes))
console.log('Ожидают подтверждения:', store.getPending())
await Promise.all([like1, like2, like3])
console.log('
Итоговое состояние:', store.getDisplay().map(p => p.title + ': ' + p.likes + ' лайков'))
console.log('Ожидают подтверждения:', store.getPending())
}
main()Server Actions — функции Next.js, которые выполняются на сервере, но вызываются из клиентского кода. Они решают задачу отправки данных на сервер без написания отдельного API-эндпоинта.
До Server Actions для мутации данных нужно было:
1. Создать API Route (/api/updateUser)
2. Сделать fetch('/api/updateUser', { method: 'POST', body: ... })
3. Обработать состояния загрузки, ошибок
Теперь: просто функция с 'use server'.
// app/actions.ts
'use server' // все функции этого файла — серверные
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
const content = formData.get('content') as string
// Прямая работа с БД — безопасно, код не попадает в браузер!
await db.posts.create({ data: { title, content } })
// Инвалидируем кэш страницы с постами
revalidatePath('/blog')
}
export async function deletePost(id: string) {
await db.posts.delete({ where: { id } })
revalidatePath('/blog')
}Самый простой способ использования — атрибут action у <form>:
// app/blog/new/page.tsx
import { createPost } from '../actions'
export default function NewPostPage() {
return (
<form action={createPost}> {/* Server Action как action формы! */}
<input name="title" placeholder="Заголовок" required />
<textarea name="content" placeholder="Содержание" />
<button type="submit">Опубликовать</button>
</form>
)
}
// Никакого fetch, никакого preventDefault — просто работает!'use client'
import { useFormStatus } from 'react-dom'
function SubmitButton() {
const { pending } = useFormStatus() // true пока форма отправляется
return (
<button type="submit" disabled={pending}>
{pending ? 'Сохранение...' : 'Сохранить'}
</button>
)
}
// Использование:
function MyForm() {
return (
<form action={saveData}>
<input name="value" />
<SubmitButton /> {/* SubmitButton должен быть ВНУТРИ form */}
</form>
)
}Показывает результат до ответа сервера, откатывается при ошибке:
'use client'
import { useOptimistic } from 'react'
import { likePost } from './actions'
function LikeButton({ post }) {
const [optimisticPost, addOptimisticLike] = useOptimistic(
post,
(state, newLikesCount) => ({ ...state, likes: newLikesCount })
)
async function handleLike() {
// Обновляем UI сразу — не ждём сервера
addOptimisticLike(optimisticPost.likes + 1)
// Реальный запрос к серверу
await likePost(post.id)
// Если ошибка — автоматически откат к исходному состоянию
}
return (
<button onClick={handleLike}>
❤️ {optimisticPost.likes}
</button>
)
}'use client'
import { useActionState } from 'react'
import { createPost } from './actions'
// Сервер-экшн возвращает состояние
async function createPostWithState(prevState, formData) {
'use server'
const title = formData.get('title')
if (!title) return { error: 'Заголовок обязателен', success: false }
await db.posts.create({ data: { title } })
return { error: null, success: true, message: 'Пост создан!' }
}
function CreatePostForm() {
const [state, formAction, isPending] = useActionState(createPostWithState, {
error: null,
success: false,
})
return (
<form action={formAction}>
{state.error && <p style={{ color: 'red' }}>{state.error}</p>}
{state.success && <p style={{ color: 'green' }}>{state.message}</p>}
<input name="title" />
<button disabled={isPending}>
{isPending ? 'Создание...' : 'Создать'}
</button>
</form>
)
}Server Actions выполняются на сервере, но их могут вызвать напрямую злоумышленники. Всегда проверяйте авторизацию:
'use server'
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'
export async function deletePost(id: string) {
// ВАЖНО: всегда проверяй аутентификацию!
const session = await auth()
if (!session) redirect('/login')
// ВАЖНО: проверяй права доступа к конкретному ресурсу!
const post = await db.posts.findUnique({ where: { id } })
if (post.authorId !== session.user.id) {
throw new Error('Нет прав для удаления этого поста')
}
await db.posts.delete({ where: { id } })
}'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
export async function updatePost(id, data) {
await db.posts.update({ where: { id }, data })
// Инвалидируем конкретный путь
revalidatePath('/blog/' + id)
// Или инвалидируем по тегу (более гибко)
revalidateTag('posts')
revalidateTag('post-' + id)
}Симуляция механизма Server Actions в ванильном JS: очередь действий, оптимистичные обновления и откат при ошибке
// Симулируем архитектуру Server Actions:
// очередь запросов, оптимистичный UI и rollback при ошибке.
// --- Симуляция сервера ---
const serverDB = {
posts: [
{ id: 1, title: 'Первый пост', likes: 10 },
{ id: 2, title: 'Второй пост', likes: 5 },
]
}
// Серверная функция (имитирует сетевой запрос)
function serverLikePost(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
const post = serverDB.posts.find(p => p.id === id)
if (!post) {
reject(new Error('Пост не найден'))
return
}
post.likes++
resolve({ id: post.id, likes: post.likes })
}, 600) // задержка 600мс
})
}
// --- Оптимистичный стор ---
function createOptimisticUI(initialPosts) {
let committed = initialPosts.map(p => ({ ...p })) // подтверждённые данные
let optimistic = initialPosts.map(p => ({ ...p })) // отображаемые данные
const pending = new Map() // id -> { previousValue, action }
return {
// Оптимистичное обновление: применяем сразу
optimisticUpdate(postId, updater) {
const post = optimistic.find(p => p.id === postId)
if (!post) return
// Сохраняем предыдущее значение для отката
pending.set(postId, {
previousLikes: post.likes,
timestamp: Date.now()
})
// Применяем обновление в UI немедленно
updater(post)
console.log('[UI] Оптимистично обновили пост', postId + ':', 'likes =', post.likes)
},
// Подтверждение: синхронизируем committed с сервером
confirm(postId, serverData) {
pending.delete(postId)
const committedPost = committed.find(p => p.id === postId)
const optimisticPost = optimistic.find(p => p.id === postId)
if (committedPost) committedPost.likes = serverData.likes
if (optimisticPost) optimisticPost.likes = serverData.likes
console.log('[OK] Сервер подтвердил пост', postId + ': likes =', serverData.likes)
},
// Откат при ошибке
rollback(postId, error) {
const saved = pending.get(postId)
if (!saved) return
const post = optimistic.find(p => p.id === postId)
if (post) post.likes = saved.previousLikes
pending.delete(postId)
console.log('[ERR] Откат поста', postId + ': likes вернулись к', saved.previousLikes)
console.log('[ERR] Причина:', error.message)
},
getDisplay() { return optimistic },
getPending() { return Array.from(pending.keys()) },
}
}
// --- Имитация Server Action ---
async function likePostAction(store, postId) {
// 1. Оптимистично обновляем UI
store.optimisticUpdate(postId, post => { post.likes++ })
// 2. Отправляем реальный запрос на "сервер"
try {
const result = await serverLikePost(postId)
store.confirm(postId, result)
} catch (err) {
store.rollback(postId, err)
}
}
// --- Запуск ---
async function main() {
const store = createOptimisticUI(serverDB.posts)
console.log('Начальное состояние:', store.getDisplay().map(p => p.title + ': ' + p.likes + ' лайков'))
// Лайкаем пост 1 — успех
console.log('
--- Лайк поста 1 ---')
const like1 = likePostAction(store, 1)
// Лайкаем пост 2 — успех
console.log('--- Лайк поста 2 ---')
const like2 = likePostAction(store, 2)
// Попытка лайкнуть несуществующий пост — откат
console.log('--- Лайк несуществующего поста 99 ---')
const like3 = likePostAction(store, 99)
console.log('
Пока ждём сервер, UI показывает:', store.getDisplay().map(p => p.title + ': ' + p.likes))
console.log('Ожидают подтверждения:', store.getPending())
await Promise.all([like1, like2, like3])
console.log('
Итоговое состояние:', store.getDisplay().map(p => p.title + ': ' + p.likes + ' лайков'))
console.log('Ожидают подтверждения:', store.getPending())
}
main()Создай React-форму с имитацией Server Action. Форма отправляет данные, показывает состояние загрузки (pending), и отображает результат (успех или ошибка). Используй хук useActionState для управления состоянием формы.
В useActionState: setIsPending(true) перед action, setState(result) после, setIsPending(false) в конце. SubmitButton: disabled={pending}. В сообщениях: state.error, state.data?.title, state.data?.id. SubmitButton получает pending={isPending}.