Dyrected
Editor ExperienceCustom Components

Custom Create & Edit Forms

Replace a collection's create or edit screen with your own component, such as a walk-in desk or a review form, while saving still runs Dyrected's validation, access rules, and hooks.

Dyrected builds the create and edit screens from your field schema, and most of the time that is exactly what you want. Sometimes the job is different: a guest walks up to the door without an RSVP, and the desk needs three inputs and one button, not the whole form. For those screens you can hand Dyrected your own component and keep everything that makes saving safe.

By the end of this page you will have a walk-in form for the guest-responses collection. It shows three of the built-in inputs, saves the guest as attending and already checked in with one button, and goes through the same create path as the default form.

This page builds on the Custom Components overview. The examples use the guest list from Operational Views: a guest-responses collection with name, email, guestCount, attending, checkedIn, and checkedInAt fields.

What you write and what Dyrected keeps

Your component owns the body of the screen. Saving is not something you rebuild:

Dyrected keepsYour component does
Validation, access rules, hooks, and drafts on every saveDecide what the editor sees and when to save
The built-in inputs, with labels, conditions, widths, and read-only rulesPlace the inputs you want with <DyrectedField>
Success and error handling, and server errors on the right fieldCall submit() and react to the result
The page header, and optionally the Save barDraw everything else

submit() is a client of the same write API the default form uses. A custom form cannot skip a hook or an access rule.

Step 1: declare the form

Point the collection at a component key in admin.components. The key is yours to name, and you register it in step 3.

import { defineCollection } from "@dyrected/core";

export const guestResponses = defineCollection({
  slug: "guest-responses",
  admin: {
    useAsTitle: "name",
    components: {
      createForm: "guest-responses.walkIn",
      formShell: "header",
    },
  },
  fields: [
    // name, email, guestCount, attending, checkedIn, checkedInAt …
  ],
});

You can set three keys. Dyrected picks one by what the editor is doing:

SettingUsed when
createFormCreating a record. Falls back to form, then the built-in form
editFormEditing a record. Falls back to form, then the built-in form
formFallback for both

formShell decides how much of the page stays around your form. We use "header" here because the walk-in form brings its own button. See Choosing the shell.

Step 2: write the component

// WalkInForm.tsx
import { useState } from "react";
import { DyrectedField, useAdminNotify, useDocumentForm } from "@dyrected/react";

export function WalkInForm() {
  const { submit, permissions, cancel } = useDocumentForm();
  const notify = useAdminNotify();
  const [saving, setSaving] = useState(false);

  async function registerAndCheckIn() {
    setSaving(true);
    const result = await submit({
      overrides: { attending: true, checkedIn: true, checkedInAt: new Date().toISOString() },
      redirect: "list",
      silent: true,
    });
    setSaving(false);

    if (result.ok) {
      notify.success("Guest registered and checked in");
    } else {
      notify.error(result.errors.name ?? result.errors._form ?? "Could not register this guest");
    }
  }

  return (
    <div>
      <h2>Walk-in guest</h2>
      <DyrectedField path="name" />
      <DyrectedField path="email" />
      <DyrectedField path="guestCount" />

      <button type="button" onClick={cancel}>
        Cancel
      </button>
      <button type="button" disabled={saving || !permissions.canCreate} onClick={() => void registerAndCheckIn()}>
        {saving ? "Registering…" : "Register and check in"}
      </button>
    </div>
  );
}

Four things in there are worth noticing:

  • <DyrectedField path="name" /> renders the real name input, with its label, required marker, and validation message. You did not rebuild any of it.
  • overrides sets attending, checkedIn, and checkedInAt for this one save. They are not inputs the editor sees; they are what "walk-in" means. Overrides are validated like any other value, and access rules still apply on the server.
  • submit() never throws. It resolves to { ok: true, doc } or { ok: false, errors }, so you branch on result.ok.
  • silent: true skips Dyrected's own success and error toasts, because this form shows its own. Leave it out if you want the default toasts.

The example has no styling on purpose. To match the admin in light and dark mode, see Styling Custom Components.

Step 3: register it

Pass the component under the same key in components.forms:

"use client";

import { DyrectedAdmin } from "@dyrected/next/admin";
import { WalkInForm } from "./WalkInForm";

