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 callscreateProdukt
, the server action handler.state
holds the return value ofcreateProdukt
, 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),
};
}
}