·

Deep Dive: Server-Side Form Management in Next.js 15

1. The Form (Client Component)

Let’s start with the client component ProduktForm. It uses useActionState to bind form state directly to the server:

const [state, action, isPending] = useActionState<
  CreateProduktActionResponse,
  FormData
>(createProdukt, initialState);

What’s happening here?

  • action is a function that internally calls createProdukt, the server action handler.
  • state holds the return value of createProdukt, such as validation errors or a success message.
  • isPending indicates if a request is currently in progress (e.g., to disable the submit button).

Form Binding

<form action={action} className="space-y-6">
  {/* Input fields */}
</form>

The key feature: the <form> element directly triggers the server function—no need for fetch, no API route involved.

2. Form Fields & Error Messages

Let’s look at the name field as an example:

<div>
  <Label htmlFor="name">Product Name</Label>
  <Input id="name" name="name" defaultValue={state.inputs?.name} />
  {state.errors?.name && (
    <p className="text-sm text-red-500">{state.errors.name[0]}</p>
  )}
</div>

Benefits:

  • defaultValue refills the field with the last input after validation errors.
  • state.errors provides detailed feedback per field—the keys (e.g., name) come directly from Zod.

3. Dynamic Inputs: Ingredients & Ratios

Ingredients are loaded via a dynamic multi-select:

<AsyncSelect
  isMulti
  loadOptions={loadZutatenOptions}
  onChange={handleZutatenChange}
  value={selectedZutaten}
/>

For each selected ingredient, an additional input for its ratio is generated:

{
  selectedZutaten.map((option, idx) => (
    <div key={idx}>
      <input type="hidden" name={`zutaten_id[]`} value={option.value} />
      <Input
        type="number"
        name={`zutaten_verhaeltnis[]`}
        step={0.1}
        defaultValue={state.inputs?.zutaten_verhaeltnis?.[idx] || ""}
      />
    </div>
  ));
}

Important:

The []-notation (zutaten_id[]) results in an array when calling FormData.getAll().

This lets you later synchronize both arrays—ingredient IDs & their ratios—on the server.

4. The Server Action: [object Object]

Now let’s check the magic behind the scenes:

export async function createProdukt(
  prevState: CreateProduktActionResponse,
  formData: FormData
);

Extracting Data

const produktSchemaValues: CreateProduktFormData = {
  name: formData.get("name")?.toString() ?? "",
  beschreibung: formData.get("beschreibung")?.toString() ?? "",
  memo: formData.get("memo")?.toString() ?? "",
  einheitId: Number(formData.get("einheitId")) || 0,
  zutaten_id: formData.getAll("zutaten_id[]").map(String),
  zutaten_verhaeltnis: formData.getAll("zutaten_verhaeltnis[]").map(Number),
};

Validation with Zod

const result = CreateProduktSchema.safeParse(produktSchemaValues);
if (!result.success) {
  return {
    success: false,
    message: "Invalid product data.",
    errors: result.error.flatten().fieldErrors,
    inputs: produktSchemaValues,
  };
}

If validation fails, the frontend gets a clean error structure per field.

5. Persistence with Prisma

After successful validation, we create the product entry:

const createdProdukt = await prisma.produkt.create({
  data: {
    name: validProdukt.name,
    beschreibung: validProdukt.beschreibung,
    memo: validProdukt.memo,
    einheitId: validProdukt.einheitId,
    produktZutaten: {
      create: produktzutaten.map((zutat) => ({
        zutat: { connect: { id: zutat.zutatId } },
        verhaeltnis: new Prisma.Decimal(zutat.verhaeltnis),
        memo: "",
      })),
    },
  },
});

We also invalidate relevant caches:

revalidatePath("/produkt");
revalidatePath("/produkt/neu");

Conclusion: Benefits of This Setup

  • Simplicity: No REST API needed – form submits directly to a server function
  • Security: Validation & DB access run server-side, no client-side manipulation
  • Feedback: Structured error messages returned to the frontend via useActionState
  • State: Form values and errors persist after reload
  • Modularity: Separation of logic with Server Actions, Zod, Prisma

Key Takeaways

Next.js 15 allows a fully integrated form experience—no need for fetch, axios, REST, or GraphQL boilerplate. Paired with Zod and Prisma, you can build robust, scalable fullstack features in a single file—with maximum DX and security.

Full Code

"use client";
 
import { useActionState, useEffect } from "react";
 
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
 
import type { ActionMeta } from "react-select";
import type React from "react"; // Added import for React
 
import { ProduktDTO } from "@/schemas/produkt-schema";
 
