Form Server Actions in Next.js


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>
}

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

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",
    };
  }
}