Next.js Server Actions i Praksis: Formularer, Validering og Optimistiske Opdateringer

En praktisk guide til Next.js Server Actions — fra grundlæggende formularhåndtering og Zod-validering til optimistiske opdateringer, cache-invalidering og sikkerhedsmønstre i App Routeren.

Introduktion: Hvorfor Server Actions er en Game Changer

Hvis du har fulgt med i vores tidligere artikler om databaseintegration med Drizzle ORM og autentificering med Auth.js v5, har du allerede et solidt fundament for full-stack udvikling med Next.js App Router. Men der mangler stadig en afgørende brik i puslespillet: hvordan håndterer du rent faktisk datamutationer? Altså alt det der sker, når en bruger indsender en formular, opdaterer sin profil eller sletter et indlæg.

Svaret er Server Actions.

Server Actions blev stabile i Next.js 14 og er hurtigt blevet det foretrukne mønster for server-side mutationer i App Routeren. I stedet for at oprette separate API-ruter og manuelt håndtere fetch-kald, kan du nu definere asynkrone funktioner der kører direkte på serveren — og kalde dem fra dine React-komponenter, som om de var helt almindelige funktioner. Ærligt talt, det ændrer fundamentalt hvordan vi tænker full-stack React.

I denne artikel dykker vi ned i alt fra grundlæggende formularopsætning til avanceret validering med Zod, optimistiske opdateringer med useOptimistic, cache-invalidering, filupload og sikkerhedsmønstre. Vi bygger naturligvis videre på den stack vi har etableret i de tidligere artikler, med Drizzle ORM og Auth.js v5.

Grundlæggende Server Actions

Hvad er en Server Action?

En Server Action er kort sagt en asynkron funktion, der kører på serveren. Du markerer den med "use server"-direktivet — enten på funktionsniveau (inline i en Server Component) eller på filniveau (i en dedikeret fil). Når en Server Action aktiveres fra klienten, sender React automatisk et POST-request til serveren, udfører funktionen og returnerer resultatet.

Det vigtigste at forstå er: Server Actions er ikke til datahentning. De bruger POST-requests og kan ikke caches. Brug Server Components til datahentning og Server Actions til mutationer.

Inline Server Actions

Den simpleste måde at definere en Server Action er inline i en Server Component:

// app/posts/page.tsx
import { db } from "@/db";
import { posts } from "@/db/schema";
import { revalidatePath } from "next/cache";

