Server Actions i Next.js App Router: Den kompletta guiden för 2026
Next.js har genomgått en ganska remarkabel transformation sedan App Router introducerades. Bland de mest kraftfulla funktionerna som kommit med denna nya arkitektur hittar vi Server Actions — och ärligt talat, de förändrar spelreglerna. De representerar ett fundamentalt skifte i hur vi tänker kring server-klient-kommunikation i moderna webbapplikationer. Istället för att manuellt skapa API-routes, hantera fetch-anrop och serialisera data fram och tillbaka, låter Server Actions oss anropa serverfunktioner direkt från våra React-komponenter. Som om gränsen mellan server och klient inte ens existerade.
Låter det för bra för att vara sant? Det är det inte.
I den här guiden går vi igenom allt du behöver veta om Server Actions i Next.js 15+ med React 19. Från grundläggande koncept till avancerade mönster, säkerhet i produktion och ett komplett praktiskt projekt. Oavsett om du precis börjat utforska App Router eller redan bygger produktionsapplikationer — den här guiden ger dig den djupa förståelse som krävs för att utnyttja Server Actions till sin fulla potential.
Varför spelar Server Actions roll? De eliminerar behovet av manuella API-lager för mutationer, möjliggör progressive enhancement (formulär fungerar även utan JavaScript!), minskar mängden klient-JavaScript och förenklar dataflödet avsevärt. Med React 19:s nya hooks som useActionState och useOptimistic får vi dessutom ett riktigt elegant sätt att hantera laddningstillstånd, felmeddelanden och optimistiska uppdateringar.
Grundläggande koncept
Så, låt oss börja från grunden. En Server Action är i sin kärna en asynkron funktion som exekveras på servern. Den definieras med direktivet 'use server' och kan anropas från både Server Components och Client Components. Under huven skapar Next.js automatiskt en HTTP POST-endpoint för varje Server Action — men som utvecklare behöver du aldrig tänka på den infrastrukturen. Det sköts helt åt dig.
Definiera en Server Action
Det finns två sätt att definiera Server Actions. Det första (och vanligaste i större projekt) är att placera direktivet 'use server' högst upp i en fil, vilket gör alla exporterade funktioner i filen till Server Actions:
// app/actions.ts
'use server'
export async function createTodo(formData: FormData) {
const title = formData.get('title') as string
// Spara till databasen
await db.todo.create({ data: { title } })
}
export async function deleteTodo(id: string) {
await db.todo.delete({ where: { id } })
}
Det andra sättet är att definiera en Server Action inline i en Server Component genom att lägga 'use server' högst upp i funktionskroppen:
// app/page.tsx
export default function TodoPage() {
async function addTodo(formData: FormData) {
'use server'
const title = formData.get('title') as string
await db.todo.create({ data: { title } })
}
return (
<form action={addTodo}>
<input type="text" name="title" />
<button type="submit">Lägg till</button>
</form>
)
}
Viktigt att förstå: Server Actions är publika HTTP-endpoints
Det här är en kritisk insikt som många missar i början. Varje Server Action exponeras som en publik HTTP POST-endpoint. Det innebär att vem som helst tekniskt sett kan anropa din Server Action om de känner till dess endpoint. Därför måste du alltid validera indata och kontrollera autentisering/auktorisering inuti varje action — precis som du skulle göra med en vanlig API-route.
Vi återkommer till detta i säkerhetssektionerna längre ner.
Anropa Server Actions från Client Components
Client Components kan inte definiera Server Actions inline, men de kan importera och använda dem utan problem:
// app/components/AddTodoButton.tsx
'use client'
import { createTodo } from '@/app/actions'
export function AddTodoButton() {
return (
<form action={createTodo}>
<input type="text" name="title" placeholder="Ny uppgift..." />
<button type="submit">Lägg till</button>
</form>
)
}
Formulärhantering
En av de mest eleganta aspekterna av Server Actions är hur smidigt de integreras med vanliga HTML-formulär. React utökar det vanliga HTML <form>-elementet med en action-prop som accepterar en Server Action direkt. När formuläret skickas in serialiseras formulärdatan automatiskt som ett FormData-objekt och skickas till din action.
Det är faktiskt riktigt smidigt.
Grundläggande formulärhantering med FormData
// app/actions.ts
'use server'
export async function createArticle(formData: FormData) {
const title = formData.get('title') as string
const content = formData.get('content') as string
const category = formData.get('category') as string
await db.article.create({
data: { title, content, category }
})
}
// app/articles/new/page.tsx
import { createArticle } from '@/app/actions'
export default function NewArticlePage() {
return (
<form action={createArticle}>
<label htmlFor="title">Titel</label>
<input id="title" name="title" type="text" required />
<label htmlFor="content">Innehåll</label>
<textarea id="content" name="content" rows={10} required />
<label htmlFor="category">Kategori</label>
<select id="category" name="category">
<option value="tech">Teknik</option>
<option value="design">Design</option>
<option value="business">Affärer</option>
</select>
<button type="submit">Publicera artikel</button>
</form>
)
}
Skicka ytterligare argument med bind()
Ibland behöver du skicka med extra data utöver formulärdatan — till exempel ett ID för den post som ska uppdateras. Här kommer JavaScripts bind()-metod in i bilden:
// app/actions.ts
'use server'
export async function updateTodo(id: string, formData: FormData) {
const title = formData.get('title') as string
const completed = formData.get('completed') === 'on'
await db.todo.update({
where: { id },
data: { title, completed }
})
}
// app/components/EditTodoForm.tsx
'use client'
import { updateTodo } from '@/app/actions'
export function EditTodoForm({ todo }: { todo: Todo }) {
const updateTodoWithId = updateTodo.bind(null, todo.id)
return (
<form action={updateTodoWithId}>
<input name="title" defaultValue={todo.title} />
<label>
<input
type="checkbox"
name="completed"
defaultChecked={todo.completed}
/>
Klar
</label>
<button type="submit">Spara</button>
</form>
)
}
Det bundna argumentet (id) skickas som det första argumentet till din action, följt av FormData. Argumentet serialiseras och skickas som en del av formulärdatan, men det krypteras av Next.js för säkerhetens skull.
Progressive Enhancement
En av de stora fördelarna med Server Actions i kombination med formulär är progressive enhancement. Formuläret fungerar även om JavaScript inte har laddats ännu eller om det är inaktiverat. Det skickas då som en helt vanlig HTML POST-request, och Server Action exekveras på servern som vanligt. När JavaScript väl är tillgängligt "hydreras" formuläret och framtida inskickningar hanteras via fetch utan sidladdning.
Det här ger en märkbart bättre användarupplevelse — särskilt på långsammare nätverk eller äldre enheter — och förbättrar dessutom tillgängligheten.
Validering och säkerhet
Att validera indata är absolut kritiskt för Server Actions. Det kan inte understrykas nog. Kom ihåg: de är publika endpoints. All data som kommer in måste behandlas som potentiellt opålitlig, oavsett hur fin validering du har på klientsidan.
Validering med Zod
Zod är det rekommenderade biblioteket för schemavalidering i TypeScript-baserade Next.js-projekt. Det ger dig typsäker validering med utmärkta felmeddelanden, och det är ärligt talat ett nöje att använda:
// app/actions.ts
'use server'
import { z } from 'zod'
const CreateTodoSchema = z.object({
title: z
.string()
.min(1, 'Titel krävs')
.max(200, 'Titeln får inte överstiga 200 tecken'),
description: z
.string()
.max(1000, 'Beskrivningen får inte överstiga 1000 tecken')
.optional(),
priority: z.enum(['low', 'medium', 'high'], {
errorMap: () => ({ message: 'Ogiltig prioritetsnivå' })
})
})
export type CreateTodoState = {
errors?: {
title?: string[]
description?: string[]
priority?: string[]
}
message?: string
success?: boolean
}
export async function createTodo(
prevState: CreateTodoState,
formData: FormData
): Promise<CreateTodoState> {
const validatedFields = CreateTodoSchema.safeParse({
title: formData.get('title'),
description: formData.get('description'),
priority: formData.get('priority')
})
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: 'Valideringsfel. Kontrollera fälten nedan.'
}
}
try {
await db.todo.create({
data: validatedFields.data
})
return { success: true, message: 'Uppgiften skapades!' }
} catch (error) {
return { message: 'Databasfel. Kunde inte skapa uppgiften.' }
}
}
Autentisering och auktorisering
Varje Server Action som utför en skyddad operation måste kontrollera att användaren är autentiserad och har rätt behörigheter. Det här är inte valfritt:
// app/actions.ts
'use server'
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'
export async function deleteProject(projectId: string) {
const session = await auth()
if (!session?.user) {
redirect('/login')
}
// Kontrollera att användaren äger projektet
const project = await db.project.findUnique({
where: { id: projectId }
})
if (!project) {
throw new Error('Projektet hittades inte')
}
if (project.ownerId !== session.user.id) {
throw new Error('Du har inte behörighet att radera detta projekt')
}
await db.project.delete({ where: { id: projectId } })
revalidatePath('/projects')
}
Det är lätt att glömma auktoriseringskontroller — jag har sett det hända i produktion mer än en gång. Men det är en av de vanligaste säkerhetsbristerna i webbapplikationer. Kontrollera alltid att den inloggade användaren har rätt att utföra den begärda operationen.
Revalidering och cache
Next.js App Router har ett ganska aggressivt cachningssystem (ibland kanske lite för aggressivt, om man ska vara ärlig). När du utför en mutation via en Server Action behöver du berätta för Next.js vilka delar av cachen som ska ogiltigförklaras. Det finns två primära verktyg för detta: revalidatePath och revalidateTag.
revalidatePath
Denna funktion ogiltigförklarar cachen för en specifik URL-sökväg. Nästa gång en användare besöker den sökvägen hämtas helt färsk data:
'use server'
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.post.create({ data: { title, content } })
// Ogiltigförklara listvisningen
revalidatePath('/posts')
// Du kan också ogiltigförklara en specifik dynamisk route
// revalidatePath('/posts/[slug]', 'page')
// Eller en hel layoutgrupp
// revalidatePath('/posts', 'layout')
}
revalidateTag
För mer granulär kontroll kan du använda taggar. Taggar kopplas till fetch-anrop och kan ogiltigförklaras oberoende av sökvägar — vilket ger dig finare kontroll:
// Datahämtning med tagg
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] }
})
return res.json()
}
async function getPost(id: string) {
const res = await fetch(`https://api.example.com/posts/${id}`, {
next: { tags: ['posts', `post-${id}`] }
})
return res.json()
}
// Server Action som ogiltigförklarar specifika taggar
'use server'
import { revalidateTag } from 'next/cache'
export async function updatePost(id: string, formData: FormData) {
const title = formData.get('title') as string
await db.post.update({
where: { id },
data: { title }
})
// Ogiltigförklara bara den specifika posten och listan
revalidateTag(`post-${id}`)
revalidateTag('posts')
}
Att använda taggar ger dig finare kontroll över vilken data som uppdateras. I större applikationer gör det verkligen skillnad — du vill ju inte ogiltigförklara hela sidor i onödan bara för att en enda post ändrades.
Optimistiska uppdateringar
Optimistiska uppdateringar är ett av de mönstren som verkligen lyfter användarupplevelsen. Tanken är enkel: vi uppdaterar gränssnittet omedelbart när användaren utför en åtgärd, innan servern hunnit bekräfta operationen. Det ger en snabbare och mer responsiv känsla. React 19 introducerar useOptimistic-hooken specifikt för detta.
useOptimistic i praktiken
// app/components/TodoList.tsx
'use client'
import { useOptimistic } from 'react'
import { toggleTodo, deleteTodo } from '@/app/actions'
type Todo = {
id: string
title: string
completed: boolean
}
export function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, setOptimisticTodos] = useOptimistic(
todos,
(currentTodos: Todo[], update: { type: string; payload: any }) => {
switch (update.type) {
case 'toggle':
return currentTodos.map((todo) =>
todo.id === update.payload.id
? { ...todo, completed: !todo.completed }
: todo
)
case 'delete':
return currentTodos.filter(
(todo) => todo.id !== update.payload.id
)
case 'add':
return [
...currentTodos,
{
id: `temp-${Date.now()}`,
title: update.payload.title,
completed: false
}
]
default:
return currentTodos
}
}
)
async function handleToggle(id: string) {
setOptimisticTodos({ type: 'toggle', payload: { id } })
await toggleTodo(id)
}
async function handleDelete(id: string) {
setOptimisticTodos({ type: 'delete', payload: { id } })
await deleteTodo(id)
}
return (
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id} style={{
textDecoration: todo.completed ? 'line-through' : 'none',
opacity: todo.id.startsWith('temp-') ? 0.6 : 1
}}>
<span>{todo.title}</span>
<button onClick={() => handleToggle(todo.id)}>
{todo.completed ? 'Återställ' : 'Markera klar'}
</button>
<button onClick={() => handleDelete(todo.id)}>
Radera
</button>
</li>
))}
</ul>
)
}
Lägg märke till det lilla tricket — temporära element får ett ID som börjar med temp- och visas med lägre opacitet. Det ger användaren en visuell hint om att operationen fortfarande pågår. Och om serveranropet misslyckas? Då rullar React automatiskt tillbaka till det senaste bekräftade tillståndet. Smidigt.
Felhantering
Robust felhantering är avgörande för en bra användarupplevelse. Ingen gillar att sitta och undra om något gick fel. Next.js och React 19 ger oss flera verktyg för att hantera fel i Server Actions på ett elegant sätt.
useActionState för formulärtillstånd
useActionState är en React 19-hook som ger dig tillgång till allt du behöver: laddningstillstånd, returnerade data och fel. Det är det rekommenderade sättet att hantera formulärtillstånd, och det förenklar koden rejält jämfört med äldre mönster:
// app/components/CreateTodoForm.tsx
'use client'
import { useActionState } from 'react'
import { createTodo, type CreateTodoState } from '@/app/actions'
const initialState: CreateTodoState = {
errors: {},
message: ''
}
export function CreateTodoForm() {
const [state, formAction, isPending] = useActionState(
createTodo,
initialState
)
return (
<form action={formAction}>
<div>
<label htmlFor="title">Titel</label>
<input
id="title"
name="title"
type="text"
aria-describedby="title-error"
disabled={isPending}
/>
{state.errors?.title && (
<p id="title-error" role="alert" className="error">
{state.errors.title.join(', ')}
</p>
)}
</div>
<div>
<label htmlFor="priority">Prioritet</label>
<select id="priority" name="priority" disabled={isPending}>
<option value="low">Låg</option>
<option value="medium">Medium</option>
<option value="high">Hög</option>
</select>
{state.errors?.priority && (
<p role="alert" className="error">
{state.errors.priority.join(', ')}
</p>
)}
</div>
<button type="submit" disabled={isPending}>
{isPending ? 'Skapar...' : 'Skapa uppgift'}
</button>
{state.message && (
<p role="status" className={state.success ? 'success' : 'error'}>
{state.message}
</p>
)}
</form>
)
}
useActionState tar emot din Server Action och ett initialt tillstånd, och returnerar tre värden: det aktuella tillståndet (uppdateras varje gång actionen returnerar), en omsluten version av din action att använda i formuläret, och en boolean som anger om actionen pågår. Notera att din Server Action nu tar prevState som första argument — det krävs av useActionState.
Error Boundaries
För oväntade fel som inte fångas av din action kan du använda Next.js error.tsx-filer, som fungerar som Error Boundaries:
// app/todos/error.tsx
'use client'
export default function TodoError({
error,
reset
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div role="alert">
<h2>Något gick fel</h2>
<p>Ett oväntat fel inträffade. Försök igen.</p>
<button onClick={reset}>Försök igen</button>
</div>
)
}
Strukturerade felreturer
Ett mönster jag verkligen rekommenderar är att alltid returnera strukturerade svar från dina Server Actions istället för att kasta fel. Det gör klientkoden mycket enklare att hantera:
'use server'
type ActionResult<T = void> =
| { success: true; data: T }
| { success: false; error: string }
export async function updateProfile(
formData: FormData
): Promise<ActionResult<{ name: string }>> {
try {
const session = await auth()
if (!session) {
return { success: false, error: 'Ej inloggad' }
}
const name = formData.get('name') as string
if (!name || name.length < 2) {
return { success: false, error: 'Namnet måste vara minst 2 tecken' }
}
const updated = await db.user.update({
where: { id: session.user.id },
data: { name }
})
revalidatePath('/profile')
return { success: true, data: { name: updated.name } }
} catch (error) {
return { success: false, error: 'Ett oväntat fel inträffade' }
}
}
Avancerade mönster
Okej, nu börjar det bli riktigt intressant. När din applikation växer kommer du att behöva mer sofistikerade mönster för att hantera återkommande logik som autentisering, validering och loggning.
Komponera actions med middleware-liknande mönster
Du kan skapa återanvändbara wrappers som lägger till gemensam funktionalitet till dina Server Actions. Tänk på det som middleware, fast för actions:
// lib/action-utils.ts
'use server'
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'
type ActionHandler<TInput, TOutput> = (
input: TInput,
context: { userId: string }
) => Promise<TOutput>
export function authenticatedAction<TInput, TOutput>(
handler: ActionHandler<TInput, TOutput>
) {
return async (input: TInput): Promise<TOutput> => {
const session = await auth()
if (!session?.user?.id) {
redirect('/login')
}
return handler(input, { userId: session.user.id })
}
}
// Användning
export const updateBio = authenticatedAction(
async (formData: FormData, { userId }) => {
const bio = formData.get('bio') as string
await db.user.update({
where: { id: userId },
data: { bio }
})
revalidatePath('/profile')
}
)
next-safe-action för typsäkra Server Actions
Biblioteket next-safe-action ger dig ett riktigt elegant ramverk för att definiera typsäkra Server Actions med inbyggd validering, autentisering och felhantering. Det är värt att kolla in om du bygger något större:
// lib/safe-action.ts
import { createSafeActionClient } from 'next-safe-action'
import { auth } from '@/lib/auth'
export const actionClient = createSafeActionClient({
handleServerError(error) {
console.error('Action error:', error.message)
return 'Ett oväntat fel inträffade'
}
})
export const authActionClient = actionClient.use(async ({ next }) => {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Ej autentiserad')
}
return next({
ctx: {
userId: session.user.id,
userRole: session.user.role
}
})
})
// app/actions/todo-actions.ts
'use server'
import { authActionClient } from '@/lib/safe-action'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
const createTodoSchema = z.object({
title: z.string().min(1).max(200),
priority: z.enum(['low', 'medium', 'high'])
})
export const createTodo = authActionClient
.schema(createTodoSchema)
.action(async ({ parsedInput, ctx }) => {
const todo = await db.todo.create({
data: {
...parsedInput,
userId: ctx.userId
}
})
revalidatePath('/todos')
return { todo }
})
// I Client Component
'use client'
import { useAction } from 'next-safe-action/hooks'
import { createTodo } from '@/app/actions/todo-actions'
export function CreateTodoForm() {
const { execute, result, isExecuting } = useAction(createTodo)
return (
<form action={(formData) => {
execute({
title: formData.get('title') as string,
priority: formData.get('priority') as string
})
}}>
<input name="title" />
<select name="priority">
<option value="low">Låg</option>
<option value="medium">Medium</option>
<option value="high">Hög</option>
</select>
<button disabled={isExecuting}>
{isExecuting ? 'Skapar...' : 'Skapa'}
</button>
{result.serverError && <p className="error">{result.serverError}</p>}
{result.validationErrors && (
<p className="error">Kontrollera formulärfälten</p>
)}
</form>
)
}
next-safe-action hanterar automatiskt validering, typinferens, felhantering och middleware-kedjan. I större projekt där konsekvens och typsäkerhet är avgörande blir det nästan oumbärligt.
Säkerhet i produktion
Att köra Server Actions i produktion kräver uppmärksamhet på flera säkerhetsaspekter. Låt oss gå igenom de viktigaste — det här är saker du verkligen inte vill missa.
CSRF-skydd
Server Actions anropas via HTTP POST, vilket gör dem potentiellt sårbara för CSRF-attacker (Cross-Site Request Forgery). Lyckligtvis hanterar Next.js detta på flera sätt:
- SameSite cookies: Next.js sätter
SameSite=laxsom standard på cookies, vilket innebär att cookies inte skickas med vid cross-origin POST-requests. Det ger grundläggande CSRF-skydd. - Origin-header-kontroll: Next.js jämför automatiskt
Origin-headern medHost-headern för att verifiera att anropet kommer från samma domän. - Krypterade action-ID:n: Varje Server Action identifieras av ett krypterat, icke-deterministiskt ID som genereras vid byggtid. ID:na ändras mellan byggen, vilket gör det svårt för angripare att gissa vilka endpoints som finns.
Trots dessa inbyggda skydd bör du implementera extra kontroller för riktigt känsliga operationer:
'use server'
import { headers } from 'next/headers'
export async function sensitiveAction(formData: FormData) {
const headersList = await headers()
const origin = headersList.get('origin')
const host = headersList.get('host')
// Extra Origin-kontroll
if (origin && !origin.includes(host!)) {
throw new Error('Otillåten origin')
}
// Verifiera autentisering
const session = await auth()
if (!session) {
throw new Error('Ej autentiserad')
}
// Utför operationen...
}
Rate limiting
Eftersom Server Actions är publika HTTP-endpoints bör du definitivt implementera rate limiting för att skydda mot missbruk. Här är ett grundläggande exempel:
// lib/rate-limit.ts
import { headers } from 'next/headers'
const rateLimit = new Map<string, { count: number; resetTime: number }>()
export async function checkRateLimit(
limit: number = 10,
windowMs: number = 60000
): Promise<boolean> {
const headersList = await headers()
const forwarded = headersList.get('x-forwarded-for')
const ip = forwarded?.split(',')[0] ?? 'unknown'
const now = Date.now()
const record = rateLimit.get(ip)
if (!record || now > record.resetTime) {
rateLimit.set(ip, { count: 1, resetTime: now + windowMs })
return true
}
if (record.count >= limit) {
return false
}
record.count++
return true
}
// Användning i en Server Action
'use server'
export async function submitComment(formData: FormData) {
const allowed = await checkRateLimit(5, 60000) // 5 per minut
if (!allowed) {
return { error: 'För många förfrågningar. Försök igen om en minut.' }
}
// Fortsätt med att spara kommentaren...
}
En viktig varning: exemplet ovan fungerar bra under utveckling, men i produktion (särskilt i serverlösa miljöer med flera instanser) bör du använda en dedikerad rate limiting-lösning som Upstash Ratelimit eller liknande som bygger på Redis.
Krypterade och icke-deterministiska action-ID:n
Next.js genererar unika, krypterade ID:n för varje Server Action vid byggtid. Dessa ID:n ändras vid varje ny build, vilket innebär att:
- En angripare inte kan förutspå vilka endpoints som finns
- Oanvända Server Actions elimineras automatiskt genom dead code elimination — om ingen komponent refererar till en action inkluderas den inte i produktionsbygget
- Varje action-ID är kopplat till en specifik version av din applikation
Prestandaoptimering
Server Actions erbjuder flera prestandafördelar jämfört med traditionella klient-API-mönster. Låt oss titta på de viktigaste.
Single roundtrip
Med Server Actions sker mutation och revalidering i en enda request-response-cykel. Istället för att klienten först skickar en POST-request till ett API, väntar på svar och sedan gör ytterligare GET-requests för att hämta uppdaterad data, hanterar Next.js allt i ett enda anrop. Servern utför mutationen, ogiltigförklarar cachen och returnerar den uppdaterade sidan — allt på en gång.
Det är en ganska dramatisk förbättring om man tänker efter.
Reducerat klient-JavaScript
Eftersom logiken i Server Actions aldrig skickas till klienten minskar mängden JavaScript som behöver laddas ner. Valideringslogik, databasanrop och annan serverlogik stannar kvar på servern. Det bidrar till snabbare sidladdningar och bättre Core Web Vitals — och vem vill inte ha det?
Streaming
Server Actions fungerar sömlöst med Next.js streaming-arkitektur. Under tiden en action exekveras kan servern streama uppdateringar till klienten, så användaren ser progressiva uppdateringar istället för att vänta på att hela operationen ska bli klar:
// Server Actions i kombination med Suspense och streaming
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { TodoList } from './TodoList'
import { TodoForm } from './TodoForm'
export default function DashboardPage() {
return (
<div>
<h1>Min instrumentpanel</h1>
<TodoForm />
<Suspense fallback={<p>Laddar uppgifter...</p>}>
<TodoList />
</Suspense>
</div>
)
}
Praktiskt projekt: En komplett CRUD-applikation
Nu är det dags att sätta ihop allt vi gått igenom i ett praktiskt projekt. Vi bygger en komplett uppgiftshanterare (todo-app) med full CRUD-funktionalitet, validering, optimistiska uppdateringar och korrekt felhantering. Det blir det bästa sättet att verkligen cementera kunskaperna.
Databasmodell och typer
// types/todo.ts
export type Todo = {
id: string
title: string
description: string | null
completed: boolean
priority: 'low' | 'medium' | 'high'
createdAt: Date
updatedAt: Date
}
// lib/db.ts (förenklad in-memory-databas för exemplet)
import { Todo } from '@/types/todo'
let todos: Todo[] = [
{
id: '1',
title: 'Lär dig Server Actions',
description: 'Gå igenom hela guiden',
completed: false,
priority: 'high',
createdAt: new Date(),
updatedAt: new Date()
}
]
export const db = {
todo: {
findMany: async () => [...todos],
findUnique: async (id: string) =>
todos.find((t) => t.id === id) ?? null,
create: async (data: Omit<Todo, 'id' | 'createdAt' | 'updatedAt'>) => {
const todo: Todo = {
...data,
id: crypto.randomUUID(),
createdAt: new Date(),
updatedAt: new Date()
}
todos.push(todo)
return todo
},
update: async (id: string, data: Partial<Todo>) => {
const index = todos.findIndex((t) => t.id === id)
if (index === -1) throw new Error('Not found')
todos[index] = { ...todos[index], ...data, updatedAt: new Date() }
return todos[index]
},
delete: async (id: string) => {
todos = todos.filter((t) => t.id !== id)
}
}
}
Server Actions
// app/actions/todo-actions.ts
'use server'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
import { db } from '@/lib/db'
// Valideringsscheman
const CreateTodoSchema = z.object({
title: z.string().min(1, 'Titel krävs').max(200, 'Max 200 tecken'),
description: z.string().max(1000).optional().default(''),
priority: z.enum(['low', 'medium', 'high']).default('medium')
})
const UpdateTodoSchema = z.object({
id: z.string().min(1),
title: z.string().min(1, 'Titel krävs').max(200).optional(),
description: z.string().max(1000).optional(),
completed: z.boolean().optional(),
priority: z.enum(['low', 'medium', 'high']).optional()
})
// Typer för action-tillstånd
export type TodoActionState = {
errors?: Record<string, string[]>
message?: string
success?: boolean
}
// CREATE
export async function createTodoAction(
prevState: TodoActionState,
formData: FormData
): Promise<TodoActionState> {
const rawData = {
title: formData.get('title'),
description: formData.get('description'),
priority: formData.get('priority')
}
const validated = CreateTodoSchema.safeParse(rawData)
if (!validated.success) {
return {
errors: validated.error.flatten().fieldErrors,
message: 'Valideringsfel'
}
}
try {
await db.todo.create({
title: validated.data.title,
description: validated.data.description || null,
completed: false,
priority: validated.data.priority
})
revalidatePath('/todos')
return { success: true, message: 'Uppgiften skapades!' }
} catch (error) {
return {
message: 'Kunde inte skapa uppgiften. Försök igen.'
}
}
}
// UPDATE
export async function updateTodoAction(
prevState: TodoActionState,
formData: FormData
): Promise<TodoActionState> {
const rawData = {
id: formData.get('id'),
title: formData.get('title'),
description: formData.get('description'),
priority: formData.get('priority'),
completed: formData.get('completed') === 'true'
}
const validated = UpdateTodoSchema.safeParse(rawData)
if (!validated.success) {
return {
errors: validated.error.flatten().fieldErrors,
message: 'Valideringsfel'
}
}
try {
const { id, ...data } = validated.data
await db.todo.update(id, data)
revalidatePath('/todos')
return { success: true, message: 'Uppgiften uppdaterades!' }
} catch (error) {
return {
message: 'Kunde inte uppdatera uppgiften.'
}
}
}
// DELETE
export async function deleteTodoAction(id: string): Promise<void> {
if (!id) {
throw new Error('ID krävs')
}
await db.todo.delete(id)
revalidatePath('/todos')
}
// TOGGLE COMPLETED
export async function toggleTodoAction(id: string): Promise<void> {
const todo = await db.todo.findUnique(id)
if (!todo) {
throw new Error('Uppgiften hittades inte')
}
await db.todo.update(id, { completed: !todo.completed })
revalidatePath('/todos')
}
Sidan — Server Component
// app/todos/page.tsx
import { db } from '@/lib/db'
import { CreateTodoForm } from './components/CreateTodoForm'
import { TodoList } from './components/TodoList'
export default async function TodosPage() {
const todos = await db.todo.findMany()
return (
<main className="container">
<h1>Uppgiftshanteraren</h1>
<p>Hantera dina dagliga uppgifter med Server Actions.</p>
<section>
<h2>Skapa ny uppgift</h2>
<CreateTodoForm />
</section>
<section>
<h2>Dina uppgifter ({todos.length})</h2>
<TodoList todos={todos} />
</section>
</main>
)
}
CreateTodoForm — Client Component med useActionState
// app/todos/components/CreateTodoForm.tsx
'use client'
import { useActionState, useRef, useEffect } from 'react'
import { createTodoAction, type TodoActionState } from '@/app/actions/todo-actions'
const initialState: TodoActionState = {}
export function CreateTodoForm() {
const [state, formAction, isPending] = useActionState(
createTodoAction,
initialState
)
const formRef = useRef<HTMLFormElement>(null)
// Återställ formuläret vid lyckad skapning
useEffect(() => {
if (state.success) {
formRef.current?.reset()
}
}, [state])
return (
<form ref={formRef} action={formAction}>
<div className="form-group">
<label htmlFor="title">Titel *</label>
<input
id="title"
name="title"
type="text"
placeholder="Vad behöver du göra?"
required
disabled={isPending}
aria-describedby={state.errors?.title ? 'title-error' : undefined}
/>
{state.errors?.title && (
<p id="title-error" className="field-error" role="alert">
{state.errors.title[0]}
</p>
)}
</div>
<div className="form-group">
<label htmlFor="description">Beskrivning</label>
<textarea
id="description"
name="description"
placeholder="Ytterligare detaljer (valfritt)"
rows={3}
disabled={isPending}
/>
</div>
<div className="form-group">
<label htmlFor="priority">Prioritet</label>
<select id="priority" name="priority" disabled={isPending}>
<option value="low">Låg</option>
<option value="medium" selected>Medium</option>
<option value="high">Hög</option>
</select>
</div>
<button type="submit" disabled={isPending}>
{isPending ? 'Skapar uppgift...' : 'Skapa uppgift'}
</button>
{state.message && (
<p
role="status"
className={state.success ? 'success-message' : 'error-message'}
>
{state.message}
</p>
)}
</form>
)
}
TodoList med optimistiska uppdateringar
// app/todos/components/TodoList.tsx
'use client'
import { useOptimistic, useTransition } from 'react'
import { toggleTodoAction, deleteTodoAction } from '@/app/actions/todo-actions'
import type { Todo } from '@/types/todo'
type OptimisticAction =
| { type: 'toggle'; id: string }
| { type: 'delete'; id: string }
export function TodoList({ todos }: { todos: Todo[] }) {
const [isPending, startTransition] = useTransition()
const [optimisticTodos, applyOptimistic] = useOptimistic(
todos,
(currentTodos: Todo[], action: OptimisticAction) => {
switch (action.type) {
case 'toggle':
return currentTodos.map((todo) =>
todo.id === action.id
? { ...todo, completed: !todo.completed }
: todo
)
case 'delete':
return currentTodos.filter((todo) => todo.id !== action.id)
default:
return currentTodos
}
}
)
function handleToggle(id: string) {
startTransition(async () => {
applyOptimistic({ type: 'toggle', id })
await toggleTodoAction(id)
})
}
function handleDelete(id: string) {
startTransition(async () => {
applyOptimistic({ type: 'delete', id })
await deleteTodoAction(id)
})
}
if (optimisticTodos.length === 0) {
return <p className="empty-state">Inga uppgifter ännu. Skapa din första!</p>
}
const priorityLabels = {
low: 'Låg',
medium: 'Medium',
high: 'Hög'
}
return (
<ul className="todo-list">
{optimisticTodos.map((todo) => (
<li
key={todo.id}
className={`todo-item ${todo.completed ? 'completed' : ''}`}
>
<div className="todo-content">
<button
className="toggle-btn"
onClick={() => handleToggle(todo.id)}
aria-label={
todo.completed
? `Markera "${todo.title}" som ej klar`
: `Markera "${todo.title}" som klar`
}
>
{todo.completed ? '✓' : '○'}
</button>
<div>
<h3 className={todo.completed ? 'line-through' : ''}>
{todo.title}
</h3>
{todo.description && (
<p className="description">{todo.description}</p>
)}
<span className={`priority priority-${todo.priority}`}>
{priorityLabels[todo.priority]}
</span>
</div>
</div>
<button
className="delete-btn"
onClick={() => handleDelete(todo.id)}
aria-label={`Radera "${todo.title}"`}
>
Radera
</button>
</li>
))}
</ul>
)
}
EditTodoForm — uppdatering med bind()
// app/todos/components/EditTodoForm.tsx
'use client'
import { useActionState } from 'react'
import { updateTodoAction, type TodoActionState } from '@/app/actions/todo-actions'
import type { Todo } from '@/types/todo'
export function EditTodoForm({ todo }: { todo: Todo }) {
const initialState: TodoActionState = {}
const [state, formAction, isPending] = useActionState(
updateTodoAction,
initialState
)
return (
<form action={formAction}>
<input type="hidden" name="id" value={todo.id} />
<div className="form-group">
<label htmlFor={`title-${todo.id}`}>Titel</label>
<input
id={`title-${todo.id}`}
name="title"
defaultValue={todo.title}
disabled={isPending}
/>
</div>
<div className="form-group">
<label htmlFor={`desc-${todo.id}`}>Beskrivning</label>
<textarea
id={`desc-${todo.id}`}
name="description"
defaultValue={todo.description ?? ''}
disabled={isPending}
/>
</div>
<div className="form-group">
<label htmlFor={`priority-${todo.id}`}>Prioritet</label>
<select
id={`priority-${todo.id}`}
name="priority"
defaultValue={todo.priority}
disabled={isPending}
>
<option value="low">Låg</option>
<option value="medium">Medium</option>
<option value="high">Hög</option>
</select>
</div>
<div className="form-group">
<label>
<input
type="checkbox"
name="completed"
value="true"
defaultChecked={todo.completed}
/>
Markera som klar
</label>
</div>
<button type="submit" disabled={isPending}>
{isPending ? 'Sparar...' : 'Spara ändringar'}
</button>
{state.message && (
<p className={state.success ? 'success-message' : 'error-message'}>
{state.message}
</p>
)}
</form>
)
}
Med det här projektet har vi täckt alla grundläggande CRUD-operationer: Create med formulärvalidering och useActionState, Read via en Server Component som hämtar data direkt, Update med dolda fält och bind()-mönstret, samt Delete med optimistiska uppdateringar. Allt bygger på samma principer vi gått igenom tidigare.
Sammanfattning och bästa praxis
Server Actions representerar en betydande förenkling av hur vi bygger fullstack-applikationer med Next.js. De eliminerar boilerplate, möjliggör progressive enhancement och ger oss ett typsäkert sätt att hantera server-klient-kommunikation. Här kommer en sammanställning av de viktigaste bästa praxis.
Validering och säkerhet
- Validera alltid indata på servern — klientvalidering är för användarupplevelsen, servervalidering är för säkerheten. Använd Zod eller liknande för schemavalidering.
- Kontrollera autentisering och auktorisering i varje action — kom ihåg att de är publika HTTP-endpoints. Verifiera alltid att användaren har rätt att utföra operationen.
- Implementera rate limiting — särskilt för actions som skapar data eller skickar meddelanden. Använd Upstash Ratelimit eller liknande i produktion.
- Lita på Next.js inbyggda CSRF-skydd — men lägg till extra kontroller för riktigt känsliga operationer.
Arkitektur och organisation
- Separera actions i egna filer — placera dina Server Actions i dedikerade filer (t.ex.
app/actions/) med'use server'högst upp. Det gör koden överskådlig och lättare att underhålla. - Använd strukturerade returvärden — returnera objekt med
success,dataocherror-fält istället för att kasta undantag. Det ger bättre kontroll i klientkoden. - Överväg next-safe-action — för större projekt ger det en konsekvent struktur med inbyggd validering, middleware och typinferens.
- Komponera actions med wrappers — skapa återanvändbara högre ordningens funktioner för gemensam logik som autentisering och loggning.
Prestanda
- Var strategisk med revalidering — använd
revalidateTagför granulär cache-ogiltigförklaring istället för att ogiltigförklara hela sidor medrevalidatePathnär det är möjligt. - Utnyttja optimistiska uppdateringar — använd
useOptimisticför omedelbar feedback. Det förbättrar den upplevda prestandan dramatiskt. - Dra nytta av single roundtrip — Server Actions utför mutation och revalidering i ett enda anrop. Undvik extra fetch-anrop efter att en action slutförts.
- Lita på dead code elimination — oanvända Server Actions inkluderas inte i produktionsbygget. Definiera fritt actions som bara används i vissa kodvägar.
Användarupplevelse
- Använd useActionState för formulärtillstånd — det ger dig laddningstillstånd, felmeddelanden och bekräftelse, allt i en enkel hook.
- Implementera progressive enhancement — formulär med Server Actions fungerar utan JavaScript. Testa gärna med JavaScript inaktiverat för att säkerställa grundfunktionaliteten.
- Ge tydlig feedback — visa laddningsindikatorer med
isPending, felmeddelanden medaria-describedbyoch bekräftelser vid lyckade operationer. - Hantera fel med grace — fånga alltid potentiella fel och returnera användarvänliga meddelanden. Logga detaljerade fel på servern men visa aldrig interna felmeddelanden för användaren.
Avslutande ord
Server Actions i Next.js 15+ med React 19 har mognat till ett kraftfullt och produktionsredo verktyg. De förändrar fundamentalt hur vi tänker kring mutationer i webbapplikationer — från manuellt skapade API-lager till direkt, typsäker kommunikation mellan komponenter och serverlogik.
Nyckeln till framgång ligger i att alltid tänka på Server Actions som publika endpoints som kräver validering och auktorisering, samtidigt som du utnyttjar deras elegans för att förenkla din kodbas och förbättra användarupplevelsen. Kombinera dem med React 19:s hooks, Zod för validering och verktyg som next-safe-action — och du har en modern, robust stack som verkligen är redo för produktion.