Creating a Server Action for Form Data

    Jack HerringtonJack Herrington

    So far, we've explored two out of four different types of interaction with the server: an API endpoint that takes JSON and another that takes form data.

    Now we'll dive into creating a server action and using it to submit data.

    Creating a Server Action

    Server actions can be placed within a React server component, like a page, or within a separate file. For now, we'll create the server action inside src/app/page.tsx.

    The server action will be an asynchronous function named onDataAction that we'll pass into the RegistrationForm client component:

    // inside page.tsx
    export default function Home() {
      const onDataAction = async (data: any) => {
      return (
        <div className="mx-auto max-w-xl">
          <RegistrationForm onDataAction={onDataAction} /> // red squiggly line under onDataAction

    However, there's an error underneath the onDataAction prop because it isn't defined as a property on RegistrationForm.

    On the page in the browser, we are shown an error:

    Unhandled Runtime Error
    Error: Event handlers cannot be passed to Client Component props.
    <... onDataAction={function}>
    If you need interactivity, consider converting part of this to a Client Component.

    Next.js thinks that onDataAction is an event handler because it doesn't know that it's a server action.

    In order to resolve this, we need to include "use server"; inside of the onDataAction function to notify Next.js that the function is server-only:

    const onDataAction = async (data: any) => {
      "use server";

    With this change, the error goes away in the browser but TypeScript isn't happy that onDataAction not being defined as a property on RegistrationForm.

    Let's fix it.

    Adding onDataAction to RegistrationForm

    Over in RegistrationForm.tsx, we'll define onDataAction as a function that takes data and returns a promise. For now we'll broadly define the data type as any, and the return type will be Promise<void> since nothing is returned:

    // inside RegistrationForm.tsx
    export const RegistrationForm = ({
    }: {
      onDataAction: (data: any) => Promise<void>;
    }) => {
      // ...

    With the onDataAction function defined and wired up, we can test it out.

    Testing the Server Action

    On the web page, fill out the form and hit submit.

    Nothing seems to happen in the browser console where we previously saw our console.log() output.

    Instead, we'll check the terminal and see that our data has successfully been sent to the server:

    The console.log output is in the terminal

    Now that we know our submit is working properly, let's add the proper types to our server action.

    Adding Typing and Validating Data

    Back in page.tsx we import z from Zod and our schema:

    import { z } from "zod";
    import { schema } from "@app/registrationSchema";

    Inside the onDataAction function, we'll type data using the z.infer trick we've used before. We also will use the schema's safeParse to parse the data and use the conditional logic to check if the parsing was successful:

    const onDataAction = async (data: z.infer<typeof schema>) => {
      "use server";
      const parsed = schema.safeParse(data);
      if (parsed.success) {
        console.log("User registered");
        return { message: "User registered", user: };
      } else {
        return {
          message: "Invalid data",
          issues: => issue.message),

    Now that we've made these changes, TypeScript gives us another error on the onDataAction prop in page.tsx because the output of the function isn't void anymore. It's now an object that contains either a success message and user data, or an error message and the issues.

    In RegistrationForm.tsx, we need to modify the promise to match the new schema.

    The Promise will now return an object that will contain a message, and might contain a user or issues. The user will be inferred from the schema, and issues will be a string array:

    export const RegistrationForm = ({
    }: {
      onDataAction: (data: z.infer<typeof schema>) => Promise<{ 
        message: string;
        user?: z.infer<typeof schema>;
        issues?: string[];
    }) => {

    We'll also update the onSubmit function to log out the output. Remember to use await since we're dealing with a promise:

    const onSubmit = async (data: z.infer<typeof schema>) => {
      // commented out fetch implementations above
      console.log(await onDataAction(data));

    Testing our work in the browser, we can submit the form and see the output in the console as expected.

    Wrapping Up & Next Steps

    We've now completed the third technique for form data handling, this time using a Server Action.

    For our fourth method, we will reuse form data again by rewriting the existing code to use it.

    Follow the included resources below to try it yourself, then check back in the next video to see how I did it.

    Resources for Posting Form Data to a Server Action

    In order to post form data to the server action, you'll be able to reuse much of the code from the onDataAction to create a new onFormAction function.

    The new onFormAction function will accept FormData which will be parsed after being turned into a simple object with Object.fromEntries.

    Then over on the client side, you'll need to update onSubmit function to create a formData object based on the submitted data, then call formAction with the formData as an argument.

    As an alternative, you could use the useFormState hook and React refs to manage the form state and submit the form data.

    We'll look at both solutions in the next video.


    All right, so far we've taken a look at two of our four different types of interactions with the server. We've looked at an API endpoint that takes JSON and another that takes form data. Now let's try and create a server action and use a server action to submit the data. Now you can either put a server action in a React server component, like page, or you can create a different file. I'll show you that in a bit.

    But I'm just going to go and create the server action in page.tsx. Now I'm going to call this server action onDataAction. It's going to be an async function. And we'll just pass it on to our ClientComponent. Now our ClientComponent isn't expecting that property, so maybe that's what it's telling us.

    Let's give it a try and see what happens on the client. All right, so we get a runtime error about how a event handler, in this case, cannot be passed to a ClientComponent prop from an RSC. Why does it think it's an event handler? Well, it thinks it's an event handler because it doesn't know it is a server action.

    Now to do that, we're going to add useServer. That tells Next.js that this is a server-only function. Let's go take a look. And now it's happy. TypeScript isn't so happy, though, because onDataAction isn't defined as a property onRegistrationForm.

    So let's go and add that. All right, so we'll define that as a function. It takes data. Currently, we'll just define that as any. And it returns a promise, since it's an async function, to avoid. So it doesn't return anything. And now let's just use it.

    So instead of either fetch here, we're going to just invoke it. We'll hit Save. We'll hit Submit.

    And now nothing seems to happen, but if I look on the terminal, ah, there we go. There's our data all the way back on the server. Awesome. Well, let's start getting the typing right. So on this side, it's pretty much as easy as just doing the infer for data. And it's the same thing on the other side.

    So let's bring in Zod and the schema. And then again, we'll infer. And there we go. Now we got our typing. But of course, we always want to check that data.

    So let's go and add in our save parse. So now we're going to save parse our schema. And then if we parse successfully, we've got the actual data. Then we are going to do similar things to what we did before. We're going to return a message that has the user. And if we are invalid, then we return a message of invalid data.

    And we give back a list of all of the issues. Now if we look down here, we can see that TypeScript is not happy again. And that's because the output of this function is no longer a void. It's a object that has either a message and user or a message and issues. So back in our registration form, let's go and change the promise here to match that schema.

    So a message, which is a string. If it's a user, it's going to have the type of that schema. Otherwise, it's going to have a list of issues. And let's go down here and console log out the output. Of course, we have to await it. Let's give it a go.

    And there we go. Our user is registered all the way out to the server and back, just using a server action. For our fourth method, we are going to use form data again. So we're going to take that exact same code and then rewrite it

    to use form data instead. I leave that to you in the instructions. I'll check in the next video, and you can see how I did it.