export default async function PostsPage() {
  const allPosts = await db.select().from(posts);

  async function createPost(formData: FormData) {
    "use server";

    const title = formData.get("title") as string;
    const content = formData.get("content") as string;

    await db.insert(posts).values({
      title,
      content,
      published: false,
      authorId: 1,
    });

    revalidatePath("/posts");
  }

  return (
    <div>
      <h1>Indlæg</h1>
      <form action={createPost}>
        <input name="title" placeholder="Titel" required />
        <textarea name="content" placeholder="Indhold" required />
        <button type="submit">Opret indlæg</button>
      </form>
      <ul>
        {allPosts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

Læg mærke til det elegante her: formularen bruger action-proppen til at kalde Server Action direkte. Det understøtter progressiv forbedring, så formularen faktisk fungerer selv uden JavaScript. Når handlingen er fuldført, kalder vi revalidatePath for at invalidere cachen og vise de opdaterede data.

Dedikerede Action-filer

Til større projekter anbefaler jeg at organisere dine Server Actions i dedikerede filer. Opret en actions/-mappe og marker hele filen med "use server":

// actions/posts.ts
"use server";

import { db } from "@/db";
import { posts } from "@/db/schema";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";

export async function createPost(formData: FormData) {
  const title = formData.get("title") as string;
  const content = formData.get("content") as string;

  await db.insert(posts).values({
    title,
    content,
    published: false,
    authorId: 1,
  });

  revalidatePath("/posts");
  redirect("/posts");
}

export async function deletePost(postId: number) {
  await db.delete(posts).where(eq(posts.id, postId));
  revalidatePath("/posts");
}

export async function togglePublish(postId: number, published: boolean) {
  await db
    .update(posts)
    .set({ published: !published, updatedAt: new Date() })
    .where(eq(posts.id, postId));
  revalidatePath("/posts");
}

Denne tilgang giver markant bedre separation of concerns og gør det nemmere at genbruge actions på tværs af komponenter. En lille, men vigtig detalje: redirect skal kaldes efter revalidatePath — ellers vil den nye side ikke vise de opdaterede data.

Formularhåndtering med useActionState

Fra useFormState til useActionState

React 19 introducerede useActionState som afløser for det tidligere useFormState fra react-dom. Hooket bor nu i selve react-pakken og har fået en renere API. Det er det anbefalede mønster for formularhåndtering i Next.js 15+.

useActionState håndterer automatisk pending-tilstand og returnerer den seneste state fra din Server Action. Lad os se på grundstrukturen:

// components/CreatePostForm.tsx
"use client";

import { useActionState } from "react";
import { createPost } from "@/actions/posts";

const initialState = {
  message: "",
  errors: {} as Record<string, string[]>,
};

export function CreatePostForm() {
  const [state, formAction, isPending] = useActionState(
    createPost,
    initialState
  );

  return (
    <form action={formAction}>
      <div>
        <label htmlFor="title">Titel</label>
        <input
          id="title"
          name="title"
          disabled={isPending}
        />
        {state.errors?.title && (
          <p className="error">{state.errors.title[0]}</p>
        )}
      </div>

      <div>
        <label htmlFor="content">Indhold</label>
        <textarea
          id="content"
          name="content"
          disabled={isPending}
        />
        {state.errors?.content && (
          <p className="error">{state.errors.content[0]}</p>
        )}
      </div>

      <button type="submit" disabled={isPending}>
        {isPending ? "Opretter..." : "Opret indlæg"}
      </button>

      {state.message && <p>{state.message}</p>}
    </form>
  );
}

Det smarte er, at useActionState giver dig tre ting: den aktuelle state (som din Server Action returnerer), en formAction-funktion til formularens action-prop, og en isPending-boolean til loading-tilstand. Ret elegant, synes jeg.

useFormStatus til Submit-knappen

Hvis du vil isolere loading-tilstanden i en separat komponent — for eksempel en genanvendelig submit-knap — kan du bruge useFormStatus:

// components/SubmitButton.tsx
"use client";

import { useFormStatus } from "react-dom";

export function SubmitButton({ label = "Gem" }: { label?: string }) {
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? "Gemmer..." : label}
    </button>
  );
}

Én ting der er værd at huske: useFormStatus skal bruges i en komponent, der er et barn af <form>-elementet. Den kan ikke bruges i den samme komponent, der renderer formularen.

Validering med Zod

Server-side validering

Validering på serveren er obligatorisk. Punkt. Klient-side validering er kun til brugeroplevelsen — en ondsindet bruger kan altid omgå den. Zod er det oplagte valg til server-side validering i TypeScript-projekter, fordi det giver fuld type-safety og beskrivende fejlmeddelelser.

Først definerer vi et valideringsskema i en separat fil, så det kan deles mellem server og klient:

// lib/validations/post.ts
import { z } from "zod";

export const createPostSchema = z.object({
  title: z
    .string()
    .min(3, "Titlen skal være mindst 3 tegn")
    .max(255, "Titlen må højst være 255 tegn"),
  content: z
    .string()
    .min(10, "Indholdet skal være mindst 10 tegn")
    .max(50000, "Indholdet må højst være 50.000 tegn"),
});

export type CreatePostInput = z.infer<typeof createPostSchema>;

Derefter opdaterer vi vores Server Action til at bruge skemaet:

// actions/posts.ts
"use server";

import { db } from "@/db";
import { posts } from "@/db/schema";
import { createPostSchema } from "@/lib/validations/post";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";

export type CreatePostState = {
  message: string;
  errors?: Record<string, string[]>;
};

export async function createPost(
  prevState: CreatePostState,
  formData: FormData
): Promise<CreatePostState> {
  const rawData = {
    title: formData.get("title"),
    content: formData.get("content"),
  };

  const validatedFields = createPostSchema.safeParse(rawData);

  if (!validatedFields.success) {
    return {
      message: "Validering fejlede. Ret venligst fejlene nedenfor.",
      errors: validatedFields.error.flatten().fieldErrors,
    };
  }

  try {
    await db.insert(posts).values({
      title: validatedFields.data.title,
      content: validatedFields.data.content,
      published: false,
      authorId: 1,
    });
  } catch (error) {
    return {
      message: "Databasefejl: Kunne ikke oprette indlægget.",
    };
  }

  revalidatePath("/posts");
  redirect("/posts");
}

Bemærk den vigtige ændring i funktionssignaturen: når du bruger useActionState, modtager Server Action nu en prevState-parameter som første argument, og FormData rykker til anden position. Denne signatur er påkrævet for at React kan spore tilstandsovergange korrekt.

Delt validering mellem klient og server

En af de virkelig store fordele ved Zod er, at du kan genbruge det samme skema på både klient og server. Ved at definere skemaet i en separat fil (som vi gjorde ovenfor) kan du importere det i din klientkomponent til øjeblikkelig validering, mens serveren stadig validerer uafhængigt:

// components/CreatePostForm.tsx
"use client";

import { useActionState, useRef } from "react";
import { createPost } from "@/actions/posts";
import { createPostSchema } from "@/lib/validations/post";

export function CreatePostForm() {
  const [state, formAction, isPending] = useActionState(createPost, {
    message: "",
    errors: {},
  });
  const formRef = useRef<HTMLFormElement>(null);

  function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    const formData = new FormData(e.currentTarget);
    const data = Object.fromEntries(formData);
    const result = createPostSchema.safeParse(data);

    if (!result.success) {
      e.preventDefault();
      // Vis klient-side fejl øjeblikkeligt
      console.log(result.error.flatten().fieldErrors);
      return;
    }
    // Lad formularen submitte normalt til Server Action
  }

  return (
    <form ref={formRef} action={formAction} onSubmit={handleSubmit}>
      {/* Formularfelter som før */}
    </form>
  );
}