import dynamic from "next/dynamic";
import { getFilteredZutaten } from "@/actions/zutat";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "../ui/select";
import {
  createProdukt,
  CreateProduktActionResponse,
} from "@/actions/produkt/create-new-produkt";
import { getEinheiten } from "@/actions/einheit";
import { EinheitDTO } from "@/schemas/einheit-schema";
 
const AsyncSelect = dynamic(() => import("react-select/async"), { ssr: false });
 
interface Option {
  value: string;
  label: string;
}
const initialState: CreateProduktActionResponse = {
  success: false,
  message: "",
  errors: undefined,
  inputs: undefined,
};
 
export default function ProduktForm({
  einheiten,
  produktToEdit,
}: {
  einheiten: EinheitDTO[];
  produktToEdit: ProduktDTO | null;
}) {
  const [state, action, isPending] = useActionState<
    CreateProduktActionResponse,
    FormData
  >(createProdukt, initialState);
 
  console.log(state);
  const [touched, setTouched] = useState({
    name: false,
    beschreibung: false,
    memo: false,
    zutaten: false,
  });
 
  const [selectedZutaten, setSelectedZutaten] = useState<Option[]>([]);
 
  const loadZutatenOptions = async (inputValue: string) => {
    const response = await getFilteredZutaten(inputValue);
    return response.map((z) => ({ value: z.id.toString(), label: z.name }));
  };
 
  const handleZutatenChange = (
    newValue: unknown,
    actionMeta: ActionMeta<unknown>
  ) => {
    setSelectedZutaten(newValue as Option[]);
  };
 
  return (
    <form action={action} className="space-y-6 max-w-md mx-auto">
      {state.message && (
        <Alert variant={state.errors ? "destructive" : "default"}>
          <AlertTitle>{state.errors ? "Fehler" : "Erfolg"}</AlertTitle>
          <AlertDescription>{state.message}</AlertDescription>
        </Alert>
      )}
      <div className="space-y-2">
        <Label htmlFor="name">Produktname</Label>
        <Input id="name" name="name" defaultValue={state.inputs?.name} />
        {state.errors?.name && (
          <p className="text-sm text-red-500">{state.errors.name[0]}</p>
        )}
      </div>
      <div className="space-y-2">
        <Label htmlFor="beschreibung">Beschreibung</Label>
        <Textarea
          id="beschreibung"
          name="beschreibung"
          defaultValue={state.inputs?.beschreibung}
        />
        {state.errors?.beschreibung && (
          <p className="text-sm text-red-500">{state.errors.beschreibung[0]}</p>
        )}
      </div>
      <div className="space-y-2">
        <Label htmlFor="memo">Notiz (optional)</Label>
        <Textarea id="memo" name="memo" defaultValue={state.inputs?.memo} />
        {state.errors?.memo && (
          <p className="text-sm text-red-500">{state.errors.memo[0]}</p>
        )}
      </div>
      <div className="space-y-2">
        <Label htmlFor="einheit">Einheit</Label>
        <Select
          name="einheitId"
          defaultValue={state.inputs?.einheitId?.toString()}
        >
          <SelectTrigger className="">
            <SelectValue placeholder="Einheit wählen" />
          </SelectTrigger>
          <SelectContent>
            {einheiten.map((einheit) => (
              <SelectItem key={einheit.id} value={einheit.id?.toString() ?? ""}>
                {einheit.name}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
        {state.errors?.einheitId && (
          <p className="text-sm text-red-500">{state.errors.einheitId[0]}</p>
        )}
      </div>
      <div className="space-y-2">
        <Label htmlFor="zutaten">Zutaten</Label>
        <AsyncSelect
          isMulti
          defaultOptions
          id="zutaten"
          placeholder={"Zutaten auswählen..."}
          loadOptions={loadZutatenOptions}
          onChange={handleZutatenChange}
          value={selectedZutaten}
          className="react-select-container"
          classNamePrefix="react-select"
        />
 
        {state.errors?.zutaten_id && (
          <p className="text-sm text-red-500">{state.errors.zutaten_id[0]}</p>
        )}
      </div>
 
      {selectedZutaten.map((option, idx) => (
        <div key={idx} className="flex items-center space-x-2">
          <input type="hidden" name={`zutaten_id[]`} value={option.value} />
          <p>{option.label}</p>
          <Input
            type="number"
            name={`zutaten_verhaeltnis[]`}
            placeholder="Verhältnis"
            step={0.1}
            defaultValue={state.inputs?.zutaten_verhaeltnis?.[idx] || ""}
          />
          {state.errors?.zutaten_verhaeltnis && (
            <p className="text-sm text-red-500">
              {state.errors?.zutaten_verhaeltnis[0]}
            </p>
          )}
        </div>
      ))}
 
      <Button type="submit" disabled={isPending}>
        {isPending ? "Wird erstellt..." : "Produkt erstellen"}
      </Button>
    </form>
  );
}
"use server";
 
import { prisma } from "@/lib/prisma";
 
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { Prisma } from "@prisma/client";
 
export type CreateProduktFormData = {
  name: string;
  beschreibung: string;
  memo: string;
  zutaten_id: string[];
  zutaten_verhaeltnis: number[];
  einheitId: number;
};
 
export interface CreateProduktActionResponse {
  success: boolean;
  message: string;
  inputs?: CreateProduktFormData;
  errors?: { [K in keyof CreateProduktFormData]?: string[] } | any;
}
 
const CreateProduktSchema = z
  .object({
    name: z
      .string()
      .min(3, "Der Produktname muss mindestens 3 Zeichen lang sein."),
    beschreibung: z
      .string()
      .min(3, "Die Beschreibung muss mindestens 3 Zeichen lang sein."),
    memo: z.string().optional().or(z.literal("")),
    einheitId: z.number().min(1, "Bitte wähle eine gültige Einheit aus."),
    zutaten_id: z
      .array(z.string())
      .nonempty("Bitte wähle mindestens eine Zutat aus."),
    zutaten_verhaeltnis: z
      .array(
        z
          .number()
          .min(0.01, "Das Verhältnis muss mindestens 0.01 betragen.")
          .max(1, "Das Verhältnis darf maximal 1 betragen.")
      )
      .nonempty("Bitte gib das Verhältnis für die Zutaten an."),
  })
  .refine(
    (data) =>
      data.zutaten_verhaeltnis.reduce((sum, value) => sum + value, 0) <= 1,
    {
      message: "Die Summe der Verhältnisse darf maximal 1 betragen.",
      path: ["zutaten_verhaeltnis"],
    }
  );
 
export async function createProdukt(
  prevState: CreateProduktActionResponse | null,
  formData: FormData
): Promise<CreateProduktActionResponse> {
  try {
    const name = formData.get("name")?.toString() ?? "";
    const beschreibung = formData.get("beschreibung")?.toString() ?? "";
    const memo = formData.get("memo")?.toString() ?? "";
    const einheitId = Number(formData.get("einheitId")) || 0;
    const ids = formData.getAll("zutaten_id[]").map(String);
    const verhaeltnisse = formData.getAll("zutaten_verhaeltnis[]").map(String);
 
    const produktSchemaValues: CreateProduktFormData = {
      name,
      beschreibung,
      memo,
      einheitId,
      zutaten_id: ids,
      zutaten_verhaeltnis: verhaeltnisse.map(Number),
    };
 
    const result = CreateProduktSchema.safeParse(produktSchemaValues);
 
    if (!result.success) {
      return {
        success: false,
        message: "Fehler in den Produktdaten.",
        errors: result.error.flatten().fieldErrors,
        inputs: produktSchemaValues,
      };
    }
 
    const validProdukt = result.data;
 
    const produktzutaten = validProdukt.zutaten_id.map((id, index) => ({
      zutatId: Number(id),
      verhaeltnis: Number(validProdukt.zutaten_verhaeltnis[index]),
      memo: "",
    }));
 
    // Erstelle das Produkt in der Datenbank
    const createdProdukt = await prisma.produkt.create({
      data: {
        name: validProdukt.name,
        beschreibung: validProdukt.beschreibung,
        memo: validProdukt.memo,
        einheitId: validProdukt.einheitId,
        produktZutaten: {
          create: produktzutaten.map((zutat) => ({
            zutat: { connect: { id: zutat.zutatId } },
            verhaeltnis: new Prisma.Decimal(zutat.verhaeltnis),
            memo: zutat.memo,
          })),
        },
      },
      include: { produktZutaten: true },
    });
 
    revalidatePath("/produkt");
    revalidatePath("/produkt/neu");
    return {
      success: true,
      message: "Produkt erfolgreich erstellt!",
    };
  } catch (error) {
    console.error("Unerwarteter Fehler:", error);
    if (error instanceof Prisma.PrismaClientKnownRequestError) {
      return {
        success: false,
        message: "Datenbankfehler: " + error.message,
      };
    }
    return {
      success: false,
      message: "Ein unerwarteter Fehler ist aufgetreten: " + String(error),
    };
  }
}