dot CMS

How to Build A Schema Driven Form in dotCMS

How to Build A Schema Driven Form in dotCMS
Rafael

Rafael Velazco

Senior Software Engineer

Share this article on:

Forms play a crucial role in web development; they’re how users communicate, request information, and interact with your brand. From contact forms to registrations, they help shape the user experience.

dotCMS provides a content model called a Content Type; a defined data structure that represents a specific type of content within the system.

In this example, we’ll use the schema defined by a Content Type to generate a dynamic form. Then, we’ll use that same content model to submit and store the contact information directly in dotCMS.

In this tutorial, we’re going to explore how to build a form in dotCMS and render it dynamically in a Headless Next.js application.

If you’re looking to:

  • Understand how content types are structured and stored in dotCMS

  • Learn how  to render those forms in a React-based frontend (Next.js)

  • Use Workflow actions to create entry in dotCMS based on the form content

  • Implement dynamic field logic, like conditionally showing extra fields based on user input

Then you’re in the right place. We’ll walk step by step through the process of:

  1. Creating a Contact Us Content Type using dotCMS Content Types

  2. Fetching and rendering that form dynamically in a Next.js app

  3. Adding support for custom field logic (e.g. show more fields based on a selection)

Let’s get started!

Prerequisites

Before you begin building, ensure you have the following:

What We’re Building

In this guide, we’ll use dotCMS Content Types to create a form; then render it dynamically in a Next.js application.

Instead of hardcoding each field, we’ll build a flexible form renderer that can handle any type of content type schema created in dotCMS. On top of that, we’ll add support for interactive fields; like showing additional inputs based on user selections.

image5.gif

Make sure to start with the template project rjvelazco/dotcms-forms-example so you can easily follow along with the steps in this guide.

Creating the Content Type

To get started, we’ll create a Content Type in dotCMS that defines our form schema. Content Types let you define and manage structured content, including forms, using a visual layout editor.

Accessing the Content Type

To open the Content Type builder in dotCMS:

Content Model → Content Types → + (Add New) → Select "Content"

Give your form a name. For this tutorial, we’ll call it “Contact Us”; but you can use any name you like. You can also set an icon and description to help your team identify it later.

image1.gif

Designing the Content Type Model

Once created, dotCMS will take you to the Content Type Builder interface. You’ll see:

  • Some system fields (used internally, not shown to end users)

  • A drag-and-drop panel of available field types (on the right)

  • A layout area to build your form structure


For this tutorial, we’ll build a Contact Us form in Next.js with the following fields:

Field

Type

Required

First Name

Text

✅ Yes

Last Name

Text

✅ Yes

Email

Text (Email Validation)

✅ Yes

Phone

Text (US Number Validation)

❌ No

Reason for Contact

Select

✅ Yes

Terms and Conditions

Checkbox

✅ Yes


If you're new to the Content Type Builder, check out this video where we walk through the process step by step:

80

Content Type Layout

Once you're done, your Content Type should look similar to this:

image12.png

It’s okay if your layout is slightly different; what matters is that you’re comfortable using the Content Type Builder and understand how to structure a Content Type in dotCMS.

Building Headless Forms with dotCMS Content Types

Rendering a dotCMS form in a headless application is a powerful way to leverage dotCMS’s content model while giving you full control over the front-end experience.

To render a form, we need to retrieve the layout of the Content Type and dynamically render it using reusable components. The layout and field definitions can be retrieved using the dotCMS REST API, which returns the full content type structure, including rows, columns, and fields; in JSON format.

You can use the following endpoint to fetch the Content Type Layout:

GET ${YOUR_DOTCMS_URL}/api/v1/contenttype/id/{contentTypeId}

This returns a JSON object that includes a layout property. This layout contains all the structural information needed to render the form exactly as it was designed in dotCMS.

Let’s walk through how to build this renderer step by step.

Step 1: Get the Content Type Schema

We start by creating a component called DotCMSDynamicForm, which receives a contentTypeId and uses it to fetch the layout from dotCMS.

These components live in: src/app/components

📄 DotCMSDynamicForm.jsx

"use client";

import React, { useEffect, useState } from "react";

