# Adding a Puck Component

> **[Docs Overview](../overview.md)** / [Editors](./README.md) / Adding a Puck Component

End-to-end recipe for adding a new block to the CMS page editor. For background on how the editor fits together (tiers, save/publish flow, Lexical rich text), read [DEALER_EDITORS.md](../DEALER_EDITORS.md) first.

---

## Overview

A Puck component has two halves:

1. **Render component** - a React function that renders the block on the public dealer site _and_ inside the editor preview. Lives alongside the other block components at the top of `lib/cms/puck-config.tsx`.
2. **Field config** - declarative schema registered under `puckConfig.components` in the same file. Puck uses this to auto-generate the sidebar form. The render component and field config must agree on the props shape (defined as a `*Props` interface near the top of `puck-config.tsx`).

The block also needs to be added to a **category** (`puckConfig.categories`) so it appears in the editor's component drawer.

**Everything lives in one file: `lib/cms/puck-config.tsx`.** That file is the seam. If you find yourself editing more than a component file plus this config, something is off.

---

## Step 1: Create the React component

**File:** `lib/cms/puck-config.tsx` (add to the `COMPONENT IMPLEMENTATIONS` block near the existing `RichTextComponent`, `HeadingComponent`, etc.)

Conventions that are not optional:

- **Inline styles only.** Tailwind classes don't render inside the Puck editor preview - use `style={{ ... }}`.
- **Sanitize any HTML.** If your component ever renders user-provided HTML (from a `LexicalField` or similar), pass it through `sanitizeHtmlWithSecureLinks()` from `./sanitize-client` before `dangerouslySetInnerHTML`. Do not skip this.
- **Follow the color palette.** `#093b66` for headings, `#c6c6c6` for borders, `#38bdf8` for interactive accents - match existing components.
- **Client-safe only.** The file is `'use client'`. Don't import Node-only modules or server-only utilities. No `fs`, no `process.env` access beyond public vars.

Define the props interface near the other `*Props` interfaces at the top of the file, then the component:

```typescript
export interface QuoteBlockProps {
  quote: string;
  attribution: string;
  alignment: 'left' | 'center';
}

function QuoteBlockComponent({ quote, attribution, alignment }: QuoteBlockProps) {
  return (
    <blockquote
      style={{
        borderLeft: '4px solid #38bdf8',
        padding: '1rem 1.5rem',
        margin: '1.5rem 0',
        color: '#093b66',
        fontStyle: 'italic',
        textAlign: alignment,
      }}
    >
      <p style={{ fontSize: '1.125rem', lineHeight: 1.6, margin: 0 }}>“{quote}”</p>
      {attribution && (
        <footer style={{ marginTop: '0.75rem', fontSize: '0.875rem', color: '#6b7280' }}>
          — {attribution}
        </footer>
      )}
    </blockquote>
  );
}
```

---

## Step 2: Register in `puckConfig.components`

Add a new key inside `puckConfig.components` (around line 880 of `puck-config.tsx`). The key name is the component's internal ID; `label` is what appears in the editor drawer.

```typescript
QuoteBlock: {
  label: 'Quote Block',
  fields: {
    quote: { type: 'text', label: 'Quote' },
    attribution: { type: 'text', label: 'Attribution' },
    alignment: {
      type: 'select',
      label: 'Alignment',
      options: [
        { label: 'Left', value: 'left' },
        { label: 'Center', value: 'center' },
      ],
    },
  },
  defaultProps: {
    quote: 'Great product, great service.',
    attribution: 'Happy Customer',
    alignment: 'left',
  },
  render: QuoteBlockComponent as any,
},
```

Field types you can use (from `@measured/puck`): `text`, `textarea`, `number`, `select`, `radio`, `array`, and `custom`. For a full-featured rich text input use `type: 'custom'` with `render: ({ value, onChange }) => <LexicalField value={value || ''} onChange={onChange} />`. For images, use `ImagePickerField` the same way. Both live in `lib/cms/`.

The `as any` cast on `render` is already the pattern used by every other component in this file - Puck's generic wrapper fights TypeScript, and the cast is the agreed workaround.

---

## Step 3: Add default content

`defaultProps` is what gets inserted when a dealer drags the block onto the canvas. Two rules:

- **Every field in `fields` needs a key in `defaultProps`.** A missing default produces `undefined` props on the first render, which breaks non-nullable field usage.
- **Make the default show off the component.** A block with default text "" just confuses the dealer. Pre-fill with a short sample like the quote above.