Denne tilgang giver dig det bedste fra begge verdener: hurtig klient-side feedback og sikker server-side validering. Det er en af de ting, der bare føles rigtigt, når det er sat op.

Optimistiske Opdateringer med useOptimistic

Hvad er optimistiske opdateringer?

Optimistiske opdateringer er et UI-mønster, hvor interfacet opdateres øjeblikkeligt — inden serveren overhovedet har bekræftet operationen. Tænk på det som en "vi antager det går godt"-tilgang. Når en bruger klikker "slet" på et indlæg, forsvinder det straks fra listen, selv om databasekaldet stadig kører i baggrunden. Hvis noget går galt, ruller UI'en automatisk tilbage.

React's useOptimistic-hook gør dette elegant og deklarativt.

Praktisk eksempel: Todo-liste med optimistiske opdateringer

Så lad os bygge en komplet todo-liste der bruger optimistiske opdateringer. Det er en af de use cases, hvor forskellen i brugeroplevelsen virkelig kan mærkes:

// actions/todos.ts
"use server";

import { db } from "@/db";
import { todos } from "@/db/schema";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";

export async function addTodo(formData: FormData) {
  const title = formData.get("title") as string;

  await db.insert(todos).values({ title, completed: false });
  revalidatePath("/todos");
}

export async function toggleTodo(id: number, completed: boolean) {
  await db
    .update(todos)
    .set({ completed: !completed })
    .where(eq(todos.id, id));
  revalidatePath("/todos");
}

export async function deleteTodo(id: number) {
  await db.delete(todos).where(eq(todos.id, id));
  revalidatePath("/todos");
}

Nu opretter vi klientkomponenten med optimistiske opdateringer:

// components/TodoList.tsx
"use client";

import { useOptimistic, useRef } from "react";
import { addTodo, toggleTodo, deleteTodo } from "@/actions/todos";

type Todo = {
  id: number;
  title: string;
  completed: boolean;
};

type OptimisticAction =
  | { type: "add"; todo: Todo }
  | { type: "toggle"; id: number }
  | { type: "delete"; id: number };