export function AdminClient() {
  return <DyrectedAdmin components={{ forms: { "guest-responses.walkIn": WalkInForm } }} />;
}

In Next.js, components are functions, so they cannot be passed from a server component. Keep the registration in a small client component like this one.

Open Guest Responses and choose New. You should see Dyrected's header and your three inputs, with no default Save bar. Leave the name empty and press Register and check in: the required message appears under the name input and nothing is saved. Fill it in and press the button, and you land on the list with the new guest checked in.

Choosing the shell

formShell controls the chrome around your component.

formShellDyrected rendersUse it when
full (default)Header, status badge, and the default Save barYour form only re-lays-out the body. Saving and navigation keep working
headerHeader onlyYour form has its own buttons, like the walk-in desk
noneNothingA full-screen tool that needs the whole page

full is the default because it is the least surprising: swap the body and Save still works. In the full shell, the Save bar saves exactly as it does for the default form, so a form that never calls submit() still saves.

formShell applies to both createForm and editForm of a collection. If one screen wants a Save bar and the other does not, choose header and give the form that needs it its own Save button.

Keeping the default form and changing part of it

You rarely need to replace the whole body. <DyrectedDefaultForm> renders everything Dyrected normally shows, tabs and change-password section included, and exclude leaves out the fields you want to handle yourself. This editForm adds an arrival card above the default form:

// GuestEditForm.tsx
import { useState } from "react";
import { DyrectedDefaultForm, useAdminNotify, useDocumentForm } from "@dyrected/react";

export function GuestEditForm() {
  const { document, submit, permissions, form } = useDocumentForm();
  const notify = useAdminNotify();
  const [saving, setSaving] = useState(false);
  const checkedIn = Boolean(document?.checkedIn);

  async function toggleCheckIn() {
    setSaving(true);
    const result = await submit({
      overrides: checkedIn
        ? { checkedIn: false, checkedInAt: null }
        : { checkedIn: true, checkedInAt: new Date().toISOString() },
      silent: true,
    });
    setSaving(false);
    if (result.ok) notify.success(checkedIn ? "Check-in undone" : "Checked in");
    else notify.error(Object.values(result.errors)[0] ?? "Could not update the check-in");
  }

  return (
    <div>
      <section>
        <p>{checkedIn ? "Arrived" : "Not arrived yet"}</p>
        <button type="button" disabled={saving || !permissions.canUpdate} onClick={() => void toggleCheckIn()}>
          {checkedIn ? "Undo check-in" : "Check in"}
        </button>
      </section>

      <DyrectedDefaultForm exclude={["checkedIn", "checkedInAt"]} />

      <button
        type="button"
        disabled={!form.isDirty || !permissions.canUpdate}
        onClick={() => void submit()}
      >
        Save changes
      </button>
    </div>
  );
}

Register it under editForm: "guest-responses.edit". With formShell: "header" it needs its own Save button, which is the last one above. Under the default full shell you would leave that button out and use Dyrected's Save bar.

The embedding components

ComponentRenders
<DyrectedField path="name" />One built-in input. Nested paths such as address.city work too
<DyrectedFields names={["name", "email"]} />Several inputs, in the order you give, with the schema's widths and conditions. Use exclude instead of names to render every field except some
<DyrectedDefaultForm exclude={["note"]} />The whole default body: tabs, fields, and the change-password section

They keep a field's admin.condition, admin.readOnly, access rules, and its own admin.component override, because they render through the same field renderer as the default form. They can only be used inside a registered form.

What useDocumentForm() gives you

It composes useDyrectedForm(), so useField(path) and useDyrectedForm() keep working in your form.

FieldWhat it gives you
mode"create" or "edit"
collectionThe collection (or global) schema
documentThe saved record. undefined on create, and while an edit loads
isLoadingDocumenttrue while an edit loads
presentation"page" or "drawer"
permissions{ canCreate, canUpdate, canDelete }, for showing or hiding controls
urls{ list, edit? }
formValues, errors, isDirty, setValue, and the rest of useDyrectedForm()
submit(options?)Save the form. See below
cancel()Leave the form. Asks first when there are unsaved changes

permissions hides controls; it is not security. The server enforces access rules on every request, whatever your component shows. When the editor may not save, submit() returns { ok: false } without sending a request.

submit()

