jameslittle.me

Forms and Server Actions in Next.JS

November 22, 2023

I've been working on a Next.JS site recently. It's a data-heavy site with a user model, some forms, and a database I'm using Supabase; it's good and, in an effort to speed up development time, I've decided to try out an architecture that uses server actions, a new technology built in collaboration with the React team, introduced in NextJS 13.4, and stabilized in version 14.

The sample code in this post is the best way I've found (so far) to set up dynamic forms that are idiomatic with these new tools. As I mention below, it's not using the fanciest functionality, but it strikes a good balance between fanciness and the amount of boilerplate needed to put together a new form.

First, set up a server component to fetch all the server-side data needed to display the form's initial state. This gets hooked into Next's routing system by being a page.tsx file.

page.tsx
1import { Form } from "./form";
2
3export default async function Page() {
4 // This is a server component, so we can look up the current session here
5 // and redirect to our protected section if it exists.
6 // Otherwise, we display the login form.
7 return (
8 <div>
9 <h1>Log In</h1>
10 <Form />
11 </div>
12 );
13}

Our client-side Form component displays the input and the form feedback:

form.tsx
1"use client";
2
3import { useFormState } from "react-dom";
4import { submitLogin } from "./actions";
5import { FormStatusFeedback } from '@/components/FormStatusFeedback';
6
7/**
8 * This state object isn't tracking the state of the form inputs - that gets
9 * handled automatically. Instead, it's tracking any auxiliary state needed
10 * to display post-submission data to the user, such as error messages or
11 * validation information.
12 */
13const initialState: FormFeedback = {
14 state: 'idle'
15 message: null,
16};
17
18export const Form = () => {
19 const [state, formAction] = useFormState(submitLogin, initialState);
20
21 return (
22 <>
23 {/* This is a shared component that displays a FormFeedback object,
24 if it exists. You might decide to use this for some forms but have
25 a more UI-specific feedback system for others. */}
26 <FormStatusFeedback state={state} />
27 <form action={formAction}>
28 <input name="username" ></input>
29 <input name="password" type="password"></input>
30 {/* You might also use the SubmitButton component below */}
31 <button type="submit">Submit</button>
32 </form>
33 </>
34 );
35};

We can split out our form actions into a separate file. This function validates the submitted data, performs any database actions necessary, and returns a new form feedback state that will be displayed in the client-side component.

actions.ts
1"use server";
2
3import { redirect } from "next/navigation";
4import { zfd } from "zod-form-data";
5
6export const submitLogin = async (prevState: FormFeedback, data: FormData) => {
7 // Validate the input using Zod
8 const schema = zfd.formData({
9 username: zfd.text(),
10 password: zfd.text(),
11 });
12
13 const parseResult = schema.safeParse(data);
14
15 if (!parseResult.success) {
16 return {
17 status: "error",
18 message: "Please enter both a username and password.",
19 };
20 }
21
22 const { username, password } = parseResult.data;
23
24 // If the username or password are invalid, session is falsey.
25 const session = application.tryCreateSession(username, password);
26
27 if (!session) {
28 return {
29 status: "error",
30 message: `We couldn't find you in the system. Please check your
31 credentials and try again.`,
32 };
33 }
34
35 // On success, you might want to stay on the same page and display a
36 // successful page...
37 //
38 // return {
39 // status: "success",
40 // message: "Successful login"
41 // }
42 //
43 // ...or in the case of login, you might want to redirect to a page that
44 // requires authentication.
45 redirect("/dashboard");
46};

This shared component takes in a form feedback object and, if there's a message to display, renders it. (I'd imagine that it uses the success or error states to change the background color of the component.) As I mentioned above, you might not want to use this shared component for every form; if you have a form that requires specific user feedback, you could build a more specific feedback data structure and set up Form.tsx to display it.

FormStatusFeedback.tsx
1"use client";
2
3type FormFeedback = {
4 state: "idle" | "success" | "error";
5 message: string | null;
6};
7
8export const FormStatusFeedback = ({ state }: { state?: FormFeedback }) => {
9 if (!state.message) {
10 return null;
11 }
12 return (
13 <div
14 className={classNames(
15 formFeedbackBaseClasses,
16 { [formErrorClasses]: state.status === "error" },
17 { [formSuccessClasses]: state.status === "success" }
18 )}
19 >
20 {state.message}
21 </div>
22 );
23};

I also have a SubmitButton component that disables itself and displays a spinner while the form is loading, which is helpful to show users that their button press is working.

SubmitButton.tsx
1"use client";
2
3import { useFormStatus } from "react-dom";
4import { Spinner } from "@/components/ui/Spinner";
5
6export default function SubmitButton({ children, ...props }: ButtonProps) {
7 const { pending } = useFormStatus();
8
9 return (
10 <button disabled={pending} type="submit" {...props}>
11 <span>{children}</span> {pending && <Spinner variant="dark" />}
12 </button>
13 );
14}

That's it! It doesn't use all the fancy features like useOptimistic, but it gets the job done, it feels relatively responsive for users, and it lets you (the developer) spin up a new form without too much boilerplate.

Despite previous angst, I'm having a lot of fun building with NextJS, server actions, and this form pattern, and I'm excited to share more about what I've been building in a future post!