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.
1import { Form } from "./form";23export default async function Page() {4 // This is a server component, so we can look up the current session here5 // 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:
1"use client";23import { useFormState } from "react-dom";4import { submitLogin } from "./actions";5import { FormStatusFeedback } from '@/components/FormStatusFeedback';67/**8 * This state object isn't tracking the state of the form inputs - that gets9 * handled automatically. Instead, it's tracking any auxiliary state needed10 * to display post-submission data to the user, such as error messages or11 * validation information.12 */13const initialState: FormFeedback = {14 state: 'idle'15 message: null,16};1718export const Form = () => {19 const [state, formAction] = useFormState(submitLogin, initialState);2021 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 have25 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.
1"use server";23import { redirect } from "next/navigation";4import { zfd } from "zod-form-data";56export const submitLogin = async (prevState: FormFeedback, data: FormData) => {7 // Validate the input using Zod8 const schema = zfd.formData({9 username: zfd.text(),10 password: zfd.text(),11 });1213 const parseResult = schema.safeParse(data);1415 if (!parseResult.success) {16 return {17 status: "error",18 message: "Please enter both a username and password.",19 };20 }2122 const { username, password } = parseResult.data;2324 // If the username or password are invalid, session is falsey.25 const session = application.tryCreateSession(username, password);2627 if (!session) {28 return {29 status: "error",30 message: `We couldn't find you in the system. Please check your31 credentials and try again.`,32 };33 }3435 // On success, you might want to stay on the same page and display a36 // 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 that44 // 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.
1"use client";23type FormFeedback = {4 state: "idle" | "success" | "error";5 message: string | null;6};78export const FormStatusFeedback = ({ state }: { state?: FormFeedback }) => {9 if (!state.message) {10 return null;11 }12 return (13 <div14 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.
1"use client";23import { useFormStatus } from "react-dom";4import { Spinner } from "@/components/ui/Spinner";56export default function SubmitButton({ children, ...props }: ButtonProps) {7 const { pending } = useFormStatus();89 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!