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 keeps | Your component does |
|---|---|
| Validation, access rules, hooks, and drafts on every save | Decide what the editor sees and when to save |
| The built-in inputs, with labels, conditions, widths, and read-only rules | Place the inputs you want with <DyrectedField> |
| Success and error handling, and server errors on the right field | Call submit() and react to the result |
| The page header, and optionally the Save bar | Draw 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:
| Setting | Used when |
|---|---|
createForm | Creating a record. Falls back to form, then the built-in form |
editForm | Editing a record. Falls back to form, then the built-in form |
form | Fallback 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.overridessetsattending,checkedIn, andcheckedInAtfor 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 onresult.ok.silent: trueskips 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.
formShell | Dyrected renders | Use it when |
|---|---|---|
full (default) | Header, status badge, and the default Save bar | Your form only re-lays-out the body. Saving and navigation keep working |
header | Header only | Your form has its own buttons, like the walk-in desk |
none | Nothing | A 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
| Component | Renders |
|---|---|
<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.
| Field | What it gives you |
|---|---|
mode | "create" or "edit" |
collection | The collection (or global) schema |
document | The saved record. undefined on create, and while an edit loads |
isLoadingDocument | true while an edit loads |
presentation | "page" or "drawer" |
permissions | { canCreate, canUpdate, canDelete }, for showing or hiding controls |
urls | { list, edit? } |
form | Values, 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
});| Option | Default | What it does |
|---|---|---|
overrides | none | Values 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 edit | Where 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 |
silent | false | Skip 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.
formShellis misspelled. Dyrected treats it asfull, and the dashboard lists the typo under the items that need attention.- Your form never calls
submit(). Nothing saves on its own. Under thefullshell, the default Save bar still works.
Recommended path
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.
Replacing a Built-in Layout
Swap the table, kanban, cards, calendar, or spreadsheet layout for your own component across every view that uses it, and keep Dyrected's toolbar, paging, and dialogs.
Admin Hooks
Read and write documents, move around the admin, show toasts, and check what an editor may do from your own components, without importing a router or a toast library.