Create a form builder with Directus & NextJS

Creating a form builder with Directus can be a powerful and efficient way to build custom forms / surveys for your NextJS website or application.

Create a form builder with Directus & NextJS

Before we begin...

You don't have to use the same packages I use to reproduce this project. You can do the exact same thing but in your own way. And of course, this is just a start, a solid base on which you can add whatever feature you want.

 

The stack

Let's start with the best of the best. Our stack is composed of the following packages:

  • Directus - a headless CMS that allows (amazingly) easy management of content and data.
  • NextJS - a framework for building server-rendered React applications.
  • react-hook-form - forget about writing validation and submission logic, react-hook-form got you covered.
  • @tanstack/react-query - the ultimate solution for client-side data management.
  • Typescript - a must-have for any serious developer.
  • TailwindCSS - a utility-first CSS framework that makes it easy to create beautiful and consistent designs.
  • tailwind-merge - Utility function to efficiently merge Tailwind CSS classes in JS without style conflicts.

With this stack, you'll be able to build a form builder that is easy to manage, easy to use, and easy to style. Easy.

 

Configuring Directus

Setting up Directus and creating the necessary collections is a piece of cake. We'll be creating three collections forms, forms_submits, and forms_components. The forms collection will be used to store the form data, forms_submits will be used to store the form submissions, and forms_components will be used to store the different form fields. Start by creating these three collections, once you're done we can go forward.

Trust me, you'll be able to set this up in no time. Let me show you.

The forms collection

Ok, let's get started with the first collection. This will be our entry point that defines our forms name, fields and behaviour. Set it-up like this:

  • name - Input
  • elements - One To Many with the form_components collection
  • on_success - this defines what happens once the form is submitted; select Dropdown and create the choices 'show_message' and 'redirect'
  • on_success_message - Input; the message shown after the form is submitted if on_success equals 'show_message'
  • on_success_redirect_link - Input; defines where the user should be redirected after the form is submitted if on_success equals 'show_message'
  • submits - One To Many with form_submits collection

 

The form_components collection

This collection will hold every fields of our forms. We will add some basic customisation capacity but keep in mind, you can add your own fields if you feel like it:

  • key - Input; this is the field's unique name that will be used to construct our form
  • label - Input; a human-readable name for our field that will be show to the user
  • type - Dropdown; the field type, we will add "input", "textarea" and "select" as the default choices
  • choices - Repeater with 'label' and value 'fields'; defines the available options if 'type' equals 'select"
  • required - Boolean; defines if our field is required
  • col_span - Integer; defines our field's size in the form (between 1 and 12)
  • component_props  - JSON; allows you to add custom props to you field (ex: placeholder, style, aria-label...)

Now that your form components are created, let's move the last collection

 

The form_submits collection

This collection will just store all the form submits:

  • elements - JSON; plain JSON object containing data of the submit

 

Rendering forms in our front-end

NextJS structure of our Directus form builder

Now that our system is ready, we can build the form generation in our front-end.

First thing first, I suggest you generate Typescript definitions of our Directus collections. To do that, you can use this wonderful tool made by called directus-typescript-gen. Store the file generated by the tool somewhere in your project.

Then, install the Directus JS SDK and create a global variable like this:

import { Directus } from "@directus/sdk";
import { ApiCollections } from "../@types/api";

export const directus = new Directus<ApiCollections>(
  process.env.NEXT_PUBLIC_API_URL as string,
  {
    auth: {
      mode: "cookie",
    },
  }
);

Now, let's create the components we need to render our form components.

 

Render input components

This component will render form components of type input.

// DirectusForm/components/DirectusInput.tsx

"use client";

import { FieldValues, UseFormProps, UseFormReturn } from "react-hook-form";
import { ApiCollections } from "../../../@types/api";
export const DirectusInput = (props: {
  element: ApiCollections["form_components"];
  hookForm: UseFormReturn<FieldValues, any>;
}) => {
  const { element, hookForm } = props;
  const { register } = hookForm;
  return (
    <input
      required={element.required || false}
      {...element.component_props}
      {...register(element.key!, {
        required: element.required || false,
      })}
    />
  );
};

 

Render textarea form components

We will use this component for textarea form components:

// DirectusForm/components/DirectusTextarea.tsx

"use client";

import { FieldValues, UseFormProps, UseFormReturn } from "react-hook-form";
import { ApiCollections } from "../../../@types/api";

export const DirectusTextarea = (props: {
  element: ApiCollections["form_components"];
  hookForm: UseFormReturn<FieldValues, any>;
}) => {
  const { element, hookForm } = props;
  const { register } = hookForm;

  return (
    <textarea
      required={element.required || false}
      {...element.component_props}
      {...register(element.key!, {
        required: element.required || false,
      })}
    />
  );
};

 

Render select form components

This speaks for itself, we will use it for select fields:

// DirectusForm/components/DirectusSelect.tsx

"use client";

import { FieldValues, UseFormReturn } from "react-hook-form";
import { ApiCollections } from "../../../@types/api";

