Form Server Actions in Next.js
- What Are Server Actions?
- Forms and Server Actions
- useActionState
- useFormStatus
- useOptimistic
- Complete Example: Contact Form
What Are Server Actions?
Server Actions are async functions that run on the server. They can be utilized in both Server and Client Components to manage form submissions and data updates within Next.js applications.
// layout.tsx
<ClientComponent updateItemAction={updateItem} />
// app/client-component.tsx
'use client'
export default function ClientComponent({
updateItemAction, // props named action or ending with Action are assumed to receive Server Actions
}: {
updateItemAction: (formData: FormData) => void
}) {
return <form action={updateItemAction}>{/* ... */}</form>
}
- Server Components inherently provide progressive enhancement, ensuring that forms will be submitted successfully even if JavaScript is not yet loaded or is turned off.
- Server Actions are versatile and not confined to <form> elements; they can also be triggered via event handlers, useEffect, third-party libraries, and other elements like <button>. Altough in this article we will be focus specially with forms.
- Server Actions seamlessly integrate with Next.js's caching and revalidation system. Upon invocation, Next.js is capable of delivering both the refreshed UI and the updated data in a single server request.
- Under the hood, actions rely on the POST HTTP method, which is the only method capable of triggering them.
Forms and Server Actions
When used in a form, the action automatically gets the FormData object. There's no need to rely on React's useState for managing fields; instead, you can retrieve the data using the built-in methods of FormData.
export default function Page() {
async function createInvoice(formData: FormData) {
'use server'
const rawFormData = {
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
}
// mutate data
// revalidate cache
revalidatePath('/'); // new UI to show
// or revalidateTag('invoices');
}
return <form action={createInvoice}>...</form>
}
You can provide additional arguments to a Server Action using the JavaScript bind method. Alternatively, arguments can be passed as hidden input fields in the form (e.g., <input type="hidden" name="userId" value={userId} />). Keep in mind, though, that the value will be included in the rendered HTML and won't be encoded.
// app/client-component.tsx
export function UserProfile({ userId }: { userId: string }) {
const updateUserWithId = updateUser.bind(null, userId);
// ...
}
// app/actions.ts
// The Server Action will accept the `userId` argument along with the form data:
("use server");
export async function updateUser(userId: string, formData: FormData) {
// ...
}
useActionState
You can then pass your action to the useActionState hook and utilize the returned state to display an error message. After the fields are validated on the server, your action can return a serializable object, which can be used with the React useActionState hook to present a message to the user.
// app/ui/signup.tsx
'use client'
import { useActionState } from 'react'
import { createUser } from '@/app/actions'
const initialState = {
message: '',
}
export function Signup() {
const [state, formAction, pending] = useActionState(createUser, initialState)
return (
<form action={formAction}>
<label htmlFor="email">Email</label>
<input type="text" id="email" name="email" required />
{/* ... */}
<p aria-live="polite">{state?.message}</p>
<button disabled={pending}>Sign up</button>
</form>
)
}
// app/actions.ts
"use server";
import { z } from "zod";
const schema = z.object({
email: z.string({
invalid_type_error: "Invalid Email",
}),
});
// When the action is passed to useActionState in component (see bellow 'app/ui/signup.tsx')
// the function signature of the action is modified to take a new prevState as its first argument
export async function createUser(prevState: any, formData: FormData) {
// validate data with zod
const validatedFields = schema.safeParse({
email: formData.get("email"),
});
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
};
}
// some fetch if neccessary
const res = await fetch("https://...");
const json = await res.json();
if (!res.ok) {
return { message: "Please enter a valid email" };
}
// or mutate data
// revalidate cache
// redirect
redirect("/dashboard");
}
useFormStatus
The useActionState hook provides a pending boolean, which can be utilized to display a loading indicator during the execution of the action.
As another option, you can use the useFormStatus hook to display a loading indicator while the action runs. When opting for this hook, you will need to create a separate component to render the loading indicator. For instance, to disable the button while the action is pending:
// app/ui/signup.tsx
import { SubmitButton } from './button'
import { createUser } from '@/app/actions'
export function Signup() {
return (
<form action={createUser}>
{/* Other form elements */}
<SubmitButton />
</form>
)
}
// app/ui/button.tsx
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton() {
const { pending } = useFormStatus()
return (
<button disabled={pending} type="submit">
Sign Up
</button>
)
}
useOptimistic
You can utilize the React useOptimistic hook to update the UI optimistically, allowing changes to be displayed before the Server Action completes, instead of waiting for the response:
'use client'
import { useOptimistic } from 'react'
import { send } from './actions'
type Message = {
message: string
}
export function Thread({ messages }: { messages: Message[] }) {
const [optimisticMessages, addOptimisticMessage] = useOptimistic<
Message[],
string
>(messages, (state, newMessage) => [...state, { message: newMessage }])
const formAction = async (formData: FormData) => {
const message = formData.get('message') as string
addOptimisticMessage(message)
await send(message)
}
return (
<div>
{optimisticMessages.map((m, i) => (
<div key={i}>{m.message}</div>
))}
<form action={formAction}>
<input type="text" name="message" />
<button type="submit">Send</button>
</form>
</div>
)
}
When to Use useOptimistic
- Immediate Response: When you need to give users real-time feedback while a process is running.
- Addressing Delays: When handling tasks that may experience noticeable network delays, enhancing the perceived responsiveness of the interface.
Complete Example: Contact Form
Next.js has added support for React 19 features, including useActionState, in PR 65058. The create-next-app template also incorporated this support in PR 65478. These updates are available starting from versions 14.3.0-canary.45 and 14.3.0-canary.46. Alternatively, as a temporary workaround until a future Next.js update, you can use the useFormState hook from the react-dom package instead of the useActionState hook from the react package. import { useFormState } from 'react-dom';
Here we can see a complete example using the 15.1.1-canary.13 version of Next JS:
"use client";
import {
FormLabel,
Grid,
TextField,
Alert,
Zoom,
Button,
Box,
} from "@mui/material/";
import { Card, Heading, Text } from "@/components";
import { createMessage, ContactSchemaErrorType } from "@/app/lib/actions";
import { useActionState } from "react";
const Contact = () => {
const [state, formAction, pending] = useActionState(
createMessage,
{
fieldsValue: {
name: '',
email: '',
contactMessage: '',
},
errors: {} as ContactSchemaErrorType,
message: '',
success: false,
}
);
return (
<Card>
<h1>Contact me</h1>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim
veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
commodo consequat.
</p>
<Grid container spacing={3} sx={{ mt: 6 }}>
<form action={formAction}>
<Grid size={{ xs: 12, md: 6 }} sx={{ marginBottom: 3 }}>
<FormLabel htmlFor="name" required sx={{ marginBottom: 1 }}>
Name
</FormLabel>
<TextField
id="name"
name="name"
type="name"
placeholder="You name"
required
size="small"
error={!!state.errors?.fieldErrors?.name}
helperText={state.errors?.fieldErrors?.name}
slotProps={{ input: { sx: { marginBottom: 1 } } }}
defaultValue={state.fieldsValue?.name}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }} sx={{ marginBottom: 3 }}>
<FormLabel htmlFor="email" required sx={{ marginBottom: 1 }}>
Email
</FormLabel>
<TextField
id="email"
name="email"
type="email"
placeholder="name@mycoolcompany.com"
required
size="small"
slotProps={{ input: { sx: { marginBottom: 1 } } }}
error={!!state.errors?.fieldErrors?.email}
helperText={state.errors?.fieldErrors?.email}
defaultValue={state.fieldsValue?.email}
/>
</Grid>
<Grid size={{ xs: 12 }} sx={{ marginBottom: 3 }}>
<FormLabel
htmlFor="contactMessage"
required
sx={{ marginBottom: 1 }}
>
Message
</FormLabel>
<TextField
id="contactMessage"
name="contactMessage"
placeholder="How can I help you?"
required
size="small"
multiline
rows={15}
slotProps={{ input: { sx: { marginBottom: 1 } } }}
error={!!state.errors?.fieldErrors?.contactMessage}
helperText={state.errors?.fieldErrors?.contactMessage}
defaultValue={state.fieldsValue?.contactMessage}
/>
</Grid>
// Error message
{Object.keys(state.errors || {}).length !== 0 && (
<Zoom in>
<Alert
sx={{ marginBottom: 3 }}
variant="filled"
severity="error"
>
{state.message}
</Alert>
</Zoom>
)}
// Success! ;) Message sent
{state.success && (
<Zoom in>
<Alert
sx={{ marginBottom: 3 }}
variant="filled"
severity="success"
>
{state.message}
</Alert>
</Zoom>
)}
<Button
sx={{ color: "primary", fontWeight: 600 }}
variant="outlined"
type="submit"
disabled={pending}
>
Send message
</Button>
</Box>
</Grid>
</Card>
);
};
export default Contact;
server action
"use server";
import { z } from "zod";
import { sendContactEmail } from "@/app/vendor/email";
const contactSchema = z.object({
name: z.string().min(2, { message: "Your name is too short" }),
email: z.string().email({ message: "Invalid email address" }),
contactMessage: z
.string()
.min(10, { message: "Contact message must be longer" }),
});
export type ContactSchemaType = z.infer<typeof contactSchema>;
export type ContactSchemaErrorType = z.inferFlattenedErrors<
typeof contactSchema
>;
type State = {
fieldsValue?: ContactSchemaType;
errors?: ContactSchemaErrorType;
message: string;
success?: boolean;
};
export async function createMessage(prevState: State, formData: FormData) {
const data = Object.fromEntries(formData);
// safeParse - ZOD don't throw errors when validation fails
const validatedFields = contactSchema.safeParse({
name: formData.get("name"),
email: formData.get("email"),
contactMessage: formData.get("contactMessage"),
});
if (!validatedFields.success)
return {
fieldsValue: data as ContactSchemaType,
errors: validatedFields.error.formErrors,
message: "Please, correct the errors",
success: false
};
try {
const { name, email, contactMessage } = validatedFields.data;
await sendContactEmail(name, email, contactMessage);
return {
message: "Message sent",
fieldsValue: {
name: '',
email: '',
contactMessage: '',
},
success: true,
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
return {
message: "Failed to send the message",
};
}
}