Build Forms Dynamically using Directus and Nuxt
Directus' data studio allows you to create data for your collections. You can then leverage these collections with Nuxt to generate dynamic and interactive forms for your application.
Before You Start
You will need:
- A Directus project with admin access.
- Fundamental understanding of Nuxt concepts.
- Optional but recommended: Familiarity with data modeling in Directus.
Set Up Your Directus Project
Create a Collection
Create a new collection called posts
with the following fields:
title
(Type: String) *requiredcontent
(Type: markdown) *requiredslug
(Type: String) *requiredcategory
(Type: Dropdown with options ofblog post
,tutorial
andannouncement
)published
(Type: datetime)
On the data model page for the posts
collection click on the 3 dots next to the slug
field and select half-width
to make the field half the width of the form. Repeat the same for the category
field.
Add slug validation
Directus has a default setting that can be applied to the slug field to ensure that only URL safe characters are used however for the sake of illustration we are going to add our own validation to this field which can be shared with Nuxt on the frontend.
Click on the slug
field then click Validation and create a Regex validation with the expression ^[a-z0-9]+(?:-[a-z0-9]+)*$
. Add a custom validation message Slug must use URL valid characters
and save.
Edit Public Policy
To give your Nuxt project access to the posts collection and the field listing, you need to update an Access Policy. Here we will update the public policy as it means we can skip any authentication. In a real project you would want to create a new policy and authenticate to Directus.
Navigate to Settings -> Access Policies -> Public and grant full create
and read
access to the posts
collection and full read
access to the directus_fields
collection.
Set Up Your Nuxt Project
Initialize Your Project
Create a new Nuxt project using Nuxi. Note: You can skip selecting any of the add-on modules, none are required for this project.
npx nuxi@latest init directus-dynamic-forms
cd directus-dynamic-forms
Inside your Nuxt project, install the Directus SDK package by running:
npm install @directus/sdk
Configuring Nuxt
With Nuxt installed with the Directus SDK you can now configure our project to connect to Directus.
Create a .env
file with the Directus URL:
API_URL="http://0.0.0.0:8055"
Add a type definition for your new environment variable by creating an env.d.ts
file with the following content:
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly API_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
Depending on your project configuration and if you are in development or production you may need to configure a Nuxt proxy to allow access between your Nuxt application and Directus in your nuxt.config.ts
:
routeRules: {
"/directus/**": { proxy: `${import.meta.env.API_URL}/**` },
},
This will allow your Nuxt application to access Directus via your Nuxt URL, eg. http://localhost:3000/directus
Define a Directus Schema
TypeScript needs to know what the structure of the Directus data is. To achieve this create a directus.d.ts
file in the root of our project which defines our schema:
/// <reference types="@directus/extensions/api.d.ts" />
interface DirectusSchema {
posts: Post[];
}
interface Post {
id: number;
title: string;
content: string;
slug?: string;
category?: string;
published?: DateTime;
}
Create a Directus plugin
Create a Nuxt plugin to streamline accessing Directus throughout your application. Create a new file plugins/directus.ts
Copy and paste in the code below, replace the your-website-url
with your Nuxt URL and port (eg. localhost:3000):
import {
createDirectus,
rest,
readFieldsByCollection,
createItem,
} from "@directus/sdk";
const directus = createDirectus<DirectusSchema>(
"http://your-website-url/directus",
).with(rest());
export default defineNuxtPlugin(() => {
return {
provide: { directus, readFieldsByCollection, createItem },
};
});
This file handles all the interaction with Directus and provides Nuxt with the required Directus SDK features.
Query the Collection's Fields
Configure Nuxt to use the page router by editing app.vue
replacing the content with:
<template>
<div>
<NuxtRouteAnnouncer />
<NuxtPage />
</div>
</template>
This allows us to create a new page in the pages
directory. Create a new file pages/posts/new.vue
and add the following code:
<script setup lang="ts">
const { $directus, $readFieldsByCollection } = useNuxtApp()
const { data, error } = await useAsyncData('posts', async () => {
return $directus.request($readFieldsByCollection('posts'))
})
if (error.value || data.value === null || data.value.length === 0) {
console.error(error)
throw createError({
statusCode: 404,
statusMessage: "Page not found"
})
}
const postFields = data
</script>
<template>
<div>
{{ postFields }}
</div>
</template>
<style scoped>
form {
display: flex;
flex-wrap: wrap;
width: 400px;
}
</style>
<style>
label {
display: block;
margin-top: 0.5em;
margin-bottom: 0.2em;
}
select {
width: 100%;
}
</style>
This code will query the Directus API for the fields of the posts
collection (using the readFieldsByCollection
from the Directus SDK) and display them on the page.
Start the Nuxt project using npm run dev
and you can see what the response looks like by visiting http://your-website-url/posts/new in your browser. You should see something like this:
[ { "collection": "posts", "field": "id", "type": "integer", "schema": { "name": "id", "table": "posts", "data_type": "integer", "default_value": null, "max_length": null, "numeric_precision": null, "numeric_scale": null, "is_generated": false, "generation_expression": null, "is_nullable": false, "is_unique": false, "is_indexed": false, "is_primary_key": true, "has_auto_increment": true, "foreign_key_column": null, "foreign_key_table": null }, "meta": { "id": 1, "collection": "posts", "field": "id", "special": null, "interface": "input", "options": null, "display": null, "display_options": null, "readonly": true, "hidden": true, "sort": 1, "width": "full", "translations": null, "note": null, "conditions": null, "required": false, "group": null, "validation": null, "validation_message": null } }, { "collection": "posts", "field": "title", "type": "string", "schema": { "name": "title", "table": "posts", "data_type": "varchar", "default_value": null, "max_length": 255, "numeric_precision": null, "numeric_scale": null, "is_generated": false, "generation_expression": null, "is_nullable": true, "is_unique": false, "is_indexed": false, "is_primary_key": false, "has_auto_increment": false, "foreign_key_column": null, "foreign_key_table": null }, "meta": { "id": 2, "collection": "posts", "field": "title", "special": null, "interface": "input", "options": { "placeholder": "Post title" }, "display": null, "display_options": null, "readonly": false, "hidden": false, "sort": 2, "width": "full", "translations": null, "note": null, "conditions": null, "required": true, "group": null, "validation": null, "validation_message": null } }, { "collection": "posts", "field": "content", "type": "text", "schema": { "name": "content", "table": "posts", "data_type": "text", "default_value": null, "max_length": null, "numeric_precision": null, "numeric_scale": null, "is_generated": false, "generation_expression": null, "is_nullable": true, "is_unique": false, "is_indexed": false, "is_primary_key": false, "has_auto_increment": false, "foreign_key_column": null, "foreign_key_table": null }, "meta": { "id": 3, "collection": "posts", "field": "content", "special": null, "interface": "input-rich-text-md", "options": null, "display": null, "display_options": null, "readonly": false, "hidden": false, "sort": 3, "width": "full", "translations": null, "note": null, "conditions": null, "required": true, "group": null, "validation": null, "validation_message": null } }, { "collection": "posts", "field": "slug", "type": "string", "schema": { "name": "slug", "table": "posts", "data_type": "varchar", "default_value": null, "max_length": 255, "numeric_precision": null, "numeric_scale": null, "is_generated": false, "generation_expression": null, "is_nullable": true, "is_unique": false, "is_indexed": false, "is_primary_key": false, "has_auto_increment": false, "foreign_key_column": null, "foreign_key_table": null }, "meta": { "id": 4, "collection": "posts", "field": "slug", "special": null, "interface": "input", "options": null, "display": null, "display_options": null, "readonly": false, "hidden": false, "sort": 4, "width": "full", "translations": null, "note": null, "conditions": null, "required": true, "group": null, "validation": null, "validation_message": null } }, { "collection": "posts", "field": "category", "type": "string", "schema": { "name": "category", "table": "posts", "data_type": "varchar", "default_value": null, "max_length": 255, "numeric_precision": null, "numeric_scale": null, "is_generated": false, "generation_expression": null, "is_nullable": true, "is_unique": false, "is_indexed": false, "is_primary_key": false, "has_auto_increment": false, "foreign_key_column": null, "foreign_key_table": null }, "meta": { "id": 5, "collection": "posts", "field": "category", "special": null, "interface": "select-dropdown", "options": { "choices": [ { "text": "Blog post", "value": "blog_post" }, { "text": "Tutorial", "value": "tutorial" }, { "text": "Announcement", "value": "announcement" } ] }, "display": null, "display_options": null, "readonly": false, "hidden": false, "sort": 5, "width": "full", "translations": null, "note": null, "conditions": null, "required": true, "group": null, "validation": null, "validation_message": null } }, { "collection": "posts", "field": "published", "type": "dateTime", "schema": { "name": "published", "table": "posts", "data_type": "datetime", "default_value": null, "max_length": null, "numeric_precision": null, "numeric_scale": null, "is_generated": false, "generation_expression": null, "is_nullable": true, "is_unique": false, "is_indexed": false, "is_primary_key": false, "has_auto_increment": false, "foreign_key_column": null, "foreign_key_table": null }, "meta": { "id": 6, "collection": "posts", "field": "published", "special": null, "interface": "datetime", "options": null, "display": null, "display_options": null, "readonly": false, "hidden": false, "sort": 6, "width": "full", "translations": null, "note": null, "conditions": null, "required": false, "group": null, "validation": null, "validation_message": null } } ]
This data contains all the information about the fields in the collection. Make special note of the meta field interface
which identifies the form element to be used to manage the field. In this example we have 4 different interfaces:
- input
- input-rich-text-md
- select-dropdown
- datetime
Build a form from the data
From the interfaces identified above we can create a Nuxt component for each but first lets create a generic component that can handle the different interfaces. In components/DirectusFormElement.vue
add the following code:
<script setup lang="ts">
import type { DirectusField } from '@directus/sdk'
import Input from './Input.vue'
import TextArea from './TextArea.vue'
import Select from './Select.vue'
import DateTime from './DateTime.vue'
const props = defineProps<{
field: DirectusField
}>()
const fieldLookup = computed(() => {
switch (props.field.meta.interface) {
case 'input':
return Input
case 'input-rich-text-md':
return TextArea
case 'select-dropdown':
return Select
case 'datetime':
return DateTime
default:
return Input
}
})
</script>
<template>
<component v-if="!field.meta.hidden" :key="field.field" :is="fieldLookup" :field="field.field"
:width="field.meta.width" :defaultValue="field.schema.default_value" :label="field.meta.field"
:options="field.meta.options" />
</template>
This component will take a field and render the appropriate custom component based on the interface
meta field. If the field is hidden (based on the value of field.meta.hidden
) it will not render a component.
We can add it to the new.vue
page by updating the <template>
section as follows:
<template>
<h1>New Post</h1>
<form>
<DirectusFormElement v-for="field in postFields" :key="field.field" :field="field" />
</form>
</template>
This is looping through all the fields in the posts
collection and rendering the appropriate Nuxt form element for each field.
Now we can create the individual components for each interface.
Each component will use properties from the Directus field to assist with rendering the form element. For example, the input
interface will use the default_value
and label
properties to set the default value and label of the input field. We also maintain the form layout from directus via the half and full widths.
input
Create a new file components/Input.vue
and add the following code:
<script setup lang="ts">
const props = defineProps<{
defaultValue?: string
label?: string
options: Record<string, any> | null
width: string | null
field: string
required: boolean
modelValue?: string
}>()
const emit = defineEmits(['update:modelValue'])
const fieldWidth = props.width === 'full' ? '100%' : '50%'
const inputValue = computed({
get() {
return props.modelValue || props.defaultValue || ''
},
set(value) {
emit('update:modelValue', value)
}
})
</script>
<template>
<div :style="'width: ' + fieldWidth + ';'">
<label for="field">{{ label }}</label>
<input :name="field" v-model="inputValue" type="text" :required="required" />
</div>
</template>
<style scoped>
input {
width: 100%;
}
</style>
input-rich-text-md
We will use a text area to handle the markdown content, in reality you would want to select a markdown WYSIWYG component.
Create a new file components/TextArea.vue
and add the following code:
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
defaultValue?: string
label?: string
width: string | null
field: string
required: boolean
options: Record<string, any> | null
modelValue?: string
}>()
const emit = defineEmits(['update:modelValue'])
const fieldWidth = props.width === 'full' ? '100%' : '50%'
const textAreaValue = computed({
get() {
return props.modelValue || props.defaultValue || ''
},
set(value) {
emit('update:modelValue', value)
}
})
</script>
<template>
<div :style="'width: ' + fieldWidth + ';'">
<label for="field">{{ label }}</label>
<textarea :name="field" v-model="textAreaValue" rows="12" :required="required" />
</div>
</template>
<style scoped>
textarea {
width: 100%;
}
</style>
select-dropdown
This component is similar to the others but it makes use of the options
property to populate the select dropdown options.
Create a new file components/Select.vue
and add the following code:
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
defaultValue?: string
label?: string
options: Record<string, any>
width: string | null
field: string
required: boolean
modelValue?: string
}>()
const emit = defineEmits(['update:modelValue'])
const fieldWidth = props.width === 'full' ? '100%' : '50%'
const selectValue = computed({
get() {
return props.modelValue || props.defaultValue || ''
},
set(value) {
emit('update:modelValue', value)
}
})
</script>
<template>
<div :style="'width: ' + fieldWidth + ';'">
<label for="field">{{ label }}</label>
<select :name="field" v-model="selectValue" :required="required">
<option v-for="choice in options.choices" :key="choice.value" :value="choice.value">{{ choice.text }}
</option>
</select>
</div>
</template>
datetime
Create a new file components/DateTime.vue
and add the following code:
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
defaultValue?: string
label?: string
width: string | null
field: string
required: boolean
options: Record<string, any> | null
modelValue?: string
}>()
const emit = defineEmits(['update:modelValue'])
const fieldWidth = props.width === 'full' ? '100%' : '50%'
const dateTimeValue = computed({
get() {
return props.modelValue || props.defaultValue || ''
},
set(value) {
emit('update:modelValue', value)
}
})
</script>
<template>
<div :style="'width: ' + fieldWidth + ';'">
<label for="field">{{ label }}</label>
<input :name="field" v-model="dateTimeValue" type="datetime-local" :required="required" />
</div>
</template>
<style scoped>
input {
width: 100%;
}
</style>
If you visit the create post page at http://your-website-url/posts/new you should see a form with the fields from the posts
collection.
Validate and Save the Data
While Directus validates data on the server-side we can use the validations from the field data to validate on the client side. You can see from the component code above that the components are already checking the Directus required
property and adding this to the form element to use default HTML validation.
When we created the posts
collection we added a regular expression valadation to the slug
field. We can use this validation on the client side to ensure the slug is URL safe before submitting it to Directus.
Doing this will require changes to the pages/new.vue
file to add validation and submission logic:
<script setup lang="ts">
const { $directus, $readFieldsByCollection, $createItem } = useNuxtApp()
const { data, error } = await useAsyncData('posts', async () => {
return $directus.request($readFieldsByCollection('posts'))
})
if (error.value || data.value === null || data.value.length === 0) {
console.error(error)
throw createError({
statusCode: 404,
statusMessage: "Page not found"
})
}
const postFields = data
const form = ref({})
const formError: Ref<string | null> = ref(null)
const formSuccess: Ref<string | null> = ref(null)
const submitForm = async () => {
formError.value = null
formSuccess.value = null
// Validate form data against field validation rules
for (const field of postFields.value) {
if (field.meta?.validation) {
try {
const validation = field.meta.validation
if (validation._and) {
for (const rule of validation._and) {
const fieldName = Object.keys(rule)[0]
if (rule[fieldName]._regex) {
const regex = new RegExp(rule[fieldName]._regex)
if (!regex.test(form.value[field.field])) {
formError.value = field.meta.validation_message || field.meta.field + ' failed validation'
console.error(`Validation failed for ${field.field}: ${field.meta.validation_message || 'Invalid format'}`)
return // Stop submission if validation fails
}
}
}
}
} catch (err) {
console.error(`Error parsing validation for ${field.field}:`, err)
}
}
}
const result = await $directus.request($createItem('posts', form.value))
if (result.error) {
formError.value = result.error.message
console.error('Error creating post:', result.error)
return // Stop submission if error occurs
}
formSuccess.value = 'Post created successfully'
}
</script>
<template>
<h1>New Post</h1>
<div v-if="formError" class="error">{{ formError }}</div>
<div v-else-if="formSuccess" class="success">{{ formSuccess }}</div>
<form @submit.prevent="submitForm">
<DirectusFormElement v-for="field in postFields" :key="field.field" :field="field"
v-model="form[field.field]" />
<button type="submit">Submit</button>
</form>
</template>
<style scoped>
form {
display: flex;
flex-wrap: wrap;
width: 400px;
}
button {
margin-top: 1em;
}
.error {
color: red;
}
.success {
color: green;
}
</style>
<style>
label {
display: block;
margin-top: 0.5em;
margin-bottom: 0.2em;
}
select {
width: 100%;
}
</style>
The form will now allow submission of the data to Directus but will first go through a validation process in the submitForm
function. This function loops through each field looking for Directus validation rules and then executes them. If any validation fails the form will not submit and an error message will be displayed.
This example is limited to the regex validation we added to the slug
field. You can expand this to include other validation rules from Directus.
Summary
The readFieldsByCollection
method from the Directus SDK allows you to query the fields of a collection and dynamically generate fully validated forms in Nuxt.
From here you could expand the example to include more complex validation rules, custom form elements, an edit form or dynamically display and handle relational data.