export const DirectusSelect = (props: {
  element: ApiCollections["form_components"];
  hookForm: UseFormReturn<FieldValues, any>;
}) => {
  const { element, hookForm } = props;
  const { register } = hookForm;
  return (
    <select
      required={element.required || false}
      {...element.component_props}
      {...register(element.key!, {
        required: element.required || false,
      })}
    >
      {element.component_props?.placeholder && (
        <option value="" hidden selected disabled>
          {element.component_props.placeholder}
        </option>
      )}
      {(element.choices as { label: string; value: any }[])?.map((res, key) => (
        <option value={res.value} key={`select-${element.key}-option-${key}`}>
          {res.label}
        </option>
      ))}
    </select>
  );
};

 

Render a single form component

Now that we have components for every form fields, we need a component that will render the correct component depending on the field type:

// DirectusForm/components/index.tsx

"use client";

import React from "react";
import { UseFormReturn, FieldValues } from "react-hook-form";
import { ApiCollections } from "../../../@types/api";
import { DirectusInput } from "./DirectusInput";
import { DirectusSelect } from "./DirectusSelect";
import { DirectusTextarea } from "./DirectusTextarea";

export const RenderDirectusComponent = (props: {
  element: ApiCollections["form_components"];
  hookForm: UseFormReturn<FieldValues, any>;
}) => {
  const { element, hookForm } = props;
  switch (element.type) {
    case "input":
      return <DirectusInput element={element} hookForm={hookForm} />;
    case "textarea":
      return <DirectusTextarea element={element} hookForm={hookForm} />;
    case "select":
      return <DirectusSelect element={element} hookForm={hookForm} />;
    default:
      return <React.Fragment />;
  }
};

 

Generate our form

Still there ? Good. This is the final step for our form builder: generating the entire form and its logic. Remember, our form has fields, and a certain behaviour after our form is submitted (show message or redirect).

We will implement a simple component that will build our form and display it:

// DirectusForm/index.tsx

"use client";

import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { ApiCollections } from "../../@types/api";
import { directus } from "../../lib/directus";
import { Button } from "../UI/Button";
import { RenderDirectusComponent } from "./components";

const RenderSuccessMessage = (props: { message: string }) => {
  return <p>{props.message}</p>;
};

export const RenderDirectusForm = (props: {
  form: ApiCollections["forms"];
}) => {
  const router = useRouter();
  const { mutate, isLoading, isError, isSuccess } = useMutation(
    async (elements: Record<string, any>) =>
      await directus.items("form_submits").createOne({
        form: props.form.id,
        elements,
      }),
    {
      onSuccess: () => {
        if (props.form.on_success === "redirect") {
          router.push(props.form.on_success_redirect_link || "/");
        }
      },
    }
  );
  const hookForm = useForm();

  const onSubmit = (data: Record<string, any>) => {
    mutate(data);
  };

  return isSuccess && props.form.on_success === "show_message" ? (
    <RenderSuccessMessage
      message={props.form.on_success_message || "Thanks for submitting!"}
    />
  ) : (
    <form
      className="grid grid-cols-12 gap-5"
      onSubmit={hookForm.handleSubmit(onSubmit)}
    >
      <p className="col-span-12 text-center text-xl font-bold">
        {props.form.name}
      </p>
      {(props.form?.elements as ApiCollections["form_components"][])?.map(
        (res) => (
          <div
            key={`directusComponent-${res}`}
            className={twMerge(`col-span-12`, `md:col-span-${res.col_span}`)}
          >
            <p className="mb-1">{res.label}</p>
            <RenderDirectusComponent element={res} hookForm={hookForm} />
          </div>
        )
      )}
      <div className="col-span-12 mx-auto">
        <Button type="submit" isLoading={isLoading}>
          Submit
        </Button>
      </div>
    </form>
  );
};

 

Implementation of our form builder - the contact form

Okay now we're ready to test our form builder. Start by creating a new form called Contact form, put all the elements you want in it, get its ID and display it.

// app/testpage/page.tsx

import { RenderDirectusForm } from "../../components/DirectusForm";
import { directus } from "../../lib/directus";

const getForm = async () => {
  return await directus
    .items("forms")
    .readOne("f9b8c1ea-de27-4458-a90c-92b204e76ee1", {
      fields: ["*", "elements.*"],
    })!;
};

const TestPage = async () => {
  const form = await getForm();
  return (
    <div className="container min-h-screen max-w-screen-xl">
      <div className="mx-auto w-1/2">
        <RenderDirectusForm form={form!} />
      </div>
    </div>
  );
};

export default TestPage;

Isn't that beautiful ? You now have generated a form that you created from Directus.

Form Generated From Directus

 

Conclusion

As you can see, it's quite easy to create a form builder using Directus. Its interface makes it easy to manage all your forms. One thing you can do to improve the form creation experience is hiding certain fields depending on the field type. For example, hide choices if type isn't select using Directus' Conditions.

Also, don't forget to play around with this base, add new features, new field types, new logics etc... You can do whatever you want!

You can find the Github Gists here.

Also built with Directus: