Feedtack

The content approval system lets teams annotate copywriting fields on the page, track whether they've been reviewed, and gate downstream actions (like deploys) on all fields being approved.

Feedtack owns the annotation and hashing — your adapter owns the persistence.

To also edit fields inline (not just approve them), see Content Editing. When the two are used together, useContentEdit passes live stored values into useContentApproval, so hashes are computed against what's in the store rather than whatever the last build baked into the DOM.

Annotating fields

Add data-feedtack-field to any element containing copywriting you want to track. The value is 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>

Field paths are opaque to Feedtack — they're just keys in your approval store. Use whatever namespace makes sense for your content model. A suggested convention is dot-path notation in the form {page}.{section}.{element} — for example home.hero.heading, pricing.faq.cta, or about.team.description. This keeps field paths predictable and easy to filter by page or section.

How it works

When a field is approved, Feedtack computes a SHA-256 hash of the element's current text content and stores it alongside the approver's user ID and a timestamp. If the content changes, the stored hash no longer matches — the approval is stale and must be re-approved.

Here is what a FieldApprovalState looks like before and after a content change:

// Content matches the stored hash — approval is current
{
  "fieldPath": "home.hero.heading",
  "approval": {
    "hash": "a3f8c2d91e04",
    "by": ["user_abc"],
    "at": "2024-11-01T10:30:00Z"
  },
  "stale": false
}
// Content was edited after approval — must be re-approved
{
  "fieldPath": "home.hero.heading",
  "approval": {
    "hash": "a3f8c2d91e04",
    "by": ["user_abc"],
    "at": "2024-11-01T10:30:00Z"
  },
  "stale": true
}

The hash in approval always reflects what was approved, not the current content. When stale is true, the stored hash and the live content hash diverge.

useContentApproval

The useContentApproval hook scans the current page for annotated fields, loads stored approvals from the adapter, and computes staleness:

import { useContentApproval } from 'feedtack/react'

function ApprovalPanel({ adapter, userId }) {
  const { fields, approve, revoke, rescan, checkDeploy } = useContentApproval(adapter, userId)

  return (
    <ul>
      {fields.map((f) => (
        <li key={f.fieldPath}>
          {f.fieldPath}
          {f.stale ? ' — needs approval' : ' — approved'}
          {f.stale && (
            <button onClick={() => approve(f.fieldPath)}>Approve</button>
          )}
        </li>
      ))}
    </ul>
  )
}

Return values

ValueTypeDescription
fieldsFieldApprovalState[]Current approval state for all scanned fields
approve(fieldPath)(path: string) => Promise<void>Approve the field as the current user
revoke(fieldPath)(path: string) => Promise<void>Revoke the current user's approval
rescan()() => Promise<void>Re-scan the DOM and refresh state
checkDeploy()() => Promise<DeployCheckResult>Return pending fields and overall approval status

Deploy check

checkDeploy() returns { approved: boolean, pending: string[] }. Use it to gate a deploy action:

const { checkDeploy } = useContentApproval(adapter, userId)

async function handleDeploy() {
  const result = await checkDeploy()
  if (!result.approved) {
    alert(`Unapproved fields:\n${result.pending.join('\n')}`)
    return
  }
  // proceed with deploy
}

Feedtack surfaces the data — you decide what the gate means.

Why isn't there a provider prop for this? FeedtackProvider's onDeployCheck prop is deprecated and was never invoked: Feedtack can't know when a deploy-relevant moment occurs, so auto-wiring would run DOM scans at times you didn't choose. Call checkDeploy() from your own deploy UI (above), or gate in CI (below).

CI Gate recipe

checkDeploy()'s algorithm — scan annotated fields, hash content, compare against stored approvals — also runs outside the browser, because its primitives are public API: hashField from feedtack, DiskAdapter from feedtack/node.

Static builds (SSG/SSR output on disk). Hash the built HTML's fields and fail the pipeline when any lack a current approval:

// scripts/content-gate.ts — run with: bun scripts/content-gate.ts dist/index.html
import { readFile } from 'node:fs/promises'
import { JSDOM } from 'jsdom'
import { hashField } from 'feedtack'
import { DiskAdapter } from 'feedtack/node'

const html = await readFile(process.argv[2], 'utf8')
const { document } = new JSDOM(html).window
const adapter = new DiskAdapter({ directory: '.feedtack' })
const approvals = new Map(
  (await adapter.loadApprovals()).map((s) => [s.fieldPath, s.approval]),
)

const pending: string[] = []
for (const el of document.querySelectorAll('[data-feedtack-field]')) {
  const fieldPath = el.getAttribute('data-feedtack-field') ?? ''
  const hash = await hashField(el.textContent?.trim() ?? '')
  const approval = approvals.get(fieldPath)
  if (!approval || approval.hash !== hash) pending.push(fieldPath)
}

if (pending.length > 0) {
  console.error(`Unapproved content fields:\n  ${pending.join('\n  ')}`)
  process.exit(1)
}
console.log('All content fields approved ✓')

Wire it into the pipeline after the build, before the deploy step:

# .github/workflows/deploy.yml
- run: bun run build
- run: bun scripts/content-gate.ts dist/index.html   # fails the job when pending
- run: bun run deploy

Client-rendered apps. Fields don't exist until hydration, so drive a real browser: expose the hook's checker on an admin-only route (window.__feedtackCheckDeploy = checkDeploy inside a useEffect), then in CI:

const result = await page.evaluate(() => window.__feedtackCheckDeploy())
if (!result.approved) throw new Error(`Pending: ${result.pending.join(', ')}`)

Either way the gate's meaning stays yours — block the deploy, post a PR comment, or just annotate the release notes.

ContentAdapter

Any adapter can support content approvals by implementing ContentAdapter:

import type { ContentAdapter, FeedtackAdapter } from 'feedtack'

class MyAdapter implements FeedtackAdapter, ContentAdapter {
  async approve(fieldPath, approval) { /* store */ }
  async revokeApproval(fieldPath, userId) { /* remove */ }
  async loadApprovals(filter?) { /* return FieldApprovalState[] */ }

  // ... FeedtackAdapter methods
}

DiskAdapter and WebhookAdapter both ship with ContentAdapter implementations.

DiskAdapter

Approvals are stored as JSON files at <directory>/approvals/<field-path>.json.

WebhookAdapter

Approvals are POSTed to updateUrl as { type: 'approve', fieldPath, ... } and { type: 'revoke', ... }. Supply a loadApprovals function in the config to support reading:

new WebhookAdapter({
  submitUrl: '/api/feedback',
  updateUrl: '/api/feedback/update',
  loadFeedback: (filter) => fetch('/api/feedback').then(r => r.json()),
  loadApprovals: (filter) => fetch('/api/approvals').then(r => r.json()),
})

FieldApprovalState

Each field's state includes:

interface FieldApprovalState {
  fieldPath: string        // e.g. "hero.heading"
  approval: FieldApproval | null  // null = never approved
  stale: boolean           // true if content changed since last approval
}

interface FieldApproval {
  hash: string    // 12-char SHA-256 of approved content
  by: string[]    // user IDs who approved
  at: string      // ISO 8601 UTC timestamp
}

On this page