const result = await submit({
  overrides: { checkedIn: true }, // merged over the form values for this call
  redirect: "stay",               // "edit" | "list" | "stay" | false
  silent: true,                   // skip Dyrected's toasts
});
OptionDefaultWhat it does
overridesnoneValues merged over the form's values for this call only. They are validated like any other value, and put back if the save fails. They are not a way around access: the server still checks every field
redirect"edit" on create, "stay" on editWhere to go after a successful save. "list" goes to the collection list (the detail page for a global). false and "stay" both stay on the form. Ignored in a drawer
silentfalseSkip the success and error toasts, for forms that report the outcome themselves

The result is { ok: true, doc } or { ok: false, errors, error? }. errors maps a field path to its message and includes both validation errors and errors the API returned. An error with no field is under _form. The same errors appear on the inputs: useField("name").error has the message with no extra code.

result.ok narrows the result in TypeScript projects that use strict (or strictNullChecks), so result.errors is available in the failure branch. In a project without those options, TypeScript cannot narrow on ok. Check "errors" in result instead, which works in both.

After a save with redirect: "stay" on a new record, the form keeps the saved values and the next submit() creates another record. That is what a scanner wants. If you would rather open the record, use the default "edit".

Globals

A global is always edited, never created, so it takes editForm and form. In your component, mode is "edit" and there is no documentId.

defineGlobal({
  slug: "site-settings",
  admin: { components: { editForm: "site-settings.form" } },
  fields: [/* … */],
});

Globals do not send collection-level access to the admin, so permissions.canUpdate is true and the server decides when the editor saves.

Relationship drawers

When an editor creates a related record from inside another form, Dyrected uses the target collection's createForm with presentation: "drawer". submit() resolves to the drawer's own handler and does not redirect, because the drawer decides what happens next. The same applies to the Create dialog on relationship fields.

Your component receives presentation as a prop, so it can hide controls that make no sense in a drawer. The walk-in form above would hide its Cancel button, for example, because the drawer has its own close button.

To keep the built-in form in drawers while using your form on the page, set formInDrawer: false in admin.components.

Prefilling a new record

Links to /collections/guest-responses/[email protected] start the form with email filled in. The values are plain ?field=value pairs, and useDocumentForm().form.values shows them.

In Vue and Nuxt

Register the component the same way. The names are identical and state comes back as refs:

<script setup lang="ts">
import { DyrectedField, useAdminNotify, useDocumentForm } from "@dyrected/vue";

const { submit, permissions, cancel } = useDocumentForm();
const notify = useAdminNotify();

async function registerAndCheckIn() {
  const result = await submit({
    overrides: { attending: true, checkedIn: true, checkedInAt: new Date().toISOString() },
    redirect: "list",
    silent: true,
  });
  if (result.ok) notify.success("Guest registered and checked in");
  else notify.error(result.errors.name ?? result.errors._form ?? "Could not register this guest");
}
</script>

<template>
  <div>
    <DyrectedField path="name" />
    <DyrectedField path="email" />
    <DyrectedField path="guestCount" />
    <button type="button" @click="cancel">Cancel</button>
    <button type="button" :disabled="!permissions.canCreate" @click="registerAndCheckIn">
      Register and check in
    </button>
  </div>
</template>

With Nuxt, drop the file at components/dyrected/forms/guest-responses.walkIn.vue and it registers itself. See Nuxt Auto-Registration.

When something is wrong

  • The key is not registered. Dyrected renders the default form, so the editor is never left with an empty screen. In development the console names the missing key. See Registration warnings.
  • Your component throws. The screen shows an error card with Retry and Use default form, so a broken form never blocks data entry.
  • formShell is misspelled. Dyrected treats it as full, and the dashboard lists the typo under the items that need attention.
  • Your form never calls submit(). Nothing saves on its own. Under the full shell, the default Save bar still works.

Start with formShell: "full" and <DyrectedDefaultForm> with exclude, and add only the pieces that are special. Move to header when the form has its own actions, and to none only for a tool that needs the whole page. Put anything that is not a field value in overrides, and keep the real rules in your collection hooks and access rules, where the server enforces them.

On this page

Dyrected| Cloud

Get your backend ready in minutes

Use a managed database, storage, APIs, and admin dashboard without setting up the infrastructure yourself.

Set Up My Backend