For components with array fields (like `Accordion`), you also need a `resolveData` hook to attach stable IDs to new items - see `AccordionComponent` in `puck-config.tsx` for the exact pattern (`crypto.randomUUID()` inside `resolveData`).

---

## Step 4: Add the component to a category

Categories control the grouping in the editor's left-hand drawer. Add your component's key to one of them in `puckConfig.categories` (bottom of `puck-config.tsx`, around line 1523):

```typescript
categories: {
  content: {
    title: 'Content',
    components: ['Heading', 'RichText', 'Image', 'Video', 'HtmlEmbed', 'QuoteBlock'], // added
  },
  interactive: {
    title: 'Interactive',
    components: ['Accordion', 'Tabs'],
  },
  layout: {
    title: 'Layout',
    components: ['Grid', 'TwoColumn', 'Spacer', 'Divider'],
  },
},
```

Components not listed in any category will not appear in the drawer, even if they're registered.

---

## Step 5: Test in the dealer editor

The CMS page editor requires a Professional tier dealer. Use the seeded test account:

```bash
# 1. Power-cycle so your changes compile
npm run power-cycle

# 2. Sign in as the Professional test dealer via the dev auth API
curl -X POST http://localhost:3000/api/dev/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "test-professional@claude.dev", "secret": "<DEV_AUTH_SECRET>"}' \
  -c cookies.txt

# 3. Open the editor URL in the same browser session:
# http://localhost:3000/dashboard/cms/pages
# Create a new page, then click Edit to open Puck at /dashboard/cms/pages/[id]/edit
```

In the editor, your component should appear under its category in the left drawer. Drag it onto the canvas, confirm the fields render in the right-hand panel, edit values, click Save, then Publish. Check the published output at `http://test-professional.localhost:3000/<slug>`.

If the drawer doesn't show your component, check: (a) registered in `puckConfig.components`, (b) listed in a category array, (c) the dev server recompiled without errors.

---

## Step 6: Tier gating (optional)

Puck components themselves aren't tier-gated - the _editor_ is (Professional only, via `canCreateCMSPages` / `canEditCMSPage` in `lib/cms/types.ts`). If you need a block to be available to Professional-tier dealers only for certain downstream behavior (e.g., a "Lead Form" block that only works when `hasLeadFormAccess` is true), gate at render time:

```typescript
function LeadFormBlockComponent(props: LeadFormBlockProps) {
  // Read dealer tier from context if available, or render a fallback.
  // See existing patterns in the CMS publisher (lib/cms/publisher.ts) for
  // how tier is threaded into rendered page output.
}
```

For new tier thresholds, add a constant + helper in `lib/cms/types.ts` alongside the existing `SLIDER_EDITOR_TIERS`, `LEAD_FORM_TIERS`, etc., and call it from the render path.

---

## Pitfalls

- **Tailwind in preview.** Doesn't work. Use inline styles. This trips up everyone at least once.
- **Unsanitized HTML.** Any path from a text field to `dangerouslySetInnerHTML` must go through `sanitizeHtmlWithSecureLinks()` or `sanitizeHtmlRelaxed()` (see `lib/cms/sanitize-client.ts`). The sanitizer also adds `rel="noopener noreferrer"` to external links.
- **SSR-unsafe browser APIs.** `puck-config.tsx` is `'use client'`, but the `render` function's output ends up in the static-rendered dealer page via `renderPageToHtml()` (`lib/cms/publisher.ts`). Guard browser-only code (`window`, `document`, `IntersectionObserver`) behind `useEffect` or `typeof window !== 'undefined'` checks.
- **Prop serialization.** Puck serializes props to JSON and stores them in `Page.puckData`. Don't put functions, `Date` objects, `Map`/`Set`, or class instances into props - stick to primitives, plain objects, and arrays. If you need a date, store an ISO string and parse on render.
- **Array field IDs.** Array items without stable IDs break reordering in the editor. Use `resolveData` to assign `crypto.randomUUID()` to new items - mirror the `Accordion` implementation.
- **Name collisions.** The component key (e.g., `QuoteBlock`) becomes the serialized type in `puckData`. Renaming it later will orphan existing dealer pages. Pick a final name up front.

---

## See Also

- [DEALER_EDITORS.md](../DEALER_EDITORS.md) - full editor architecture, tier matrix, Lexical details
- [editors/README.md](./README.md) - editor landing page + customization quick reference
- [PRISMA_JSON_SCHEMAS.md](../PRISMA_JSON_SCHEMAS.md) - how `puckData` is stored
- `lib/cms/puck-config.tsx` - source of truth; read existing components before writing a new one
