The content editing layer turns data-feedtack-field annotations into inline-editable fields. Changes are persisted through the adapter, approvals are cleared on edit, and a contextual toolbar surfaces approve/revert/deploy actions.
This builds directly on the Content Approval system.
How to use it
1. Annotate fields
Add data-feedtack-field to any element containing copy you want to be editable. Use a dot-path identifier that's unique per page:
<h1 data-feedtack-field="hero.heading">Welcome to Acme</h1>
<p data-feedtack-field="hero.subheading">The best tools for your team.</p>
<a data-feedtack-field="hero.cta" href="/signup">Get started</a>Standard form inputs (<input>, <textarea>, <select>) are also supported — they get cursor: pointer styling instead of contenteditable.
2. Mount useContentEdit and ContentEditToolbar
In a layout or admin wrapper component, call useContentEdit and render ContentEditToolbar:
"use client";
import { useContentEdit, ContentEditToolbar } from 'feedtack/react'
function AdminLayout({ adapter, currentUser, children }) {
const edit = useContentEdit(adapter, currentUser.id)
return (
<>
{children}
{edit.active && <ContentEditToolbar {...edit.toolbarProps} />}
<button onClick={edit.active ? edit.deactivate : edit.activate}>
{edit.active ? 'Exit edit mode' : 'Edit content'}
</button>
</>
)
}3. Activate edit mode
Call edit.activate() (via the button above or any other trigger). This:
- Fetches live stored values from
adapter.loadFields() - Swaps those values into all annotated DOM elements
- Sets
contenteditable="true"on each field - Starts watching for new fields added to the DOM (handles client-side navigation)
4. Edit and save
Click into any annotated field and type. When you click away (blur), the new value is saved automatically via adapter.saveField() — no explicit save button needed. The toolbar shows a saving indicator while the write is in-flight.
Saving a field also clears its stored approval, since the content has changed.
5. Use the toolbar
The ContentEditToolbar (rendered via {...edit.toolbarProps}) shows:
- Approve / unaccept — when a field is focused, approve it or revoke approval
- Saving indicator — shown while a save is in-flight
- Changes panel — collapsible list of session edits; click revert on any field to undo
- Deploy gate — checks whether all fields are currently approved before you deploy
6. Deactivate
Call edit.deactivate() to remove contenteditable, unbind event listeners, and disconnect the mutation observer.
Adapter requirement
Your adapter must implement ContentEditAdapter — an extension of ContentAdapter:
interface ContentEditAdapter extends ContentAdapter {
loadFields(): Promise<Record<string, string>>
saveField(fieldPath: string, value: string): Promise<void>
}loadFields() returns all stored field values for DOM hydration. saveField() persists the new value and must atomically clear the stored approval for that field — an edit invalidates sign-off.
DiskAdapter and WebhookAdapter both implement ContentEditAdapter out of the box.
useContentEdit
import { useContentEdit, ContentEditToolbar } from 'feedtack/react'
function AdminLayout({ adapter, children }) {
const edit = useContentEdit(adapter, currentUser.id)
return (
<>
{children}
{edit.active && <ContentEditToolbar {...edit.toolbarProps} />}
<button onClick={edit.active ? edit.deactivate : edit.activate}>
{edit.active ? 'Exit edit mode' : 'Edit content'}
</button>
</>
)
}What activate() does
- Calls
loadFields()on the adapter - Sets
data-feedtack-hydrating="true"ondocument.bodyduring load - Swaps live stored values into all
[data-feedtack-field]elements in the DOM - Sets
contenteditable="true"on each annotated field - Attaches blur-to-save behavior — when a user edits and blurs a field,
saveField()is called automatically if the value changed
A MutationObserver handles fields added to the DOM after activation (e.g. after client-side navigation).
Hydration and the static-build problem
When a page is server-rendered or statically built, the DOM contains the values from the last build — which may be stale. loadFields() fetches the live values from your store and swaps them in before editing begins. This also ensures approval hashes are compared against the stored value, not the stale built value.
ContentEditToolbar
The toolbar is opt-in — render it wherever makes sense in your app. useContentEdit returns toolbarProps with everything it needs:
<ContentEditToolbar {...edit.toolbarProps} />Toolbar features:
- Field actions — when a field is focused: approve button (if stale) or unaccept button (if approved)
- Saving indicator — shown while a save is in-flight
- Changes panel — collapsible list of session edits with per-field revert
- Deploy gate — check whether all fields have current approvals before deploying
Session changes
Every successful save is tracked in edit.changes:
interface FieldChange {
fieldPath: string
from: string // value at session start
to: string // most recent saved value
savedAt: number // unix ms
}Calling revert(fieldPath) calls saveField(fieldPath, change.from) and removes the entry.
DiskAdapter
saveField writes to <dir>/fields/<field-path>.json and deletes <dir>/approvals/<field-path>.json. loadFields reads all files in <dir>/fields/.
WebhookAdapter
saveField POSTs { type: 'save-field', fieldPath, value, clearApproval: true } to updateUrl. Supply a loadFields function in the config:
new WebhookAdapter({
submitUrl: '/api/feedback',
updateUrl: '/api/feedback/update',
loadFeedback: (filter) => fetch('/api/feedback').then(r => r.json()),
loadApprovals: () => fetch('/api/approvals').then(r => r.json()),
loadFields: () => fetch('/api/content/fields').then(r => r.json()),
})