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:
Creating a Contact Us Content Type using dotCMS Content Types
Fetching and rendering that form dynamically in a Next.js app
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:
dotCMS instance: Access to a dotCMS instance (Evergreen recommended)
For testing: You can use the dotCMS demo site
For production: Sign up for a dotCMS instance
Node.js and npm: If you don’t have these installed, follow the Node.js Installation
Next App running: To follow along with this example, you can use the same starter project we’ll be working with: rjvelazco/dotcms-forms-example.
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.

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.

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 |
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:
Content Type Layout
Once you're done, your Content Type should look similar to this:

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.

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

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>
);
};

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.

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.

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.)

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.”

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.

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.

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:
GitHub Example: nextjs example
SDK Docs: @dotcms/react documentation
NPM Package: @dotcms/react
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

💡 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.

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

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