# Adding a CMS Page Type

> **[Docs Overview](./overview.md)** / Adding a Page Type

Recipe for extending the CMS with a new page kind - e.g., adding a `landing` type alongside the existing `page` and `blog` types.

For editor internals read [DEALER_EDITORS.md](./DEALER_EDITORS.md); for component work read [editors/ADDING_A_PUCK_COMPONENT.md](./editors/ADDING_A_PUCK_COMPONENT.md).

---

## What's a page type

A **page type** is a classification stored on `Page.type` in the database. It determines:

1. **URL shape** - `page` types live at `/<slug>`; `blog` types live at `/blog/<slug>` (see `getPagePath()` in `lib/cms/types.ts`).
2. **List view behavior** - `blog` types render in a blog index at `/blog`; `page` types don't aggregate.
3. **Navigation treatment** - blog pages have a dedicated default nav item (`BLOG_NAV_DEFAULT_KEY` = `about.blog`) that auto-toggles visibility via `updateBlogNavVisibility()`.
4. **Metadata fields** - blog posts use `author` and `excerpt`; regular pages typically don't.

**Current types** (`lib/cms/types.ts`, line ~11):

```typescript
export type PageType = 'page' | 'blog';
```

At the Prisma level, `Page.type` is `String` (`prisma/schema.prisma`, around line 181) - the union is enforced in TypeScript, not the database. That means adding a new type is a **code change, not a migration**, as long as you don't need new columns.

> **If your new page type needs new columns** (e.g., a `landing` type that stores a CTA target field), you _do_ need a Prisma migration. Add nullable fields to the `Page` model, run `npx prisma migrate dev`, and treat them as optional in existing `page`/`blog` types.

---

## Step 1: Add the type to the union

**File:** `lib/cms/types.ts`

```typescript
export type PageType = 'page' | 'blog' | 'landing'; // added 'landing'
```

TypeScript will now flag every `switch` / narrowing site that doesn't handle the new variant. Work through them - each one is a place the routing or UI cares about the type.

**Update `getPagePath()` in the same file** if the URL shape differs from the default `/<slug>`:

```typescript
export function getPagePath(page: Pick<Page, 'slug' | 'type'>): string {
  if (page.type === 'blog') return `/blog/${page.slug}`;
  if (page.type === 'landing') return `/lp/${page.slug}`; // if landing pages live under /lp
  return `/${page.slug}`;
}
```

If your new type shares the default URL shape, you can leave `getPagePath()` alone.

---

## Step 2: Routing registration

**File:** `app/dealers/[subdomain]/[domainSlug]/[...slug]/page.tsx`

This catch-all handles every CMS-rendered page on a dealer subdomain. It's ISR-static and discovers pages via `generateStaticParams()`. Two touch points:

**`generateStaticParams()`** - currently maps blog pages to `['blog', slug]` and everything else to `[slug]`:

```typescript
return pages.map((page) => ({
  subdomain: page.dealer.subdomain!,
  slug: page.type === 'blog' ? ['blog', page.slug] : [page.slug],
}));
```

If your new type has its own URL prefix (like the `/lp/` example above), extend this mapping.

**`getCachedPage()` / page-resolution logic** - the `isBlogPost` flag is derived from the incoming route params (`slug[0] === 'blog'`). For a new prefix, extend that derivation to pass a `type` through to the cache key and the `prisma.page.findFirst({ where: { type } })` call.

Read the whole file before editing - the ISR tags, `revalidate: 31536000`, and static/dynamic params flags all need to continue working.

Two ISR tags are applied per dealer page:

- `dealer-${subdomain}` - broad tag that covers all pages for a subdomain across all parent domains (used by `revalidateTag` for bulk invalidation)
- `dealer-${subdomain}-${domainPrefix}-${domain}` - precise tag scoped to a single `(subdomain, domainPrefix, domain)` tuple (used when only one parent's pages should be invalidated)

---

## Step 3: Puck starter template (if applicable)

When a dealer creates a new page of your type, you probably want a non-empty starting canvas. Two places to do this:

**a) `app/api/cms/pages/route.ts`** (`POST` handler) currently defaults `puckData` to:

```typescript
puckData: (validatedData.puckData || { content: [], root: { props: {} } }) as object,
```

Branch on `validatedData.type` to inject a type-specific starter:

```typescript
const starterByType = {
  page: { content: [], root: { props: {} } },
  blog: { content: [], root: { props: {} } },
  landing: {
    content: [
      { type: 'Heading', props: { id: crypto.randomUUID(), text: 'Your Headline', level: 'h2', alignment: 'center' } },
      { type: 'RichText', props: { id: crypto.randomUUID(), content: '<p>Your pitch...</p>' } },
    ],
    root: { props: {} },
  },
};

puckData: validatedData.puckData || starterByType[validatedData.type] || starterByType.page,
```

**b)** Also update `CreatePageSchema` in `lib/cms/validation.ts` to accept the new `type` value (the schema is currently `z.enum(['page', 'blog'])` or similar - widen it).

---

## Step 4: Tier gating

Creating a new page type is governed by `canCreateCMSPages()` (Professional only). If your new type should be restricted further - e.g., landing pages are a Professional add-on, not a default Professional feature - add a helper to `lib/cms/types.ts`:

```typescript
export const LANDING_PAGE_TIERS = ['professional'] as const;

export function canCreateLandingPages(subscriptionTier: string): boolean {
  return (LANDING_PAGE_TIERS as readonly string[]).includes(subscriptionTier.toLowerCase());
}
```

Then in `app/api/cms/pages/route.ts`, after the existing `canCreateCMSPages` check, add a type-specific gate:

```typescript
if (validatedData.type === 'landing' && !canCreateLandingPages(dealer.subscriptionTier)) {
  return NextResponse.json({ error: 'Landing pages require Professional tier' }, { status: 403 });
}
```

If the new type represents a sellable feature, also update `PLAN_FEATURES` in `lib/plan-features.ts` so the upgrade-comparison table reflects it.

---

## Step 5: Navigation and UI integration

**a) Page creation UI** - `app/dashboard/cms/pages/new/page.tsx` hard-codes a `'page' | 'blog'` radio group (around line 17). Add a third radio for your new type and widen the local `useState<'page' | 'blog'>` to include it.

**b) Page list UI** - `app/dashboard/cms/pages/page.tsx` groups pages by type. Add a section for the new type so dealers can see and filter their landing pages.

**c) Default navigation** - `lib/cms/nav-defaults.ts` seeds the dealer's navigation menu at first publish. If the new type deserves a default nav entry (like blog's `BLOG_NAV_DEFAULT_KEY`), add a new `DefaultNavItem` entry there and mirror the `updateBlogNavVisibility()` pattern (`lib/blog-nav-visibility.ts`) - visibility toggles based on whether any published pages of that type exist.

**d) Blog-index parallel** - if the new type needs a list page analogous to `/blog`, extend `getCachedBlogPosts()` and `renderBlogIndexToHtml()` (`lib/cms/publisher.ts`). Otherwise skip this.

---

## Pitfalls

- **Prisma `String` field.** `Page.type` has no DB constraint; bad values written via direct DB access or backfill scripts will pass validation but break routing. Guard at every write site with the TS union and Zod.
- **RESERVED_SLUGS.** `lib/cms/types.ts` maintains a blocklist (`blog`, `contact`, `admin`, etc.). If your new type's URL prefix (e.g., `lp`) could collide with a valid slug, add it to `RESERVED_SLUGS` so dealers can't create a regular page at `/lp` that shadows the type's index.
- **ISR cache keys.** `getCachedPage()` uses `cms-page-${subdomain}-${pageSlug}` as its unstable_cache key. If two different page types can share a slug (unlikely but possible when URL prefixes differ), include the type in the cache key so they don't alias each other.
- **`generateStaticParams()` staleness.** Pre-generation happens at build time. Newly added page types won't have their existing pages pre-rendered until the next production build; they'll generate on first request (`dynamicParams = true` is set), which is fine but adds a cold-start latency to the first visitor.
- **Migration-style backfills.** Existing pages keep `type = 'page'` or `type = 'blog'`. Don't silently retype them - if you're converting a subset to the new type, write an explicit migration with a clear audit trail.
- **TypeScript narrowing misses.** After widening `PageType`, run `npx tsc --noEmit` to find every exhaustiveness hole. The lint-staged pipeline doesn't run a full typecheck, so skipping this step will let broken narrowing ship.

---

## See Also

- [DEALER_EDITORS.md](./DEALER_EDITORS.md) - editor architecture and tier matrix
- [editors/ADDING_A_PUCK_COMPONENT.md](./editors/ADDING_A_PUCK_COMPONENT.md) - if your new type needs its own Puck blocks
- [CONTRIBUTING.md](./CONTRIBUTING.md) - Common Recipes (API routes, tier gating helpers)
- [PRISMA_JSON_SCHEMAS.md](./PRISMA_JSON_SCHEMAS.md) - how `puckData` is shaped
- `lib/cms/types.ts` - page type union and tier helpers
- `app/dealers/[subdomain]/[domainSlug]/[...slug]/page.tsx` - dealer-site routing