export const DotCMSDynamicForm = ({ contentTypeId }) => {
  const [layout, setLayout] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchLayout = async () => {
      try {
        const res = await fetch(
          `${YOUR_DOTCMS_URL}/api/v1/contenttype/id/${contentTypeId}`
        );
        const { entity } = await res.json();
        setLayout(entity.layout);
      } catch (err) {
        setError("Failed to load form layout.");
        console.error(err);
      }
    };

    fetchLayout();
  }, [contentTypeId]);

  if (error) return <div className="text-red-500">{error}</div>;
  if (!layout) return <div>Loading form...</div>;

  return <h1>Form</h1>;
};

💡 Replace YOUR_DOTCMS_URL with your actual environment URL.


This component takes a contentTypeId, that id is given by the contenttype variable name, if you follow the example correctly, the variable name for our content type is ContactUs.

If your Content Type Id or variable name is different, just update line 51 in the following file to match your setup:

File: src/app/page.js

<DotCMSDynamicForm contentTypeId="${YOUR_CONTENT_TYPE_ID}" /> 

Replace ${YOUR_CONTENT_TYPE_ID} with the correct ID for your form.

At this point, we’re simply fetching the layout and storing it in state. We'll use this layout to render the form structure next. If we check the browser, we'll see the component.

image14.gif

Step 2: Build the Layout Components

The layout is composed of rows, and each row contains one or more columns. Each column can contain multiple fields. To handle this structure cleanly, we’ll create three layout components:

  • Row: renders a row of the layout

  • Column: renders a column within a row

  • Field: renders individual fields inside a column

image7.png

These components live in: src/app/components/layout

📄 FormRow.jsx

"use client"

import React from "react";
import { FormColumn } from "./FormColumn";

// Maps number of columns to Tailwind grid classes
const getGridClass = (colCount) => {
  const classes = {
    1: "md:grid-cols-1",
    2: "md:grid-cols-2",
    3: "md:grid-cols-3",
    4: "md:grid-cols-4",
  };
  return classes[colCount] || "md:grid-cols-1";
};

export const FormRow = ({ row }) => {
  const columnCount = row.columns.length;
  const gridClass = getGridClass(columnCount);

  return (
    <div className={`grid gap-6 mb-4 ${gridClass}`}>
      {row.columns.map((col, idx) => (
        <FormColumn key={idx} column={col} />
      ))}
    </div>
  );
};

📄 FormColumn.jsx

"use client"

import React from "react";
import { FormField } from "./FormField";

export const FormColumn = ({ column }) => {
  return (
    <>
      {column.fields.map((field) => (
        <FormField key={field.id} field={field} />
      ))}
    </>
  );
};

📄 FormField.jsx

"use client"

// Render a single field component based on type
export const FormField = ({ field }) => {
  return <span className="p-4 bg-gray-100 mb-4">{field.fieldType}</span>
};

Once we have the layout, we can update our form component.

📄 DotCMSDynamicForm.jsx

"use client";

import React, { useState, useEffect } from "react";

import { FormRow } from "./layout/FormRow";

export const DotCMSDynamicForm = ({ contentTypeId }) => {
  const [layout, setLayout] = useState(null);
  const [error, setError] = useState(null);

  // Fetch Content Type layout from dotCMS API 
  useEffect(() => {
    const fetchLayout = async () => {
      try {
        const res = await fetch(
          `${YOUR_DOTCMS_URL}/api/v1/contenttype/id/${contentTypeId}`
        );
        const { entity } = await res.json();
        setLayout(entity.layout);
      } catch (err) {
        setError("Failed to load form layout.");
        console.error(err);
      }
    };

    fetchLayout();
  }, [contentTypeId]);

  if (error) return <div className="text-red-500">{error}</div>;
  if (!layout) return <div>Loading form...</div>;

  return (
    <form className="space-y-6">
      {layout.map((row, idx) => (
        <FormRow key={idx} row={row} />
      ))}

      <button
        type="submit"
        className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg py-3 transition-colors"
      >
        Submit
      </button>
    </form>
  );
};
Screenshot 2025-07-10 at 12.10.26PM.png

Great! We are getting there. Let’s now create some components for our fields.

Step 3: Build the Field Components

Each field in dotCMS includes a fieldType; such as Text, Select, Checkbox, or Textarea. These types are defined by dotCMS and come with their own structure and behavior. To learn more about how fields are represented and returned by the system, check out the dotCMS Content API documentation.

For us, the most important properties are fieldType, name, hint, id and regexCheck.

To render the Content Type fields, we’ll create the following components

  • TextField

  • SelectField

  • CheckboxField

  • TextareaField


These components should live in: src/app/components/fields

📄 CheckboxField.jsx

import { useState } from "react";

export const CheckboxField = ({ variable, name, required, hint }) => {
  const [isChecked, setIsChecked] = useState(false);

  const handleChange = (e) => {
    setIsChecked(e.target.checked);
  };

  return (
    <div className="flex items-center gap-2">
      <input
        type="checkbox"
        id={variable}
        checked={isChecked}
        onChange={handleChange}
        className="accent-blue-600"
        required={required}
      />

      {/* Hidden input ensures value is submitted even when checkbox is unchecked. Without this, HTML omits unchecked checkboxes from form data entirely */}
      <input
        type="hidden"
        name={variable}
        value={isChecked.toString()}
      />

      <label htmlFor={variable} className="font-medium">
        {hint || name}
        {required && <span className="text-red-500">*</span>}
      </label>
    </div>
  );
}

📄 SelectField.jsx

export const SelectField = ({ variable, name, required, values, hint }) => {
  /* Learn about Select Field Value format: https://dev.dotcms.com/docs/field-properties#Select */
  const options = (values || "").split("\n").map((v) => {
    const [label, value] = v.split("|");
    return { label, value };
  });

  return (
    <div>
      <label htmlFor={variable} className="block font-medium mb-1">
        {name}
        {required && <span className="text-red-500">*</span>}
      </label>
      <select
        id={variable}
        name={variable}
        required={required}
        className="block w-full border border-gray-300 rounded px-3 py-2 mb-1 focus:outline-none focus:ring-2 focus:ring-blue-400 bg-white"
      >
        {options.map(({ value, label }) => (
          <option key={value} value={value}>
            {label}
          </option>
        ))}
      </select>
      {hint && <div className="text-xs text-gray-500 mt-1">{hint}</div>}
    </div>
  );
};

📄 TextareaField.jsx

export const TextareaField = ({ variable, name, required, hint }) => {
  return (
    <div>
      <label htmlFor={variable} className="block font-medium mb-1">
        {name}
        {required && <span className="text-red-500">*</span>}
      </label>
      <textarea
        id={variable}
        name={variable}
        required={required}
        placeholder={hint || name}
        rows={4}
        className="block w-full border border-gray-300 rounded px-3 py-2 mb-1 focus:outline-none focus:ring-2 focus:ring-blue-400 bg-white"
      />
    </div>
  );
};

📄 TextField.jsx

export const TextField = ({ variable, name, required, hint }) => {
  return (
    <div>
      <label htmlFor={variable} className="block font-medium mb-1">
        {name}
        {required && <span className="text-red-500">*</span>}
      </label>
      <input
        id={variable}
        name={variable}
        required={required}
        placeholder={hint || name}
        className="block w-full border border-gray-300 rounded px-3 py-2 mb-1 focus:outline-none focus:ring-2 focus:ring-blue-400 bg-white"
      />
    </div>
  );
}

Then, inside the FormField component, we’ll map each fieldType to its corresponding component and render it dynamically.

Here’s how FormField works:

📄 FormField.jsx

import React from "react";
import { TextField } from "../fields/TextField";
import { SelectField } from "../fields/SelectField";
import { CheckboxField } from "../fields/CheckboxField";
import { TextareaField } from "../fields/TextareaField";

// Maps dotCMS field types to their corresponding components
const FIELD_COMPONENTS = {
  Text: TextField,
  Select: SelectField,
  Checkbox: CheckboxField,
  Textarea: TextareaField,
};

export const FormField = ({ field }) => {
  const FieldComponent = FIELD_COMPONENTS[field.fieldType];

  if (!FieldComponent) return null; // Skip unsupported fields

  return <FieldComponent {...field} />;
};

And that’s it!

We’ve built a Dynamic Form Component that takes a Content Type ID and renders a form in React. It works with any Content Type, making it fully reusable and flexible for different use cases.

