shadcn/ui Integration
next-server-actions
works seamlessly with shadcn/ui components. This guide shows how to integrate your server actions with reusable form components using shadcn/ui
.
Getting Started
Installation
To use Slot
in custom form components, install the @radix-ui/react-slot
package:
- npm
- Yarn
- pnpm
npm install @radix-ui/react-slot
yarn add @radix-ui/react-slot
pnpm install @radix-ui/react-slot
You’ll also need the Label
component from shadcn/ui
. Install it using:
- npm
- Yarn
- pnpm
npx shadcn@latest add label
npx shadcn@latest add label
pnpm dlx shadcn@latest add label
Create Shared Form Components
Create a file called form-fields.tsx
in your app/components
directory. This file defines reusable components for form layout, validation messages, and accessibility attributes.
Copy the following code into form-fields.tsx
:
"use client";
import { ComponentProps, createContext, useContext, useId } from "react";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
import { Slot } from "@radix-ui/react-slot";
type Context = { id: string; name: string; errors: string[] };
const FormFieldContext = createContext<Context>({} as Context);
interface FormFieldProps<S extends object> extends ComponentProps<"div"> {
name: keyof S;
errors?: { [K in keyof S]?: string[] };
}
export function FormField<S extends object>({
className,
name,
errors,
...props
}: FormFieldProps<S>) {
const id = useId();
const fieldErrors = errors ? errors[name] ?? [] : [];
return (
<FormFieldContext.Provider value={{ id, name: name as string, errors: fieldErrors }}>
<div className={cn("space-y-2", className)} {...props} />
</FormFieldContext.Provider>
);
}
export function FormControl(props: ComponentProps<typeof Slot>) {
const { id, errors } = useContext(FormFieldContext);
const hasError = errors.length > 0;
const messages = errors.map((_, index) => `form-message-${id}-${index}`);
return (
<Slot
id={id}
aria-invalid={hasError}
aria-describedby={`form-description-${id} ${messages.join(" ")}`}
{...props}
/>
);
}
export function FormLabel({
className,
...props
}: ComponentProps<typeof Label>) {
const { id, errors } = useContext(FormFieldContext);
return (
<Label
htmlFor={id}
className={cn(className, errors.length > 0 && "text-destructive")}
{...props}
/>
);
}
export function FormMessage() {
const { id, errors } = useContext(FormFieldContext);
return errors.map((error, index) => (
<p
key={error}
id={`form-message-${id}-${index}`}
className="text-[0.8rem] font-medium text-destructive"
>
{error}
</p>
));
}
export function FormDescription({ className, ...props }: ComponentProps<"p">) {
const { id } = useContext(FormFieldContext);
return (
<p
id={`form-description-${id}`}
className={cn("text-[0.8rem] text-muted-foreground", className)}
{...props}
/>
);
}
Example Usage
Define a validation schema and form types:
import { z } from "zod";
export const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
export type FormData = z.infer<typeof schema>;
Now use the shared components to build a clean form:
"use client";
import Form from "next/form";
import { useActionState } from "react";
// the server action from previous example
import { signIn } from "@lib/actions/sign-in";
// import the components
import {
FormField,
FormControl,
FormLabel,
FormMessage,
} from "@/components/form-fields";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
// import the schema
import { FormData } from "@/lib/definitions";
export function SignInForm() {
const [state, action, pending] = useActionState(signIn, { ok: false });
return (
<Form action={action} className="grid gap-4 mx-auto max-w-2xl py-12">
<FormField<FormData> name="email" errors={state.errors}>
<FormLabel>Email</FormLabel>
<FormControl>
<Input name="email" type="email" />
</FormControl>
<FormMessage />
</FormField>
<FormField<FormData> name="password" errors={state.errors}>
<FormLabel>Password</FormLabel>
<FormControl>
<Input name="password" type="password" />
</FormControl>
<FormMessage />
</FormField>
<hr />
<Button disabled={pending}>
{pending ? "Submitting..." : "Submit"}
</Button>
</Form>
);
}
Usage with Select
You can also use FormField
with the Select
component from shadcn/ui
.
"use client";
import {
FormField,
FormControl,
FormLabel,
FormMessage,
} from "@/components/form-fields";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
<FormField<FormData> name="fruit" errors={state.errors}>
<FormLabel>Fruit</FormLabel>
<FormControl>
<Select name="fruit">
<SelectTrigger>
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Fruits</SelectLabel>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
<SelectItem value="blueberry">Blueberry</SelectItem>
<SelectItem value="grapes">Grapes</SelectItem>
<SelectItem value="pineapple">Pineapple</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormField>
Always pass the name
prop to inputs like Input
, Textarea
, or Select
. This is required for the form data to be submitted properly.