export function TodoList({ todos }: { todos: Todo[] }) {
  const formRef = useRef<HTMLFormElement>(null);

  const [optimisticTodos, dispatch] = useOptimistic(
    todos,
    (state: Todo[], action: OptimisticAction) => {
      switch (action.type) {
        case "add":
          return [...state, action.todo];
        case "toggle":
          return state.map((t) =>
            t.id === action.id
              ? { ...t, completed: !t.completed }
              : t
          );
        case "delete":
          return state.filter((t) => t.id !== action.id);
      }
    }
  );

  async function handleAdd(formData: FormData) {
    const title = formData.get("title") as string;
    if (!title.trim()) return;

    const tempTodo: Todo = {
      id: -Date.now(),
      title,
      completed: false,
    };

    dispatch({ type: "add", todo: tempTodo });
    formRef.current?.reset();
    await addTodo(formData);
  }

  async function handleToggle(id: number, completed: boolean) {
    dispatch({ type: "toggle", id });
    await toggleTodo(id, completed);
  }

  async function handleDelete(id: number) {
    dispatch({ type: "delete", id });
    await deleteTodo(id);
  }

  return (
    <div>
      <form ref={formRef} action={handleAdd}>
        <input name="title" placeholder="Ny opgave..." />
        <button type="submit">Tilføj</button>
      </form>

      <ul>
        {optimisticTodos.map((todo) => (
          <li key={todo.id}>
            <button onClick={() => handleToggle(todo.id, todo.completed)}>
              {todo.completed ? "✓" : "○"}
            </button>
            <span style={{
              textDecoration: todo.completed ? "line-through" : "none"
            }}>
              {todo.title}
            </span>
            <button onClick={() => handleDelete(todo.id)}>
              Slet
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

Nøglen er useOptimistic-hooket, der tager to argumenter: den aktuelle server-state (vores todos-prop) og en reducer-funktion der beregner den optimistiske tilstand. Når serveren returnerer ny data via revalidatePath, synkroniseres UI'en automatisk med den faktiske server-tilstand. Det er simpelthen en fantastisk developer experience.

Cache-invalidering og Revalidering

revalidatePath vs. revalidateTag

Når du har udført en mutation, skal du fortælle Next.js at den cachede data er forældet. Der er to primære funktioner til dette:

  • revalidatePath: Invaliderer en specifik side eller layout-sti. Brug denne når du vil opdatere alle data på en bestemt side.
  • revalidateTag: Markerer data med bestemte tags som forældede — på tværs af alle sider der bruger disse tags. Giver mere granulær kontrol.
// Invalidér hele /posts-siden
revalidatePath("/posts");

// Invalidér et specifikt indlæg
revalidatePath("/posts/123");

// Invalidér et layout (og alle børnesider)
revalidatePath("/dashboard", "layout");

// Invalidér alle data tagget med "posts"
revalidateTag("posts");

updateTag — den nye standard for Server Actions

I de seneste Next.js-versioner er updateTag blevet introduceret specifikt til brug i Server Actions. Forskellen fra revalidateTag er at updateTag øjeblikkeligt udløber cache-indgangen, hvilket er ideelt til "read-your-own-writes"-scenarier, hvor brugeren skal se sine egne ændringer med det samme:

// actions/posts.ts
"use server";

import { updateTag } from "next/cache";

export async function updatePost(id: number, formData: FormData) {
  // Opdatér i databasen...

  // Øjeblikkelig cache-udløb for denne brugers visning
  updateTag("posts");
  updateTag(`post-${id}`);
}

Brug af cacheTag med use cache-direktivet

Next.js har også introduceret cacheTag-funktionen, der fungerer sammen med det nye use cache-direktiv. Det giver dig mulighed for at tagge cachede data direkte i dine Server Components:

// app/posts/page.tsx
import { cacheTag } from "next/cache";
import { db } from "@/db";
import { posts } from "@/db/schema";

export default async function PostsPage() {
  "use cache";
  cacheTag("posts");

  const allPosts = await db.select().from(posts);

  return (
    <ul>
      {allPosts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

Når du så kalder updateTag("posts") i en Server Action, invalideres netop denne cache-indgang. Simpelt og effektivt.

Filupload med Server Actions

Grundlæggende filupload

Server Actions understøtter FormData, hvilket faktisk gør filupload overraskende simpelt. Her er et grundlæggende eksempel:

// actions/upload.ts
"use server";

import { writeFile, mkdir } from "fs/promises";
import { join } from "path";
import { revalidatePath } from "next/cache";

const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];

export async function uploadImage(formData: FormData) {
  const file = formData.get("file") as File;

  if (!file || file.size === 0) {
    return { error: "Ingen fil valgt" };
  }

  if (file.size > MAX_FILE_SIZE) {
    return { error: "Filen er for stor. Maks 5 MB." };
  }

  if (!ALLOWED_TYPES.includes(file.type)) {
    return { error: "Ugyldig filtype. Kun JPEG, PNG og WebP." };
  }

  const bytes = await file.arrayBuffer();
  const buffer = new Uint8Array(bytes);

  const uploadDir = join(process.cwd(), "public", "uploads");
  await mkdir(uploadDir, { recursive: true });

  const uniqueName = `${Date.now()}-${file.name}`;
  const filePath = join(uploadDir, uniqueName);

  await writeFile(filePath, buffer);

  revalidatePath("/gallery");
  return { success: true, path: `/uploads/${uniqueName}` };
}

I produktion vil du typisk uploade til en cloud-storage-tjeneste som AWS S3 eller Cloudflare R2 i stedet for det lokale filsystem. Men mønsteret er det samme: modtag filen via FormData, validér den grundigt, og gem den.

Filupload-komponent med preview

// components/ImageUpload.tsx
"use client";

import { useActionState, useState } from "react";
import { uploadImage } from "@/actions/upload";

export function ImageUpload() {
  const [preview, setPreview] = useState<string | null>(null);
  const [state, formAction, isPending] = useActionState(
    async (_prev: any, formData: FormData) => {
      return await uploadImage(formData);
    },
    null
  );

  function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0];
    if (file) {
      const url = URL.createObjectURL(file);
      setPreview(url);
    }
  }

  return (
    <form action={formAction}>
      <input
        type="file"
        name="file"
        accept="image/jpeg,image/png,image/webp"
        onChange={handleFileChange}
      />

      {preview && (
        <img src={preview} alt="Preview" width={200} />
      )}

      <button type="submit" disabled={isPending}>
        {isPending ? "Uploader..." : "Upload billede"}
      </button>

      {state?.error && <p className="error">{state.error}</p>}
      {state?.success && <p>Billede uploadet!</p>}
    </form>
  );
}

Type-sikre Server Actions med next-safe-action

Hvorfor next-safe-action?

Efterhånden som din applikation vokser, vil du hurtigt opdage at du gentager den samme boilerplate i hver eneste Server Action: validering, autentificering, fejlhåndtering, logging. Det bliver trættende. next-safe-action løser dette med et kraftfuldt middleware-system, der minder en del om tRPC.

Installer biblioteket:

npm install next-safe-action zod

Opret en Action Client

Start med at definere en base action client med fælles middleware:

// lib/safe-action.ts
import { createSafeActionClient } from "next-safe-action";
import { auth } from "@/auth";

// Base client med logging
export const actionClient = createSafeActionClient({
  handleServerError(e) {
    console.error("Server Action fejl:", e.message);
    return "Der opstod en uventet fejl. Prøv igen senere.";
  },
});

// Autentificeret client
export const authActionClient = actionClient.use(async ({ next }) => {
  const session = await auth();

  if (!session?.user) {
    throw new Error("Du skal være logget ind.");
  }

  return next({
    ctx: {
      userId: session.user.id,
      userRole: session.user.role,
    },
  });
});

// Admin-only client
export const adminActionClient = authActionClient.use(
  async ({ ctx, next }) => {
    if (ctx.userRole !== "admin") {
      throw new Error("Kun administratorer har adgang.");
    }
    return next({ ctx });
  }
);

Nu kan du definere type-sikre actions med validering og autentificering indbygget fra starten:

// actions/posts.ts
"use server";

import { authActionClient } from "@/lib/safe-action";
import { createPostSchema } from "@/lib/validations/post";
import { db } from "@/db";
import { posts } from "@/db/schema";
import { revalidatePath } from "next/cache";
import { z } from "zod";

export const createPost = authActionClient
  .schema(createPostSchema)
  .action(async ({ parsedInput, ctx }) => {
    const post = await db
      .insert(posts)
      .values({
        title: parsedInput.title,
        content: parsedInput.content,
        authorId: parseInt(ctx.userId),
        published: false,
      })
      .returning();

    revalidatePath("/posts");
    return { post: post[0] };
  });

export const deletePost = authActionClient
  .schema(z.object({ postId: z.number() }))
  .action(async ({ parsedInput, ctx }) => {
    // Tjek at brugeren ejer indlægget
    const existingPost = await db.query.posts.findFirst({
      where: eq(posts.id, parsedInput.postId),
    });

    if (!existingPost || existingPost.authorId !== parseInt(ctx.userId)) {
      throw new Error("Du har ikke adgang til at slette dette indlæg.");
    }

    await db.delete(posts).where(eq(posts.id, parsedInput.postId));
    revalidatePath("/posts");
    return { success: true };
  });

Middleware-kæden sikrer automatisk at brugeren er logget ind, input er valideret, og fejl håndteres konsistent. Ingen gentagen kode i hver action. Det er en kæmpe forbedring for vedligeholdelsen af din kodebase.

Sikkerhedsmønstre for Server Actions

1. Validér altid på serveren

Jeg kan ikke understrege det nok: klient-side validering er kun til UX. En ondsindet bruger kan sende vilkårlige POST-requests direkte til dine Server Actions. Serveren skal altid validere uafhængigt af hvad klienten gør:

// Forkert — stoler på klientdata
export async function updateProfile(formData: FormData) {
  "use server";
  const name = formData.get("name") as string;
  await db.update(users).set({ name }); // Ingen validering!
}

// Korrekt — validerer på serveren
export async function updateProfile(formData: FormData) {
  "use server";
  const schema = z.object({
    name: z.string().min(2).max(100),
  });

  const result = schema.safeParse({
    name: formData.get("name"),
  });

  if (!result.success) {
    return { errors: result.error.flatten().fieldErrors };
  }

  await db.update(users).set({ name: result.data.name });
}

2. Autentificering og autorisation

Hver Server Action der ændrer data, skal tjekke to ting: (1) er brugeren logget ind? og (2) har brugeren rettigheder til denne specifikke operation? Det er to helt separate ting, og det er en fejl mange begår at blande dem sammen:

export async function deleteComment(commentId: number) {
  "use server";

  // 1. Autentificering
  const session = await auth();
  if (!session?.user) {
    throw new Error("Ikke logget ind");
  }

  // 2. Autorisation
  const comment = await db.query.comments.findFirst({
    where: eq(comments.id, commentId),
  });

  if (!comment) {
    throw new Error("Kommentar ikke fundet");
  }

  if (comment.userId !== session.user.id && session.user.role !== "admin") {
    throw new Error("Ingen adgang");
  }

  // 3. Udfør handlingen
  await db.delete(comments).where(eq(comments.id, commentId));
  revalidatePath("/posts");
}

3. Rate limiting

Her er noget mange glemmer: Server Actions er offentligt tilgængelige endpoints. Uden rate limiting kan en angriber overbelaste din server eller database. Du kan implementere rate limiting med @upstash/ratelimit:

// lib/rate-limit.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

export const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, "60 s"), // 10 requests per minut
});

// Brug i en Server Action
export async function createPost(formData: FormData) {
  "use server";

  const session = await auth();
  if (!session?.user) throw new Error("Ikke logget ind");

  const { success } = await ratelimit.limit(session.user.id);
  if (!success) {
    return { error: "For mange forespørgsler. Vent venligst." };
  }

  // Fortsæt med at oprette indlægget...
}

4. Brug server-only pakken

For at sikre at dine server-hjælpefunktioner aldrig ved et uheld lækker til klienten, kan du bruge server-only-pakken:

npm install server-only
// lib/db-helpers.ts
import "server-only";

export async function getPostById(id: number) {
  // Denne funktion kan aldrig importeres i en Client Component
  return db.query.posts.findFirst({
    where: eq(posts.id, id),
  });
}

Fejlhåndtering i Server Actions

Struktureret fejlhåndtering

En god praksis er at definere en konsistent returtype for alle dine Server Actions. Det gør det meget nemmere at håndtere fejl i klientkoden:

// lib/action-types.ts
export type ActionResult<T = void> =
  | { success: true; data: T }
  | { success: false; error: string; fieldErrors?: Record<string, string[]> };
// actions/posts.ts
"use server";

import { ActionResult } from "@/lib/action-types";
import { Post } from "@/db/schema";

export async function createPost(
  prevState: ActionResult<Post> | null,
  formData: FormData
): Promise<ActionResult<Post>> {
  const validatedFields = createPostSchema.safeParse({
    title: formData.get("title"),
    content: formData.get("content"),
  });

  if (!validatedFields.success) {
    return {
      success: false,
      error: "Validering fejlede",
      fieldErrors: validatedFields.error.flatten().fieldErrors,
    };
  }

  try {
    const [post] = await db
      .insert(posts)
      .values(validatedFields.data)
      .returning();

    revalidatePath("/posts");
    return { success: true, data: post };
  } catch (error) {
    if (error instanceof Error && error.message.includes("unique")) {
      return {
        success: false,
        error: "Et indlæg med denne titel findes allerede.",
      };
    }
    return {
      success: false,
      error: "Der opstod en uventet fejl.",
    };
  }
}

Fejlhåndtering i klienten

// components/CreatePostForm.tsx
"use client";

import { useActionState } from "react";
import { createPost } from "@/actions/posts";
import type { ActionResult } from "@/lib/action-types";
import type { Post } from "@/db/schema";

export function CreatePostForm() {
  const [state, formAction, isPending] = useActionState(createPost, null);

  return (
    <form action={formAction}>
      <input name="title" placeholder="Titel" />
      {!state?.success && state?.fieldErrors?.title && (
        <p className="text-red-500">{state.fieldErrors.title[0]}</p>
      )}

      <textarea name="content" placeholder="Indhold" />
      {!state?.success && state?.fieldErrors?.content && (
        <p className="text-red-500">{state.fieldErrors.content[0]}</p>
      )}

      <button type="submit" disabled={isPending}>
        {isPending ? "Gemmer..." : "Opret indlæg"}
      </button>

      {state?.success && (
        <p className="text-green-500">Indlæg oprettet!</p>
      )}
      {state && !state.success && (
        <p className="text-red-500">{state.error}</p>
      )}
    </form>
  );
}

Avancerede Mønstre

Server Actions med bind

Nogle gange har du brug for at sende ekstra data til en Server Action udover FormData. I stedet for skjulte formularfelter (som kan manipuleres i DOM'en) kan du bruge .bind():

// Server Action
export async function updatePost(postId: number, formData: FormData) {
  "use server";
  // postId er type-sikkert og kan ikke manipuleres i DOM
  const title = formData.get("title") as string;
  await db.update(posts).set({ title }).where(eq(posts.id, postId));
  revalidatePath("/posts");
}

// I komponenten
export function EditPostForm({ post }: { post: Post }) {
  const updatePostWithId = updatePost.bind(null, post.id);

  return (
    <form action={updatePostWithId}>
      <input name="title" defaultValue={post.title} />
      <button type="submit">Opdatér</button>
    </form>
  );
}

Kald Server Actions uden formularer

Server Actions kan også bruges fra event handlers, useEffect og tredjepartsbiblioteker. Men husk at wrappe dem i en transition for den bedste brugeroplevelse:

"use client";

import { useTransition } from "react";
import { togglePublish } from "@/actions/posts";

export function PublishToggle({ postId, published }: {
  postId: number;
  published: boolean;
}) {
  const [isPending, startTransition] = useTransition();

  return (
    <button
      disabled={isPending}
      onClick={() => {
        startTransition(async () => {
          await togglePublish(postId, published);
        });
      }}
    >
      {isPending
        ? "Opdaterer..."
        : published
          ? "Afpublicér"
          : "Publicér"}
    </button>
  );
}

Best Practices og Opsummering

Lad os samle op på de vigtigste tommelfingerregler for Server Actions i Next.js:

  • Brug Server Actions til mutationer, ikke datahentning. De sender POST-requests og kan ikke caches. Brug Server Components til at hente data.
  • Validér altid på serveren med Zod. Klient-side validering er en bonus for brugeroplevelsen, men serveren skal validere uafhængigt. Altid.
  • Adskil autentificering og autorisation. At være logget ind er ikke det samme som at have rettigheder. Tjek begge dele i hver Server Action.
  • Brug useActionState til formularer. Det er det officielle React 19-mønster og giver dig automatisk pending-tilstand og fejlhåndtering.
  • Organisér actions i dedikerede filer for større projekter. Brug "use server" på filniveau.
  • Kald revalidatePath/revalidateTag/updateTag efter mutationer for at holde UI'en synkroniseret med databasen.
  • Brug useOptimistic til at gøre din applikation hurtig og responsiv — specielt for toggle, slet og tilføj-handlinger.
  • Overvej next-safe-action til større projekter med genbrugelig middleware til autentificering, logging og rate limiting.
  • Implementér rate limiting. Server Actions er offentlige endpoints, og det er nemt at glemme.
  • Returnér strukturerede fejl i stedet for at kaste exceptions. Det giver bedre kontrol over fejlvisning i UI'en.

Server Actions har fundamentalt ændret hvordan vi bygger full-stack applikationer med Next.js. Kombineret med Server Components til datahentning, Drizzle ORM til databaseadgang og Auth.js til autentificering, har du en komplet, moderne stack der er type-sikker, performant og — ærligt talt — en fornøjelse at arbejde med.

I vores næste artikel kigger vi på Next.js Middleware — hvordan du bruger det til routing, internationalisering, A/B-testing og beskyttelse af ruter. Bliv hængende!

Om Forfatteren Editorial Team

Our team of expert writers and editors.