image6.gif

Creating a “Contact Us” Entry in dotCMS Using Workflow Actions

As we’ve seen, we’re using the schema of the Contact Us Content Type to dynamically render a form in our React component. But that same schema can also be used to create actual contentlets in dotCMS.

Instead of manually adding contact entries in the dotCMS back office, we can let users submit this form; and create those contentlets automatically. To do that, we’ll use dotCMS workflow actions.

👉 Learn more about workflow actions

Step 1: Create a createContentlet utility

This utility function will live in: 📁 src/app/components/utils/createDotCMSContent.js

It takes the form data and content type ID, and sends a request to the NEW workflow action to create a new contentlet in dotCMS.

📄 createDotCMSContent.js

/**
 * Submit form data to dotCMS via workflow
 * @param {Object} formData - Data submitted from the form
 * @param {string} contentTypeId - The ID of the content type to create
 * @returns {Promise<Object>} - The dotCMS API response
 */
export async function createContentlet(formData, contentTypeId) {
  const contentletPayload = {
    contentlet: {
      contentType: contentTypeId,
      ...formData,
    },
  };

  const headers = { "Content-Type": "application/json" };

  const response = await fetch(
    `${YOUR_DOTCMS_URL}/api/v1/workflow/actions/default/fire/NEW`,
    {
      method: "PUT",
      headers,
      body: JSON.stringify(contentletPayload),
    }
  );

  if (!response.ok) {
    throw new Error(`Form submission failed: ${response.status}`);
  }

  return response.json();
}

💡 Replace YOUR_DOTCMS_URL with your actual environment URL.

Step 2: Update the Form Component to Submit to dotCMS

Now, let’s connect that utility to our dynamic form. When a user submits the form, we collect the data, convert it into an object, and pass it to createContentlet.

📄 DotCMSDynamicForm.jsx

"use client";

import React, { useState, useEffect } from "react";

import { FormRow } from "./layout/FormRow";
import { createContentlet } from "./utils/createDotCMSContent";

export const DotCMSDynamicForm = ({ contentTypeId }) => {
  const [layout, setLayout] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchLayout = async () => {
      try {
        const res = await fetch(
          `${YOUR_DOTCMS_URL}/api/v1/contenttype/id/${contentTypeId}`
        );
        const { entity } = await res.json();
        setLayout(entity.layout);
      } catch (err) {
        setError("Failed to load form layout.");
        console.error(err);
      }
    };

    fetchLayout();
  }, [contentTypeId]);

  const handleSubmit = async (e) => {
    e.preventDefault();
    const formData = new FormData(e.target);
    const data = Object.fromEntries(formData);

    try {
      await createContentlet(data, contentTypeId);
      alert("🎉 Contact submitted successfully!");
      e.target.reset();
    } catch (err) {
      setError("Failed to submit form.");
      console.error(err);
    }
  };

  if (error) return <div className="text-red-500">{error}</div>;
  if (!layout) return <div>Loading form...</div>;

  return (
    <form className="space-y-6" onSubmit={handleSubmit}>
      {layout.map((row, idx) => (
        <FormRow key={idx} row={row} />
      ))}

      <button
        type="submit"
        className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg py-3 transition-colors"
      >
        Submit
      </button>
    </form>
  );
};

Result: Your Form Now Creates Content in dotCMS

And that’s it! Now when a user submits the form, it will automatically create a new Contact Us entry in dotCMS using the NEW workflow action.

image4.gif

This makes your Headless forms not just dynamic but fully functional and integrated with your CMS workflow.

Adding a Smart Conditional Field

Our form is working well, but we want to make it smarter by showing or hiding fields based on user input.

In this example, we’ll show a follow-up question, “What kind of product are you interested in?” but only if the user selects “Pricing” as the reason for contact.

Step 1: Add the Conditional Field in dotCMS

First, go to your Contact Us Content Type in dotCMS and add a new field:

  • Title: Products

  • Field Type: Select (or whatever fits your use case)

  • Values: (e.g., “Cloud Server | cloud”, “Enterprise Edition | enterprise”, etc.)

image8.gif

Next, let’s configure the Field Variables to define when this field should appear.

📘 Learn more about field variables

