Skip to content

Instantly share code, notes, and snippets.

@paulpopus
Last active May 2, 2026 21:36
Show Gist options
  • Select an option

  • Save paulpopus/fb8433c9287e7d781f88e1ba2bc19e07 to your computer and use it in GitHub Desktop.

Select an option

Save paulpopus/fb8433c9287e7d781f88e1ba2bc19e07 to your computer and use it in GitHub Desktop.
Payload Website Template as a prompt

Payload Website Template as a prompt

Self-contained specification to regenerate the full Payload CMS website template from scratch using code. No Figma file needed.


Stack

Layer Technology
Framework Next.js 15 (App Router, React 19, TypeScript)
CMS Payload CMS 3 (local API, SQLite by default — swap adapter for MongoDB or PostgreSQL in production)
Styling Tailwind CSS v4 + shadcn/ui
Font Geist Sans + Geist Mono (via geist npm package)
Rich text Lexical (via @payloadcms/richtext-lexical)
Forms @payloadcms/plugin-form-builder
SEO @payloadcms/plugin-seo
Search @payloadcms/plugin-search
Auth Payload built-in (admin user)

Dependencies

All @payloadcms/* packages must be pinned to the same version as payload itself (e.g. if payload is 3.33.0, every @payloadcms/* is also 3.33.0). Do not mix patch versions.

Key packages:

payload
@payloadcms/next
@payloadcms/richtext-lexical
@payloadcms/db-sqlite          ← default dev adapter
@payloadcms/plugin-seo
@payloadcms/plugin-form-builder
@payloadcms/plugin-search
geist
tailwindcss                    ← v4
@tailwindcss/postcss           ← required for Tailwind v4 (postcss.config.mjs)
@tailwindcss/typography
sharp                          ← pnpm: add enable-pre-post-scripts=true to .npmrc so sharp builds

Environment variables (.env):

DATABASE_URI=file:./payload.db
PAYLOAD_SECRET=your-secret-change-in-production
NEXT_PUBLIC_SERVER_URL=http://localhost:3000

Design Tokens

Colors (oklch → hex approximations)

Light mode (:root)

CSS variable oklch Hex
--background oklch(100% 0 0) #ffffff
--foreground oklch(14.5% 0 0) #1a1a1a
--card oklch(96.5% 0.005 265) #f5f5f8
--card-foreground oklch(14.5% 0 0) #1a1a1a
--primary oklch(20.5% 0 0) #252525
--primary-foreground oklch(98.5% 0 0) #fbfbfb
--secondary oklch(97% 0 0) #f7f7f7
--secondary-foreground oklch(20.5% 0 0) #252525
--muted oklch(97% 0 0) #f7f7f7
--muted-foreground oklch(55.6% 0 0) #767676
--border oklch(92.2% 0 0) #ebebeb
--input oklch(92.2% 0 0) #ebebeb
--ring oklch(70.8% 0 0) #b3b3b3

Dark mode ([data-theme='dark'])

CSS variable oklch Hex
--background oklch(14.5% 0 0) #0f0f0f
--foreground oklch(98.5% 0 0) #f5f5f5
--card oklch(17% 0 0) #1c1c1c
--card-foreground oklch(98.5% 0 0) #f5f5f5
--primary oklch(98.5% 0 0) #f5f5f5
--primary-foreground oklch(20.5% 0 0) #252525
--secondary oklch(26.9% 0 0) #2e2e2e
--secondary-foreground oklch(98.5% 0 0) #f5f5f5
--muted oklch(26.9% 0 0) #2e2e2e
--muted-foreground oklch(70.8% 0 0) #a3a3a3
--border oklch(26.9% 0 0) #2e2e2e
--input oklch(26.9% 0 0) #2e2e2e
--ring oklch(43.9% 0 0) #5c5c5c

Border Radius

Variable Value
--radius (base) 0.625rem (10px)
--radius-sm calc(var(--radius) - 4px) = 6px
--radius-md calc(var(--radius) - 2px) = 8px
--radius-lg var(--radius) = 10px
--radius-xl calc(var(--radius) + 4px) = 14px

Breakpoints

Name Value
sm 40rem (640px)
md 48rem (768px)
lg 64rem (1024px)
xl 80rem (1280px)
2xl 86rem (1376px)

Container

.container {
  width: 100%;
  margin-inline: auto;
  padding-inline: 1rem; /* mobile */
}
/* md: padding-inline: 2rem; max-width: 768px */
/* lg: max-width: 1024px */
/* xl: max-width: 1280px */
/* 2xl: max-width: 1376px */

Typography

Font: Geist Sans (variable, loaded via geist/font/sans). Mono: Geist Mono.
Applied via CSS variables --font-sans and --font-mono on <html>.

Style Size Line Height Weight Class
H1 48px (3rem) 52px 700 (bold) prose h1
H2 36px (2.25rem) 40px 700 prose h2
H3 24px (1.5rem) 28px 600 (semibold) prose h3
Body 16px (1rem) 24px (1.5rem) 400 base
Small 14px (0.875rem) 20px 400 text-sm
XS 12px (0.75rem) 16px 400 text-xs
Label / Button 14px 20px 500 (medium) text-sm font-medium

Typography is applied through @tailwindcss/typography prose classes (prose dark:prose-invert).
Global heading overrides reset default browser weights: h1–h6 { font-weight: unset; font-size: unset; } — sizes come from prose plugin.


Theme System

Dark mode uses a data-theme attribute on <html>, not prefers-color-scheme.

@custom-variant dark (&:is([data-theme='dark'] *));

Implementation:

  • InitTheme script injected in <head> reads localStorage and sets data-theme before first paint to prevent flash.
  • ThemeSelector component (in footer) writes to localStorage and updates html[data-theme].
  • The header adapts per-page: on pages with HighImpactHero, the hero calls setHeaderTheme('dark') so the transparent header shows white text. On normal pages, the header is light with dark text.
  • HTML starts with opacity: 0 and becomes visible once data-theme is set:
html {
  opacity: 0;
}
html[data-theme='dark'],
html[data-theme='light'] {
  opacity: initial;
}

Component Catalog

Button

Variants: default | outline | ghost | link | secondary | destructive
Sizes: default (h-10) | sm (h-9) | lg (h-11) | icon (size-10)

// Default (filled primary)
<Button variant="default">All posts</Button>
// bg-primary text-primary-foreground, px-4 py-2, h-10, rounded-md

// Outline
<Button variant="outline">Contact</Button>
// border border-input bg-background, px-4 py-2, h-10, rounded-md

Card (Post Card)

<article className='border border-border rounded-lg overflow-hidden bg-card hover:cursor-pointer'>
  {/* Image fills top */}
  <div className='relative w-full'>
    <Media resource={metaImage} size='33vw' />
  </div>
  <div className='p-4'>
    {/* Category — uppercase text-sm mb-4 */}
    {/* H3 title — prose h3 */}
    {/* Description — mt-2 paragraph */}
  </div>
</article>

Header

<header className='container relative z-20' data-theme={theme}>
  <div className='py-8 flex justify-between'>
    <Logo /> {/* Payload logo, inverted in light mode: className="invert dark:invert-0" */}
    <HeaderNav /> {/* nav links from CMS global */}
  </div>
</header>
  • Position: sticky (sits above page content). On HighImpactHero pages, the hero uses -mt-[10.4rem] to slide under the header.
  • Height: ~80px (padding-top/bottom 2rem = 32px × 2 = 64px + logo 34px = ~80px total).
  • Theme: transparent with dark/light text depending on headerTheme context.

Footer

<footer className='mt-auto border-t border-border bg-black dark:bg-card text-white'>
  <div className='container py-8 gap-8 flex flex-col md:flex-row md:justify-between'>
    <Logo />
    <div className='flex flex-col-reverse items-start md:flex-row gap-4 md:items-center'>
      <ThemeSelector /> {/* dropdown: Light / Dark / System */}
      <nav className='flex flex-col md:flex-row gap-4'>{/* white CMSLink items */}</nav>
    </div>
  </div>
</footer>

Footer nav items (seed): Admin (/admin), Source Code (github.com external), Payload (payloadcms.com external).


Block System

Each block has two parts: a Payload config (data model, registered in the Pages collection's layout blocks array) and a React component (renders the data). Both are required.

1. HighImpact Hero

Usage: Homepage only (hero field, not a layout block).

Payload config: part of the hero group field on Pages, not a block.

React component:

<div className='relative -mt-[10.4rem] flex items-center justify-center text-white' data-theme='dark'>
  <div className='container mb-8 z-10 relative flex items-center justify-center'>
    <div className='max-w-[36.5rem] md:text-center'>
      <RichText /> {/* H1 + body paragraph */}
      <ul className='flex md:justify-center gap-4'>{/* Button links */}</ul>
    </div>
  </div>
  <div className='min-h-[80vh]'>
    <Media fill /> {/* full-bleed background image */}
  </div>
</div>

Visual: Full viewport dark section, full-bleed background image, centered text (max 584px), two buttons (default + outline). The -mt-[10.4rem] pulls it behind the sticky header.

2. Content Block

Payload config:

{
  slug: 'content',
  fields: [
    {
      name: 'columns',
      type: 'array',
      fields: [
        { name: 'size', type: 'select', options: ['oneThird','half','twoThirds','full'], defaultValue: 'oneThird' },
        { name: 'richText', type: 'richText' },
      ],
    },
  ],
}

React component:

<div className='container my-16'>
  <div className='grid grid-cols-4 lg:grid-cols-12 gap-y-8 gap-x-16'>{/* Each column: col-span-4 lg:col-span-{4|6|8|12} */}</div>
</div>

Column sizes: full = 12 cols, half = 6 cols, oneThird = 4 cols, twoThirds = 8 cols.

Homepage seed: 1 full-width H2 + 5 one-third columns (feature cards with H3 + paragraph each):

  • Admin Dashboard — "Manage this site's pages and posts from the admin dashboard."
  • Preview — "Using versions, drafts, and preview, editors can review and share their changes before publishing them."
  • Page Builder — "Custom page builder allows you to create unique page, post, and project layouts for any type of content."
  • SEO — "Editors have complete control over SEO data and site content directly from the admin dashboard."
  • Dark Mode — "Users will experience this site in their preferred color scheme and each block can be inverted."

3. Media Block

Payload config:

{
  slug: 'mediaBlock',
  fields: [
    { name: 'media', type: 'upload', relationTo: 'media', required: true },
    { name: 'position', type: 'select', options: ['default','fullscreen'], defaultValue: 'default' },
  ],
}

React component:

<div className='my-16'>
  <Media resource={media} /> {/* full-bleed image */}
</div>

Full container-width image, no padding. Used as a visual separator on the homepage.

4. Archive Block

Payload config:

{
  slug: 'archive',
  fields: [
    { name: 'introContent', type: 'richText' },
    { name: 'limit', type: 'number', defaultValue: 3 },
    { name: 'categories', type: 'relationship', relationTo: 'categories', hasMany: true },
  ],
}

React component:

<div className='my-16'>
  <div className='container mb-16'>
    <RichText /> {/* intro: H3 + paragraph */}
  </div>
  <CollectionArchive posts={posts} /> {/* 3-col grid on desktop */}
</div>

CollectionArchive renders a responsive grid of Card components:

<div className='container'>
  <div className='grid grid-cols-4 sm:grid-cols-8 lg:grid-cols-12 gap-y-4 gap-x-4 lg:gap-y-8 lg:gap-x-8'>
    {posts.map((post) => (
      <div className='col-span-4 lg:col-span-4'>
        <Card doc={post} relationTo='posts' showCategories />
      </div>
    ))}
  </div>
</div>

Homepage seed intro: H3 "Recent posts" + body paragraph explaining the archive block.

5. CTA Block

Payload config:

{
  slug: 'cta',
  fields: [
    { name: 'richText', type: 'richText' },
    {
      name: 'links', type: 'array',
      fields: [{ name: 'link', type: 'group', fields: [type, reference, url, newTab, label, appearance] }],
    },
  ],
}

React component:

<div className='container'>
  <div className='bg-card rounded border-border border p-4 flex flex-col gap-8 md:flex-row md:justify-between md:items-center'>
    <div className='max-w-[48rem] flex items-center'>
      <RichText /> {/* H3 + paragraph */}
    </div>
    <div className='flex flex-col gap-8'>
      <CMSLink size='lg' /> {/* button */}
    </div>
  </div>
</div>

Homepage seed: H3 "This is a call to action" + body + "All posts" button (links to /posts).

6. Form Block

Payload config:

{
  slug: 'formBlock',
  fields: [
    { name: 'form', type: 'relationship', relationTo: 'forms', required: true },
    { name: 'introContent', type: 'richText' },
  ],
}

React component:

<div className='container lg:max-w-[48rem]'>
  <RichText /> {/* intro: H3 "Example contact form:" */}
  <div className='p-4 lg:p-6 border border-border rounded-[0.8rem]'>
    <form>
      {/* Fields rendered by type: text, email, number, textarea */}
      <Button type='submit'>{submitButtonLabel}</Button>
    </form>
  </div>
</div>

Contact form fields (from seed):

  1. Full Name — text, required, width 100
  2. Email — email, required, width 100
  3. Phone — number, optional, width 100
  4. Message — textarea, required, width 100
  5. Submit label: "Submit"

Page Layouts

Homepage (/)

Header (transparent over hero, dark theme)
HighImpact Hero
  - H1: "Payload Website Template"
  - Body: links to /admin and GitHub repo
  - Buttons: "All posts" (default) + "Contact" (outline)
  - Background: image-hero1.webp (see Seed Images)
Content Block
  - H2 "Core features" (full width)
  - 5 × oneThird feature cards (see seeds above)
Media Block
  - image-post2.webp (full-bleed)
Archive Block
  - H3 "Recent posts"
  - 3 post cards, auto-populated from 'posts' collection
CTA Block
  - H3 "This is a call to action"
  - "configured in the admin dashboard" body
  - "All posts" button → /posts
Footer

Posts Listing (/posts)

Header (light, sticky, border-bottom)
<div className="container py-24">
  <h1>Posts</h1>
  <PageRange /> {/* "Showing 1-10 of 12 results" */}
  <CollectionArchive posts={posts} /> {/* 3-col card grid, pagination if needed */}
</div>
Footer

Post Detail (/posts/[slug])

PostHero (full-viewport, dark, negative margin like HighImpact)
  - bg image (post meta image)
  - gradient overlay from bottom
  - Breadcrumb category (uppercase, text-sm, muted)
  - H1 title (text-3xl md:text-5xl lg:text-6xl, white)
  - Author name + published date (flex row, text-sm, muted-white)
Header (overlaid, white text via headerTheme='dark')
Post content (container max-w prose)
  - RichText (H2s, paragraphs, inline images, banner blocks)
Related Posts
  - H3 "More Posts" or similar
  - 2-3 cards
Footer

Search (/search)

Header (light, sticky, border-bottom)
<div className="pt-24 pb-24">
  <div className="container mb-16">
    <div className="prose text-center">
      <h1 className="mb-8 lg:mb-16">Search</h1>
      <div className="max-w-[50rem] mx-auto">
        <Search /> {/* input with ?q= param */}
      </div>
    </div>
  </div>
  {results > 0
    ? <CollectionArchive posts={results} />
    : <div className="container">No results found.</div>
  }
</div>
Footer

Contact (/contact)

Header (light, sticky, border-bottom — no hero so no overlap)
FormBlock (lg:max-w-[48rem], centered)
  - H3 "Example contact form:"
  - Bordered card (border border-border rounded-[0.8rem] p-4 lg:p-6):
    - Full Name input
    - Email input
    - Phone input (optional)
    - Message textarea
    - Submit button
Footer

Seed Content

Seed Images

Fetch these from GitHub raw URLs and upload to the media collection before creating pages/posts that reference them:

Variable URL Used for
imageHero https://raw.githubusercontent.com/payloadcms/payload/refs/heads/3.x/templates/website/src/endpoints/seed/image-hero1.webp Homepage hero background
image1 https://raw.githubusercontent.com/payloadcms/payload/refs/heads/3.x/templates/website/src/endpoints/seed/image-post1.webp Post 1 meta image, Post 3 block image
image2 https://raw.githubusercontent.com/payloadcms/payload/refs/heads/3.x/templates/website/src/endpoints/seed/image-post2.webp Post 2 meta image, Homepage media block
image3 https://raw.githubusercontent.com/payloadcms/payload/refs/heads/3.x/templates/website/src/endpoints/seed/image-post3.webp Post 3 meta image, Post 2 block image

Upload pattern:

const imageBuffer = await fetch(url).then((res) => res.arrayBuffer())
const imageDoc = await payload.create({
  collection: 'media',
  data: { alt: 'Description' },
  file: {
    data: Buffer.from(imageBuffer),
    mimetype: 'image/webp',
    name: 'image-hero1.webp',
    size: imageBuffer.byteLength,
  },
})

Posts (3 sample posts, all in "Technology" category)

Post 1 — "Digital Horizons: A Glimpse into Tomorrow"

  • Slug: digital-horizons
  • Category: Technology
  • Meta image: image1
  • Meta description: Explore the cutting edge of technology in our latest roundup.
  • Content: H2 lead + paragraphs + banner image block + closing paragraphs
  • Author: John Doe
  • Published: recent date

Post 2 — "The Future is Now: A Look at Emerging Technologies"

  • Slug: the-future-is-now
  • Category: Technology
  • Meta image: image2
  • Meta description: From AI to quantum computing, the future is arriving faster than ever.

Post 3 — "Navigating the Digital Landscape: A Comprehensive Guide"

  • Slug: navigating-the-digital-landscape
  • Category: Technology
  • Meta image: image3
  • Meta description: Your roadmap to thriving in an increasingly connected world.

Related Posts

Set relatedPosts after all posts are created, using payload.update(). This avoids the validation error that occurs when filterOptions tries to exclude by ID during initial creation:

// Create all 3 posts first, collect their IDs, then:
await payload.update({ collection: 'posts', id: post1Id, data: { relatedPosts: [post2Id, post3Id] } })
await payload.update({ collection: 'posts', id: post2Id, data: { relatedPosts: [post1Id, post3Id] } })
await payload.update({ collection: 'posts', id: post3Id, data: { relatedPosts: [post1Id, post2Id] } })

Category

  • Title: "Technology"

Users

  • Name: John Doe (post author)

Lexical Rich Text — Seed Format

When seeding richText fields, the value must be a serialized Lexical editor state. Minimum structure:

{
  root: {
    type: 'root',
    version: 1,
    direction: 'ltr',
    format: '',
    indent: 0,
    children: [
      // heading node
      {
        type: 'heading', tag: 'h2',
        version: 1, direction: 'ltr', format: '', indent: 0,
        children: [{ type: 'text', text: 'Hello world', version: 1 }],
      },
      // paragraph node
      {
        type: 'paragraph',
        version: 1, direction: 'ltr', format: '', indent: 0,
        children: [{ type: 'text', text: 'Body copy here.', version: 1 }],
      },
    ],
  },
}

Define small helpers at the top of the seed file to keep it readable:

const rt = (...children: object[]) => ({
  root: { type: 'root', version: 1, direction: 'ltr', format: '', indent: 0, children },
})
const h = (tag: string, text: string) => ({
  type: 'heading',
  tag,
  version: 1,
  direction: 'ltr',
  format: '',
  indent: 0,
  children: [{ type: 'text', text, version: 1 }],
})
const p = (text: string) => ({
  type: 'paragraph',
  version: 1,
  direction: 'ltr',
  format: '',
  indent: 0,
  children: [{ type: 'text', text, version: 1 }],
})

Payload Collections & Globals

Collections

Collection Key features
pages Flexible layout builder (hero field + blocks array), SEO meta, draft/preview
posts Title, content (Lexical), meta image, categories (relation), author (relation to users), published date
media Image uploads with alt text, focal point, sizes
categories Simple title + slug
users Auth-enabled, name field
forms Form builder plugin — fields, emails, confirmation
form-submissions Stores form submissions
search Search plugin index (synced from posts)

Globals

Global Purpose
header Nav items (links array)
footer Nav items (links array)

Routing

Route File Notes
/ app/(frontend)/page.tsx Fetches pages slug=home
/posts app/(frontend)/posts/page.tsx Lists all posts
/posts/[slug] app/(frontend)/posts/[slug]/page.tsx Post detail
/search app/(frontend)/search/page.tsx Search with ?q= param
/contact app/(frontend)/[slug]/page.tsx Generic page with contact slug
/admin Payload admin (built-in)
/api Payload REST API

Key Implementation Details

Negative margin hero pattern

The HighImpactHero and PostHero use -mt-[10.4rem] to slide behind the sticky header (which is ~80px + page top padding). The hero has data-theme="dark" so the header switches to white text via the HeaderTheme context.

Draft/Preview

Pages and posts support draft mode. A draftMode() Next.js flag shows the AdminBar component at the top when previewing unpublished content.

Image handling

All images go through a <Media> component that wraps Next.js <Image> with Payload's size variants. The fill prop is used for full-bleed backgrounds; size prop controls responsive srcset hints.

Link component (CMSLink)

Handles internal/external links, new-tab, and appearance mapping to Button variants:

  • appearance: 'default'<Button variant="default">
  • appearance: 'outline'<Button variant="outline">
  • No appearance → plain anchor with text-primary + underline

relatedPosts field

Define without filterOptions on the collection itself (the admin UI handles exclusion). When seeding, always create all posts first and then wire up related posts with payload.update() calls — doing it during initial creation causes a validation error because id is undefined at that point.


File Structure (frontend)

src/
  app/
    layout.tsx               ← minimal root layout (no HTML — just returns children)
    (frontend)/
      globals.css            ← all design tokens
      layout.tsx             ← frontend layout (html, head, body, fonts, providers, header, footer)
      page.tsx               ← homepage
      posts/
        page.tsx             ← posts listing
        [slug]/page.tsx      ← post detail
      search/page.tsx        ← search
      [slug]/page.tsx        ← generic CMS pages (contact, etc.)
    (payload)/
      layout.tsx             ← minimal layout (just returns children)
      admin/
        [[...segments]]/
          page.tsx           ← Payload admin UI
          not-found.tsx      ← Payload 404
        importMap.js         ← export const importMap = {}
      api/
        [...slug]/route.ts   ← Payload REST API (GET/POST/PUT/PATCH/DELETE/OPTIONS)
  blocks/
    ArchiveBlock/
      config.ts              ← Payload Block definition
      Component.tsx          ← React component
    CallToAction/
      config.ts
      Component.tsx
    Content/
      config.ts
      Component.tsx
    Form/
      config.ts
      Component.tsx
    MediaBlock/
      config.ts
      Component.tsx
    RenderBlocks.tsx         ← maps blockType → Component
  components/
    Card/                    ← post card
    CollectionArchive/       ← responsive post grid
    Logo/                    ← Payload logo (SVG)
    Media/                   ← Next.js image wrapper
    RichText/                ← Lexical renderer (uses @payloadcms/richtext-lexical/react)
    Link/                    ← CMSLink (wraps Button for CMS link data)
    Pagination/
    PageRange/
    ui/
      button.tsx             ← shadcn button
  heros/
    HighImpact/              ← full-viewport dark hero
    LowImpact/               ← simple page title hero
    PostHero/                ← post detail hero with image
    RenderHero.tsx
  Header/
    Component.tsx            ← async header (fetches global)
    Component.client.tsx     ← client header (theme + pathname)
    Nav/index.tsx
  Footer/
    Component.tsx            ← async footer
  providers/
    Theme/
      InitTheme.tsx          ← inline script to prevent FOUC
      ThemeSelector/index.tsx ← Light/Dark/System dropdown
      index.tsx
      types.ts               ← Theme type + themeLocalStorageKey constant
    HeaderTheme/index.tsx    ← context for per-page header color
    index.tsx                ← Providers wrapper
  collections/
    Pages/index.ts
    Posts/index.ts
    Media.ts
    Categories.ts
    Users.ts
  globals/
    Header/index.ts
    Footer/index.ts
  fields/
    defaultLexical.ts
    link.ts
    linkGroup.ts
  endpoints/
    seed/index.ts
  payload.config.ts
  payload-types.ts           ← generated by `payload generate:types`
postcss.config.mjs           ← required for Tailwind v4: { plugins: { '@tailwindcss/postcss': {} } }
.npmrc                       ← add enable-pre-post-scripts=true so sharp/esbuild build scripts run

Regeneration Instructions

To recreate this template from scratch:

  1. Bootstrap: pnpm create payload-app → choose "Website" template, or start from npx create-next-app + install payload.

  2. Install deps: payload, @payloadcms/next, @payloadcms/richtext-lexical, @payloadcms/plugin-seo, @payloadcms/plugin-form-builder, @payloadcms/plugin-search, @payloadcms/db-sqlite, geist, tailwindcss@latest, @tailwindcss/postcss, @tailwindcss/typography. All @payloadcms/* packages must be pinned to the same version as payload.

  3. Configure pnpm: Add enable-pre-post-scripts=true to .npmrc so sharp and esbuild build scripts run.

  4. Apply design tokens: Copy the :root and [data-theme='dark'] CSS variable blocks from the Design Tokens section above into globals.css.

  5. Set up Geist font: In (frontend)/layout.tsx, import GeistSans and GeistMono from geist/font/*, apply as CSS variables to <html>.

  6. Build collections: Create pages, posts, media, categories, users collections with the fields described above. Register all block configs in Pages's layout blocks array.

  7. Build globals: Create header and footer globals with nav items.

  8. Build blocks: For each block, create both a config.ts (Payload Block definition) and a Component.tsx (React renderer). Register configs in the Pages collection.

  9. Set up Payload admin routes: Create app/(payload)/admin/[[...segments]]/page.tsx, not-found.tsx, importMap.js, and app/(payload)/api/[...slug]/route.ts. These are required for the admin panel and REST API.

  10. Seed data: Fetch the four seed images from their GitHub raw URLs and upload to media first. Then create posts, category, and pages. Wire up relatedPosts with payload.update() after all posts exist.

  11. Theme system: Implement InitTheme (inline <script> in <head> that reads localStorage and sets data-theme), ThemeSelector dropdown, and HeaderTheme context.

  12. Verify pages: Homepage, /posts, /posts/[slug], /search?q=, /contact should all render correctly in both light and dark mode.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment