Tasks

Build a form

Define a database-stored form in code, seed it, and publish it at /forms/<slug> with email delivery.

What you'll build: a published contact form at /forms/contact. You define it in code and seed it into the database. It stores each submission and emails it to your team.

Prerequisites

The paths below are for the starter's apps/web.

Add a module installs Forms, enables NuxtHub KV and blob storage, sets its encryption key, gives its permissions to a role and generates the migration. Forms uses KV for rate limiting and blob storage for uploads. It installs Notify for its emails.

1. Write the definition

A form defined in code is versioned with your app, and the same in every environment. defineForm() from @frigear-nu/forms/utils types the definition as a FormDefinition: a mode, steps, fields, and where the answers go.

apps/web/forms/contact.ts
import { defineForm } from '@frigear-nu/forms/utils'

export const contactForm = defineForm({
  mode: 'single',
  storage: 'db',
  steps: [
    {
      id: 'message',
      fields: [
        { key: 'name', type: 'text', label: 'Name', required: true },
        { key: 'email', type: 'email', label: 'Email', required: true },
        {
          key: 'subject',
          type: 'select',
          label: 'Subject',
          options: [{ value: 'support', label: 'Support' }, { value: 'other', label: 'Other' }],
        },
        { key: 'message', type: 'textarea', label: 'Message', required: true },
      ],
    },
  ],
  submit: { successMessage: 'Thanks, we will be in touch.' },
  delivery: {
    email: {
      to: ['office@example.com'],
      subject: 'New message from {{ answer.name }}',
      replyToField: 'email',
    },
  },
})
  • Recipients. to takes literal addresses. Without a body, the email lists every visible answer.
  • Field keys. They start with a letter, and contain letters, digits and underscores.
  • Reply-to. replyToField must name an email field.
  • Access. access defaults to { availability: 'web', auth: 'none' }: a public page with no sign-in.
  • Delivery. The email goes through Notify. Notify prints it with the log driver until you configure a real one.
The submission is stored before the email is sent. So a delivery error never loses a submission.

2. Enable the pages and seed the form

Seeding puts the form in the database on start, so you need not create it by hand in the admin area. Add the pages and the seed to the forms options you set in Add a module:

apps/web/nuxt.config.ts
import { contactForm } from './forms/contact'

export default defineNuxtConfig({
  forms: {
    settings: {
      pages: { form: true, admin: true, adminForm: true, adminSubmissions: true, adminSubmission: true },
    },
    seed: {
      enabled: true, // or NUXT_FORMS_SEED_ENABLED=true
      forms: [{ slug: 'contact', name: 'Contact', status: 'published', definition: contactForm }],
    },
  },
})
  • status defaults to draft.
  • Seeding inserts a slug only when it does not exist yet. It never overwrites a form.
After the first seed, edit the form in the admin area, at /admin/forms. A changed definition in code does not reach a form that is already seeded.

3. Open the form

Restart pnpm dev. The seed runs on start.

To place the form inside any page instead, render <FormRenderer slug="contact" />.

What you should see: the form at /forms/contact. After you submit it, the submission is under /admin/forms, and the email is printed in the terminal.

Troubleshooting

SymptomCauseFix
/admin/forms refuses youNone of your roles grants admin:access and forms:manage.Grant them, as in Add a module.
/forms/contact is not foundThe seed is off, or the form is a draft.Set seed.enabled: true and status: 'published'.
No email arrivesNotify still uses the log driver.Configure a driver, as in Send an email.

Next steps

  • Forms – add conditional fields (showIf), steps, file uploads, captcha, open periods and access tokens.
  • Send an email – send email through a real provider instead of the terminal.
  • Frigear website app – see an app that seeds three forms and polices them with its own policy.
Copyright © 2026