Go to the newly created field → open its settings → click on the Field Variables tab and add the following:

Key

Value

conditionalDisplay

{ "dependsOn": { "field": "reasonForContact", "value": "pricing" } } 


This will tell the frontend: “Only display this field if the reasonForContact field is set to pricing.”

image9.gif

That’s all we need to configure in dotCMS; let’s jump back into Next.js.

Step 2: Add the Conditional Logic in Next.js

By default, all fields are shown in our form.

image10.png

Let’s fix that by adding a custom hook that determines whether a field should be rendered or not; based on the value of another field in the same form.

This hook will live in: 📁 src/app/components/hooks/useConditionalDisplay.js

📄 useConditionalDisplay.js

import { useState, useEffect } from "react";

/**
 * Hook: useConditionalDisplay
 * Determines whether a field should be visible based on fieldVariables.
 */
export function useConditionalDisplay(fieldVariables = []) {
  const [formValues, setFormValues] = useState({});
  const [isVisible, setIsVisible] = useState(true);

  // Step 1: Parse the field variable
  const variable = fieldVariables.find((v) => v.key === "conditionalDisplay");

  let conditionalConfig = {};
  try {
    conditionalConfig = JSON.parse(variable?.value || "{}");
  } catch {
    console.warn("Invalid JSON in conditionalDisplay");
  }

  const { dependsOn } = conditionalConfig || {};

  // Step 2: Listen for any input/selection change in the form
  useEffect(() => {
    if (!dependsOn) return;
    const handleFormChange = (event) => {
      const { name, value } = event.target;
      setFormValues((prev) => ({ ...prev, [name]: value }));
    };

    const form = document.querySelector("form");
    form?.addEventListener("change", handleFormChange);
    form?.addEventListener("input", handleFormChange);

    return () => {
      form?.removeEventListener("change", handleFormChange);
      form?.removeEventListener("input", handleFormChange);
    };
  }, [dependsOn]);

  // Step 3: Determine if this field should be shown
  useEffect(() => {
    if (!dependsOn) return;

    const shouldShow = formValues[dependsOn.field] === dependsOn.value;
    setIsVisible(shouldShow);
  }, [formValues, dependsOn]);

  return isVisible;
}

Step 3: Use the Hook Inside Your Field Component

Now, inside the field component (e.g., SelectField.jsx, TextField.jsx), use the hook to control whether or not to render the field. Let's use this hook in our Select Field component.

📄 SelectField.jsx

import { useConditionalDisplay } from "../hooks/useConditionalDisplay";

export const SelectField = ({ variable, name, required, values, hint, fieldVariables }) => {

  const isVisible = useConditionalDisplay(fieldVariables);

  if (!isVisible) return null;

  /* Learn about Select Field Value format: https://dev.dotcms.com/docs/field-properties#Select */
  const options = (values || "").split("\n").map((v) => {
    const [label, value] = v.split("|");
    return { label, value };
  });

  return (
    <div>
      <label htmlFor={variable} className="block font-medium mb-1">
        {name}
        {required && <span className="text-red-500">*</span>}
      </label>
      <select
        id={variable}
        name={variable}
        required={required}
        className="block w-full border border-gray-300 rounded px-3 py-2 mb-1 focus:outline-none focus:ring-2 focus:ring-blue-400 bg-white"
      >
        {options.map(({ value, label }) => (
          <option key={value} value={value}>
            {label}
          </option>
        ))}
      </select>
      {hint && <div className="text-xs text-gray-500 mt-1">{hint}</div>}
    </div>
  );
};

Done! Your Field is Now Conditional

Now, when the user selects “Pricing” as the reason for contact, the “Products” field will appear automatically.

image5.gif

Integrating with the dotCMS React SDK

Now that we’ve built a dynamic form component, let’s integrate it with the dotCMS React SDK and use it inside a real dotCMS page.

💡 Remember to add/port the DotCMSDynamicForm component in this example.

📚 Useful links to get familiar with the SDK:

Step 1: Create a Widget to Select the Content Type

Create a widget called Content Type Form with one fields:

  • Content Type to Render: A field that lets you pick the Content Type ID

CREATE WIDGET TO RENDER FORM.gif

💡 The selector stores the Content Type ID  or Variable Name to render

