nextjs-cms

Submit Pipeline

How a form submission is validated, written, extended by hooks, rolled back on failure, and logged.

Code: packages/nextjs-cms/src/core/submit/

Class hierarchy

Submit (abstract)                 submit.ts           shared pipeline
├── NewSubmit                     NewItemSubmit.ts    INSERT; beforeCreate / afterCreate
└── EditSubmit                    ItemEditSubmit.ts   UPDATE; beforeUpdate / afterUpdate; file replacement
    ├── SimpleSectionSubmit       SimpleSectionSubmit.ts  INSERT row 1 on first save, UPDATE afterwards
    └── LocaleSubmit              LocaleSubmit.ts     writes {table}_locales; hooks gated by runForLocales

Route handlers in the admin (apps/cms/app/api/submit/section/...) pick the class, then:

const submit = new EditSubmit({ sectionName, itemId, user, postData: formData, requestMetadata })
await submit.initialize() // load section (privilege-checked), build fields, load current values
await submit.submit()
if (submit.error) return Response.json({ error: submit.errorMessage }, { status: 400 })

submit() step by step

submit()
 1. handleFields()            for each field:
                                 - skip if conditional rules don't match (checkConditionalField)
                                 - setValue(postData)       parse the raw value / File
                                 - checkRequired()
                                 - prepareForSubmission()   validate + transform
                                 - collect sqlNamesAndValues (or getSqlNamesAndValues for date ranges)
 2. checkUniqueness()         one SELECT per db.unique constraint (excluding the current item)
 3. (preSubmit? stop here)    dry-run mode used for validation-only requests
 4. runPreSubmitHooks()       beforeCreate / beforeUpdate. Throw → abort with the message
 5. buildSqlQuery()           INSERT / UPDATE from sqlNamesAndValues (+ created_by / updated_by)
 6. executeQuery()            captures insertId for new items
 7. postSubmit()              field.postSubmit(): write files, join tables, inline images
 8. on failure in 7:          submitRollback() + field.postSubmitRollback() + onError hook
 9. runPostSubmitHooks()      afterCreate / afterUpdate (errors logged, never thrown)
10. handleGalleryOrder()      apply drag-and-drop order (sort_order)
11. handleGallery()           process uploaded gallery photos (failures become notices)
12. logOperation()            audit log entry

Errors never throw out of submit(). They set _error / _errorMessage (translated for the admin's language), and the route handler turns them into a 400 response.

sqlNamesAndValues

The single source of truth for the write. It's a plain Record<string, unknown> keyed by column name. Hooks receive this object as values, so before* hooks can modify it and the change is persisted. Keep that property when refactoring; it's documented behavior.

Uniqueness

checkUniqueness() iterates section.db.unique (which always contains the identifier). For each constraint whose columns all have values, it runs a SELECT excluding the current item id and, on a match, fails with a translated message naming the fields. LocaleSubmit overrides this to check localized constraints per locale in {table}_locales. The database constraint is a safety net; the pre-check is what gives users a readable error.

Hooks

resolveHook(hook) normalizes the two hook shapes into { handler, runForLocales }.

ClassPre hookPost hookrunForLocales
NewSubmitbeforeCreateafterCreateignored
EditSubmit / SimpleSectionSubmitbeforeUpdateafterUpdateignored
LocaleSubmitbeforeUpdate (row exists) or beforeCreate (new locale row)matching after*required to fire

Delete hooks live outside the submit classes, in api/actions/pages.ts (deleteSectionItem, deleteLocaleTranslation).

Rollback

Two kinds of work can fail after the row is written: files and join tables. postSubmit() runs every field's postSubmit; if any throws:

  1. submitRollback() undoes the SQL write. NewSubmit deletes the inserted row. EditSubmit doesn't restore previous column values yet (the method is a TODO), so a failed edit can leave the row updated while its files are rolled back. This is a good first contribution.
  2. Each field's postSubmitRollback() removes files it wrote and restores join rows.
  3. The onError hook runs.

Gallery processing happens after this and deliberately doesn't roll back the item: failed gallery photos become notices the admin can retry from the edit page.

EditSubmit specifics

  • Loads existing values first, so file fields can tell "unchanged" from "replaced", and required file fields pass when a file already exists.
  • Deletes replaced files (cleanupReplacedFiles() → field.cleanupReplacedFile()) after the write and post-submit steps.
  • Excludes the current item from uniqueness checks.

LocaleSubmit specifics

  • Validates that the parent item exists in the base table.
  • Only localized fields are processed; values contains only localized columns.
  • Decides create vs. update by checking for an existing (parent_id, locale) row, which drives hook selection and rollback strategy.
  • Localized destinationDb fields and inline editor photos are scoped by locale.

Adding behavior

  • Per-field behavior belongs in the field class (prepareForSubmission, postSubmit), not in Submit.
  • Cross-field behavior (uniqueness, conditional visibility, gallery) belongs in Submit.
  • Use is(field, SomeField) guards for type-specific handling in Submit, as done for DateRangeField, SelectField, and SlugField.
  • Integration tests for the pipeline live next to the code (core/submit/__tests__) and in api/__tests__/integration.