Step 2: Add the Widget to a Page

Now let’s add this widget to a real dotCMS page. We’ll use a new page called Contact Us Page Example and place the Content Type Form widget on it.

ADD_WIDGET_TO_PAGE.gif

Step 3: Create a React Component to Render the Form

Inside teh example project , create a new file called ContentTypeForm.js and render the form using our DotCMSDynamicForm component.

📄 Path: src/components/content-types/ContentTypeForm.js

import { DotCMSDynamicForm } from "../PATH_WHERE_YOU_IMPORTED_THE_COMPONET";

/**
 * Renders a form using the dotCMSDynamicForm component
 * @param {Object} contentlet - Widget data from dotCMS
 */
function ContentTypeForm(contentlet) {
  const { contentTypeToRender } = contentlet;
  return <DotCMSDynamicForm contentTypeId={contentTypeToRender} />;
}

export default ContentTypeForm;

To let the SDK know how to render this custom widget, add your CustomForm component to the component map.

📄 Path: src/components/content-types/index.js

import ContentTypeForm from "./ContentTypeForm";

export const pageComponents = {
  // ...other components
  ContentTypeForm: ContentTypeForm
};

📚 Learn more about component mapping: SDK Component Mapping Docs

SECTION_RESULT.gif

Bonus: Add React Hook Form for State Management

So far, we’ve built a flexible dynamic form that pulls layout and field info from dotCMS and renders it dynamically in Next.js. But what if you want better control over the form state, for things like validation, submission handling, or tracking field values?

That’s where React Hook Form shines.

Let’s walk through how you can integrate it into your DotCMSDynamicForm and field components.

Step 1: Set up useForm in the main form component

We initialize react-hook-form inside our DotCMSDynamicForm, and then pass the register function down to all child fields using React Context.

"use client";

import React, { useState, useEffect, createContext } from "react";
import { useForm } from "react-hook-form";
import FormRow from "./layout/FormRow";

// Create a context to share the register function
export const FormRegisterContext = createContext(null);

export const DotCMSDynamicForm = ({ contentTypeId }) => {
  const { register, handleSubmit } = useForm();
  /* ...Keep the current logic for the form */

  return (
    <FormRegisterContext.Provider value={register}>
      <form onSubmit={handleSubmit(onSubmit)} autoComplete="off" className="space-y-6">
        {layout.map((row, idx) => (
          <FormRow key={idx} row={row} />
        ))}
        <button
          type="submit"
          className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg py-3 transition-colors"
        >
          Submit
        </button>
      </form>
    </FormRegisterContext.Provider>
  );
};

Step 2: Register each field in your input components

Now, inside any field component like TextField, you can access the register function using useContext and connect the input to React Hook Form.

import React, { useContext } from "react";
import { FormRegisterContext } from "../DotCMSDynamicForm";

export default function TextField({ variable, name, required, hint, regexCheck }) {
  const register = useContext(FormRegisterContext);

  return (
    <div>
      <label htmlFor={variable} className="block font-medium mb-1">
        {name}
        {required && <span className="text-red-500">*</span>}
      </label>
      <input
        {...register(variable)} 
        // ... other props
      />
    </div>
  );
}

Why this matters

By using react-hook-form:

  • 🧠 You avoid manually handling field states (useState for every field).

  • ✅ You get built-in validation, error handling, and form submission control.

  • 📦 You can integrate this with APIs or backends easily using onSubmit.

Conclusion

By combining the power of dotCMS’s visual Content Type Builder with the flexibility of a Headless Next.js frontend, we've created a fully dynamic form rendering system; one that’s easy to manage for content editors and powerful for developers.

In this tutorial, we:

  • Built a form in Next.js using a dotCMS Content Type Schema.

  • Fetched and rendered the Content Type Layout dynamically in a Next.js component

  • Leveraged fieldVariables to add conditional logic (like showing sub-fields)

  • Created reusable, beginner-friendly components to structure the form cleanly

This approach allows you to separate content from code, giving non-technical users control over form structure, while keeping your frontend logic clean, flexible, and scalable.

As a next step, you could:

  • Add support for more field types (date pickers, radio buttons, file uploads)

  • Add client-side validation and error handling