feat: migrate website-creator from Next.js+Payload to Astro+Tina CMS

Major changes:
- Replace Payload CMS with Tina CMS (self-hosted)
- Add Astro DB for consent logging (PDPA compliant)
- Update Tailwind v3 to v4 (@tailwindcss/vite plugin)
- Add astro-tina-starter template
- Rewrite consent template for Astro (ConsentBanner.astro, Astro DB, Nano Stores)
- Add install-tina-backend.sh for self-hosted Tina per customer
- Rename convert-astro.sh to migrate-tina.sh
- Add AGENTS.md template for generated websites
- Delete all Payload/Next.js files

Technical updates:
- Astro DB using defineDb with eq operators for queries
- Tailwind v4 with @theme block
- Tina CMS local development mode
- Proper Astro API routes for consent

Research-verified with official documentation (April 2026)
This commit is contained in:
2026-04-17 14:52:59 +07:00
parent ce8483e546
commit 628298183a
74 changed files with 3536 additions and 11431 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,290 +0,0 @@
---
name: payload-lexical-integration
description: แนวทางการรวม Payload CMS Lexical richText content กับ design system components — อธิบายว่าทำไม design skill output กับ Payload content ถึงอยู่คนละ layer และวิธี integrate มันเข้าด้วยกัน
category: software-development
---
# Payload Lexical Integration
## ปัญหา
เวลาใช้ design skill (ui-ux-pro-max) กับ Payload CMS มักเกิดความสับสน:
- Design skill ให้โค้ดแบบไหน?
- Payload Lexical เก็บ content ยังไง?
- ทำไม content ไม่แสดงหลังสร้าง fields เสร็จ?
## สิ่งที่ต้องเข้าใจก่อน
### Two Layers — แยกกันทำ
```
┌─────────────────────────────────────────────────────────┐
│ DESIGN LAYER (ui-ux-pro-max, ckm:design, ckm:ui-styling)│
│ • Component structure (Hero, Card, Navbar) │
│ • Color tokens, typography, spacing │
│ • Animation specs (150-300ms, ease-out) │
│ • Layout grid, responsive breakpoints │
│ • Interaction states │
│ │
│ Output: React + Tailwind code — "ภาชนะ" ไม่ใช่ "เนื้อหา"│
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ CONTENT LAYER (Payload CMS) │
│ • ข้อความ + format (bold, italic, link) │
│ • Headings (H1-H6) │
│ • Lists, blockquotes, code blocks │
│ • Images, links │
│ • Tables │
│ │
│ Output: Lexical JSON — "เนื้อหา" ไม่ใช่ "ภาชนะ" │
└─────────────────────────────────────────────────────────┘
```
**Design skill สร้าง "ภาชนะ" — Payload สร้าง "เนื้อหา" — ต้องรวมกันตอน render**
---
## ขั้นตอน
```
[1] Design Phase
ui-ux-pro-max → Component structure, tokens, animations
Output: Component skeleton (ไม่มี content)
[2] Payload Phase
สร้าง Collections + richText Fields
Output: Content structure ใน Payload
[3] Content Phase
พิมพ์ content ใน /admin (Lexical visual editor)
Output: Lexical JSON
[4] Integration Phase
ครอบ Payload content ด้วย Design components
```
---
## Step 1: Payload Collection
กำหนด content fields ตาม section:
```ts
// src/collections/Posts.ts
const Posts: CollectionConfig = {
slug: 'posts',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'slug', type: 'text', required: true },
{ name: 'heroContent', type: 'richText' }, // content สำหรับ Hero
{ name: 'features', type: 'array',
fields: [
{ name: 'heading', type: 'text' },
{ name: 'content', type: 'richText' }, // content ในแต่ละ card
]
},
{ name: 'testimonial', type: 'richText' },
{ name: 'featuredImage', type: 'upload', relationTo: 'media' },
{ name: 'status', type: 'select', options: [...], defaultValue: 'draft' },
],
}
```
---
## Step 2: สร้าง Payload Helpers
```ts
// src/lib/payload-helpers.ts
import { getPayload } from 'payload'
import config from '@/payload.config'
export async function getPost(slug: string) {
const p = await getPayload({ config })
const { docs } = await p.find({
collection: 'posts',
where: { slug: { equals: slug } },
depth: 2,
})
return docs[0] ?? null
}
export async function getAllPosts() {
const p = await getPayload({ config })
return p.find({
collection: 'posts',
where: { status: { equals: 'published' } },
depth: 1,
})
}
```
---
## Step 3: Integration — Design Component + RichText
```tsx
// src/app/(frontend)/posts/[slug]/page.tsx
import { getPost } from '@/lib/payload-helpers'
import { RichText } from '@payloadcms/richtext-lexical'
// Design tokens จาก ui-ux-pro-max
const tokens = {
hero: 'text-5xl md:text-7xl font-bold tracking-tight',
section: 'py-20 px-6 max-w-7xl mx-auto',
card: 'rounded-2xl border border-slate-200 p-6 shadow-sm',
animate: 'animate-fade-in duration-300 ease-out',
}
// Design component ครอบ Payload richText
function HeroSection({ title, content }: { title: string; content: any }) {
return (
<section className={`${tokens.section} text-center`}>
<h1 className={`${tokens.hero} mb-6`}>{title}</h1>
{content && (
<div className="max-w-3xl mx-auto">
{/* Payload content → RichText → design wrapper */}
<RichText data={content} className="prose prose-lg" />
</div>
)}
</section>
)
}
function FeatureCard({ heading, content }: { heading: string; content: any }) {
return (
<div className={`${tokens.card} ${tokens.animate}`}>
<h3 className="text-xl font-semibold mb-3">{heading}</h3>
{content && <RichText data={content} className="prose prose-sm" />}
</div>
)
}
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug)
if (!post) return <div>Not found</div>
return (
<main className="min-h-screen">
<HeroSection title={post.title} content={post.heroContent} />
{post.features?.length > 0 && (
<section className={`${tokens.section} bg-slate-50`}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{post.features.map((f: any, i: number) => (
<FeatureCard key={i} heading={f.heading} content={f.content} />
))}
</div>
</section>
)}
</main>
)
}
```
---
## Animation
Animation apply ที่ **wrapper element** ไม่ใช่ที่ content — เพราะ Lexical JSON เก็บแค่ content structure ไม่เก็บ animation metadata
```tsx
// ✅ ถูก — animation ที่ wrapper
<div className="animate-hero-in">
<RichText data={post.content} />
</div>
// ❌ ผิด — พยายามใส่ animation ใน Lexical JSON
```
Design skill จะให้ animation spec เป็น CSS class — แค่ apply ที่ element ที่ wrap `<RichText>`
---
## Tailwind Typography Setup
```bash
pnpm add @tailwindcss/typography
```
```ts
// tailwind.config.ts
plugins: [require('@tailwindcss/typography')],
```
ใช้ class `prose` กับ `<RichText>`:
```tsx
<RichText data={post.content} className="prose prose-lg max-w-none" />
```
---
## Payload Config: เปิด Lexical Editor
```ts
// payload.config.ts
import { lexicalEditor } from '@payloadcms/richtext-lexical'
export default buildConfig({
editor: lexicalEditor(), // ← ต้องมีถึงจะใช้ visual editor ได้
// ...
})
```
---
## Common Mistakes
### 1. Design skill ให้ hardcode content
Design skill อาจให้แบบนี้:
```tsx
// ❌ สิ่งที่ design skill อาจให้มา
<div className="hero">
<h1>Welcome to Our Site</h1> // hardcode
<p>Amazing content here...</p> // hardcode
</div>
```
ต้องแปลงเป็น:
```tsx
// ✅
<div className="hero animate-hero-in">
<h1>{post.title}</h1>
{post.heroContent && (
<RichText data={post.heroContent} className="prose" />
)}
</div>
```
### 2. ลืม lexicalEditor() ใน payload.config
ถ้าไม่มี `editor: lexicalEditor()` → visual editor จะไม่ขึ้น
### 3. ลืม Tailwind typography plugin
ถ้าไม่มี `@tailwindcss/typography` → richText output จะไม่มี styling
---
## สรุป: ใครทำอะไร
| Design Layer ทำ | Payload Layer ทำ | Integration ทำ |
|-----------------|------------------|----------------|
| Component structure | Content storage | ครอบ `RichText` ด้วย design component |
| Color/tokens | richText fields | Apply design tokens กับ Payload output |
| Typography system | Visual editor (/admin) | Style richText output ด้วย prose class |
| Animation specs | Content rendering | Wrap output ด้วย animation classes |
| Layout grid | SEO fields (via plugin) | Layout คงที่ + content จาก Payload |
---
## Related
- `website-creator` — workflow หลักในการสร้างเว็บด้วย Next.js + Payload
- `payload` — Payload CMS skill (fields, hooks, queries, plugins)

View File

@@ -1,183 +0,0 @@
---
name: payload-nextjs-turbopack-fix
description: Fix Payload CMS white screen / module load errors when using Next.js 16 with Turbopack
tags: [payload, nextjs, turbopack, troubleshooting, white-screen]
category: software-development
---
# Payload CMS + Next.js 16 Turbopack White Screen Fix
## Symptom
Payload CMS admin shows white screen or "initializing" forever. Console/network tab shows:
```
Error: Failed to load external module @payloadcms/db-mongodb-XXXXXXXXXXXX
ResolveMessage: Cannot find module '@payloadcms/db-mongodb-XXXXXXXXXXXX'
```
Or server returns HTTP 500 on `/admin/create-first-user` or `/admin`.
## Root Cause
**Next.js 16 defaults to Turbopack in dev mode.** Payload CMS 3.x (specifically `@payloadcms/db-mongodb`) is NOT compatible with Turbopack's module resolution — it uses Webpack-specific module IDs that Turbopack can't resolve.
## Fix Steps
### Step 1: Verify MongoDB is running
```bash
ss -tlnp | grep -E '27019|27017'
pgrep -a mongo
```
MongoDB must be running on the expected port. Check `.env` for `MONGODB_URL`.
### Step 2: Remove Next.js 16-only experimental options from next.config.ts
When downgrading from Next 16 → 15, remove any `experimental.turbo` config that was added for Next 16. In Next.js 15 this option doesn't exist and generates a warning:
```ts
// WRONG in Next.js 15 — 'turbo' is not a known ExperimentalConfig key
experimental: {
turbo: undefined,
},
// CORRECT — remove experimental.turbo entirely for Next.js 15
```
### Step 3: Downgrade Next.js to 15.x (15.5.x)
```bash
cd /path/to/moreminimore-next
bun add next@15.5.15 react@19.0.0 react-dom@19.0.0
```
Next.js 15 uses Webpack by default in dev mode, which is fully compatible with Payload CMS.
**Why not just disable Turbopack?**
- Next.js 16 has NO `--no-turbo` flag (error: unknown option)
- `NEXT_TURBOPACK=0` env var does NOT disable Turbopack in Next 16 (still starts with Turbopack)
- `experimental.turbo: undefined` in next.config.ts does NOT disable it in Next 16
- Downgrade to Next.js 15.x is the only viable option
### Step 3: Verify version
```bash
cat node_modules/next/package.json | grep '"version"'
```
Should show `15.5.x` (not `16.x`).
### Step 4: Clear cache and restart
```bash
pkill -9 -f next 2>/dev/null
rm -rf .next
bun run dev
```
### Step 5: Verify admin loads
Navigate to `http://localhost:3000/admin` — should show Payload login screen.
## Compatibility Matrix
| Next.js | Bundler | Payload CMS | Status |
|---------|---------|-------------|--------|
| 16.x | Turbopack (default) | 3.x | BROKEN |
| 16.x | Webpack (flag) | 3.x | No flag available |
| 15.5.x | Webpack (default) | 3.x | WORKS |
| 14.x | Webpack | 3.x | WORKS |
## Additional Dev Server Issues (Lessons Learned)
### Server crashes after "Ready in Xms"
Even with Next.js 15.5.15, the dev server may crash silently right after "Ready" message. Two known causes:
**1. `output: 'standalone'` in next.config.ts**
This causes Next.js to crash immediately after starting in dev mode. Remove it:
```ts
// WRONG — causes crash after "Ready" in dev mode
const nextConfig: NextConfig = {
output: 'standalone', // REMOVE THIS
...
}
// CORRECT — no output option in dev
const nextConfig: NextConfig = {
// (no output key)
...
}
```
**2. `NEXT_TURBOPACK=0` in dev script**
This env var can cause issues even on Next.js 15. Remove it:
```json
// WRONG
"dev": "cross-env NODE_OPTIONS=--no-deprecation NEXT_TURBOPACK=0 next dev"
// CORRECT
"dev": "cross-env NODE_OPTIONS=--no-deprecation next dev"
```
Restart with clean `.next` cache after making changes:
```bash
pkill -9 -f next; sleep 1
rm -rf .next
bun run dev
```
### Server starts but port 3000 shows nothing / 404
If `ss -tlnp | grep 3000` shows the port is listening but the site returns 404:
1. Check if there's a compiled `.next` cache from a previous version — always `rm -rf .next` before restarting
2. Verify MongoDB is running: `pgrep -a mongo`
3. Check server logs: `cat /tmp/moredev.log`
## Blog Posts Migration (Astro MD → Payload CMS)
Script location: `src/scripts/migrate-posts.ts`
Key approach:
- Use **absolute paths** for `configPath` and `blogDir` (avoid relative path resolution issues with ESM)
- Use **dynamic imports** for Payload config to avoid bundling issues
- Store content as plain text (strip markdown syntax with regex replacements)
- Check for existing posts by slug before creating (idempotent)
```bash
cd /home/kunthawat/moreminimore-next
npx tsx src/scripts/migrate-posts.ts
```
## What to check if still broken
1. **sharp module**: If you see `Failed to load external module sharp-XXX`, check `node_modules/sharp` exists:
```bash
ls node_modules/sharp
```
If missing: `bun add sharp`
2. **MongoDB connection**: Ensure `MONGODB_URL` in `.env` matches running mongod port
3. **Port conflict**: If port 3000 is in use:
```bash
pkill -9 -f next; pkill -9 -f bun
ss -tlnp | grep 3000
```
4. **Dev server process shows "Killed" but server is still running**:
The `bun run dev` foreground process may get killed by the shell even when the Next.js server starts successfully. Always check port 3000 directly:
```bash
ss -tlnp | grep 3000
pgrep -a next-server
```
If port 3000 is listening, the server IS running — ignore the "Killed" message.
5. **TypeScript lint errors from node_modules**: The `next lint` output shows many TS errors from `node_modules/` (e.g., `@types/react`, `next/dist/...`). These are non-blocking noise — they don't prevent the dev server from running or the admin from loading. Ignore them.
## Key Takeaway
Next.js 16 + Turbopack is incompatible with Payload CMS 3.x database adapters. Always downgrade to Next.js 15.5.x when using Payload with MongoDB adapter.

View File

@@ -1,62 +0,0 @@
---
name: payload-v3-admin-init
description: Create the first admin user in Payload CMS v3 via an internal API route. Solves the missing onInit hook problem.
category: devops
---
# Payload v3 — Create Admin User via API Route
## Problem
No admin user exists in Payload CMS. Login page at `/admin` shows email/password form but no user was created on first boot.
## Key Finding: No `onInit` Hook in Payload v3
Payload v3 `buildConfig()` does NOT have an `onInit` hook. The v2 pattern `hooks: { init: [...] }` does not exist. Adding it causes TypeScript errors.
## Solution: Create Admin via API Route
**File:** `src/app/api/create-admin/route.ts`
```typescript
import { NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@/payload.config'
export async function POST() {
try {
const p = await getPayload({ config })
const existing = await p.find({ collection: 'users', limit: 1 })
if (existing.totalDocs > 0) {
return NextResponse.json({ message: 'Admin already exists', email: existing.docs[0].email })
}
const result = await p.create({
collection: 'users',
data: {
email: 'admin@dealplustech.co.th',
password: 'DealPlus2026!',
},
})
return NextResponse.json({ success: true, email: result.email })
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 })
}
}
```
Then call:
```bash
curl -X POST http://localhost:3001/api/create-admin
```
## Common Errors
| Error | Cause | Fix |
|-------|-------|-----|
| `the payload config is required for getPayload to work` | Used `getPayload({ mongoURL })` instead of `getPayload({ config })` | Pass `config` import |
| `GET /api/users` returns 403 | Auth required — cannot list users without being logged in | Use internal API route instead |
| `onInit` in `buildConfig()` TypeScript error | Hook doesn't exist in v3 | Remove it, use API route |
## Verification
After creating, visit `/admin` and login with the credentials set in the API route.

View File

@@ -1,448 +0,0 @@
---
name: payload
description: Use when working with Payload CMS projects (payload.config.ts, collections, fields, hooks, access control, Payload API). Use when debugging validation errors, security issues, relationship queries, transactions, or hook behavior.
---
# Payload CMS Application Development
Payload is a Next.js native CMS with TypeScript-first architecture, providing admin panel, database management, REST/GraphQL APIs, authentication, and file storage.
## Quick Reference
| Task | Solution | Details |
| ------------------------ | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| Auto-generate slugs | `slugField()` | [FIELDS.md#slug-field-helper](reference/FIELDS.md#slug-field-helper) |
| Restrict content by user | Access control with query | [ACCESS-CONTROL.md#row-level-security-with-complex-queries](reference/ACCESS-CONTROL.md#row-level-security-with-complex-queries) |
| Local API user ops | `user` + `overrideAccess: false` | [QUERIES.md#access-control-in-local-api](reference/QUERIES.md#access-control-in-local-api) |
| Draft/publish workflow | `versions: { drafts: true }` | [COLLECTIONS.md#versioning--drafts](reference/COLLECTIONS.md#versioning--drafts) |
| Computed fields | `virtual: true` with afterRead | [FIELDS.md#virtual-fields](reference/FIELDS.md#virtual-fields) |
| Conditional fields | `admin.condition` | [FIELDS.md#conditional-fields](reference/FIELDS.md#conditional-fields) |
| Custom field validation | `validate` function | [FIELDS.md#validation](reference/FIELDS.md#validation) |
| Filter relationship list | `filterOptions` on field | [FIELDS.md#relationship](reference/FIELDS.md#relationship) |
| Select specific fields | `select` parameter | [QUERIES.md#field-selection](reference/QUERIES.md#field-selection) |
| Auto-set author/dates | beforeChange hook | [HOOKS.md#collection-hooks](reference/HOOKS.md#collection-hooks) |
| Prevent hook loops | `req.context` check | [HOOKS.md#context](reference/HOOKS.md#context) |
| Cascading deletes | beforeDelete hook | [HOOKS.md#collection-hooks](reference/HOOKS.md#collection-hooks) |
| Geospatial queries | `point` field with `near`/`within` | [FIELDS.md#point-geolocation](reference/FIELDS.md#point-geolocation) |
| Reverse relationships | `join` field type | [FIELDS.md#join-fields](reference/FIELDS.md#join-fields) |
| Next.js revalidation | Context control in afterChange | [HOOKS.md#nextjs-revalidation-with-context-control](reference/HOOKS.md#nextjs-revalidation-with-context-control) |
| Query by relationship | Nested property syntax | [QUERIES.md#nested-properties](reference/QUERIES.md#nested-properties) |
| Complex queries | AND/OR logic | [QUERIES.md#andor-logic](reference/QUERIES.md#andor-logic) |
| Transactions | Pass `req` to operations | [ADAPTERS.md#threading-req-through-operations](reference/ADAPTERS.md#threading-req-through-operations) |
| Background jobs | Jobs queue with tasks | [ADVANCED.md#jobs-queue](reference/ADVANCED.md#jobs-queue) |
| Custom API routes | Collection custom endpoints | [ADVANCED.md#custom-endpoints](reference/ADVANCED.md#custom-endpoints) |
| Cloud storage | Storage adapter plugins | [ADAPTERS.md#storage-adapters](reference/ADAPTERS.md#storage-adapters) |
| Multi-language | `localization` config + `localized: true` | [ADVANCED.md#localization](reference/ADVANCED.md#localization) |
| Create plugin | `(options) => (config) => Config` | [PLUGIN-DEVELOPMENT.md#plugin-architecture](reference/PLUGIN-DEVELOPMENT.md#plugin-architecture) |
| Plugin package setup | Package structure with SWC | [PLUGIN-DEVELOPMENT.md#plugin-package-structure](reference/PLUGIN-DEVELOPMENT.md#plugin-package-structure) |
| Add fields to collection | Map collections, spread fields | [PLUGIN-DEVELOPMENT.md#adding-fields-to-collections](reference/PLUGIN-DEVELOPMENT.md#adding-fields-to-collections) |
| Plugin hooks | Preserve existing hooks in array | [PLUGIN-DEVELOPMENT.md#adding-hooks](reference/PLUGIN-DEVELOPMENT.md#adding-hooks) |
| Check field type | Type guard functions | [FIELD-TYPE-GUARDS.md](reference/FIELD-TYPE-GUARDS.md) |
## Quick Start
```bash
npx create-payload-app@latest my-app
cd my-app
pnpm dev
```
### Minimal Config
```ts
import { buildConfig } from 'payload'
import { mongooseAdapter } from '@payloadcms/db-mongodb'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import path from 'path'
import { fileURLToPath } from 'url'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfig({
admin: {
user: 'users',
importMap: {
baseDir: path.resolve(dirname),
},
},
collections: [Users, Media],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET,
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
db: mongooseAdapter({
url: process.env.DATABASE_URL,
}),
})
```
## Essential Patterns
### Basic Collection
```ts
import type { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'author', 'status', 'createdAt'],
},
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'slug', type: 'text', unique: true, index: true },
{ name: 'content', type: 'richText' },
{ name: 'author', type: 'relationship', relationTo: 'users' },
],
timestamps: true,
}
```
For more collection patterns (auth, upload, drafts, live preview), see [COLLECTIONS.md](reference/COLLECTIONS.md).
### Common Fields
```ts
// Text field
{ name: 'title', type: 'text', required: true }
// Relationship
{ name: 'author', type: 'relationship', relationTo: 'users', required: true }
// Rich text
{ name: 'content', type: 'richText', required: true }
// Select
{ name: 'status', type: 'select', options: ['draft', 'published'], defaultValue: 'draft' }
// Upload
{ name: 'image', type: 'upload', relationTo: 'media' }
```
For all field types (array, blocks, point, join, virtual, conditional, etc.), see [FIELDS.md](reference/FIELDS.md).
### Hook Example
```ts
export const Posts: CollectionConfig = {
slug: 'posts',
hooks: {
beforeChange: [
async ({ data, operation }) => {
if (operation === 'create') {
data.slug = slugify(data.title)
}
return data
},
],
},
fields: [{ name: 'title', type: 'text' }],
}
```
For all hook patterns, see [HOOKS.md](reference/HOOKS.md). For access control, see [ACCESS-CONTROL.md](reference/ACCESS-CONTROL.md).
### Access Control with Type Safety
```ts
import type { Access } from 'payload'
import type { User } from '@/payload-types'
// Type-safe access control
export const adminOnly: Access = ({ req }) => {
const user = req.user as User
return user?.roles?.includes('admin') || false
}
// Row-level access control
export const ownPostsOnly: Access = ({ req }) => {
const user = req.user as User
if (!user) return false
if (user.roles?.includes('admin')) return true
return {
author: { equals: user.id },
}
}
```
### Query Example
```ts
// Local API
const posts = await payload.find({
collection: 'posts',
where: {
status: { equals: 'published' },
'author.name': { contains: 'john' },
},
depth: 2,
limit: 10,
sort: '-createdAt',
})
// Query with populated relationships
const post = await payload.findByID({
collection: 'posts',
id: '123',
depth: 2, // Populates relationships (default is 2)
})
// Returns: { author: { id: "user123", name: "John" } }
// Without depth, relationships return IDs only
const post = await payload.findByID({
collection: 'posts',
id: '123',
depth: 0,
})
// Returns: { author: "user123" }
```
For all query operators and REST/GraphQL examples, see [QUERIES.md](reference/QUERIES.md).
### Getting Payload Instance
```ts
// In API routes (Next.js)
import { getPayload } from 'payload'
import config from '@payload-config'
export async function GET() {
const payload = await getPayload({ config })
const posts = await payload.find({
collection: 'posts',
})
return Response.json(posts)
}
// In Server Components
import { getPayload } from 'payload'
import config from '@payload-config'
export default async function Page() {
const payload = await getPayload({ config })
const { docs } = await payload.find({ collection: 'posts' })
return <div>{docs.map(post => <h1 key={post.id}>{post.title}</h1>)}</div>
}
```
## Security Pitfalls
### 1. Local API Access Control (CRITICAL)
**By default, Local API operations bypass ALL access control**, even when passing a user.
```ts
// ❌ SECURITY BUG: Passes user but ignores their permissions
await payload.find({
collection: 'posts',
user: someUser, // Access control is BYPASSED!
})
// ✅ SECURE: Actually enforces the user's permissions
await payload.find({
collection: 'posts',
user: someUser,
overrideAccess: false, // REQUIRED for access control
})
```
**When to use each:**
- `overrideAccess: true` (default) - Server-side operations you trust (cron jobs, system tasks)
- `overrideAccess: false` - When operating on behalf of a user (API routes, webhooks)
See [QUERIES.md#access-control-in-local-api](reference/QUERIES.md#access-control-in-local-api).
### 2. Transaction Failures in Hooks
**Nested operations in hooks without `req` break transaction atomicity.**
```ts
// ❌ DATA CORRUPTION RISK: Separate transaction
hooks: {
afterChange: [
async ({ doc, req }) => {
await req.payload.create({
collection: 'audit-log',
data: { docId: doc.id },
// Missing req - runs in separate transaction!
})
},
]
}
// ✅ ATOMIC: Same transaction
hooks: {
afterChange: [
async ({ doc, req }) => {
await req.payload.create({
collection: 'audit-log',
data: { docId: doc.id },
req, // Maintains atomicity
})
},
]
}
```
See [ADAPTERS.md#threading-req-through-operations](reference/ADAPTERS.md#threading-req-through-operations).
### 3. Infinite Hook Loops
**Hooks triggering operations that trigger the same hooks create infinite loops.**
```ts
// ❌ INFINITE LOOP
hooks: {
afterChange: [
async ({ doc, req }) => {
await req.payload.update({
collection: 'posts',
id: doc.id,
data: { views: doc.views + 1 },
req,
}) // Triggers afterChange again!
},
]
}
// ✅ SAFE: Use context flag
hooks: {
afterChange: [
async ({ doc, req, context }) => {
if (context.skipHooks) return
await req.payload.update({
collection: 'posts',
id: doc.id,
data: { views: doc.views + 1 },
context: { skipHooks: true },
req,
})
},
]
}
```
See [HOOKS.md#context](reference/HOOKS.md#context).
## Project Structure
```txt
src/
├── app/
│ ├── (frontend)/
│ │ └── page.tsx
│ └── (payload)/
│ └── admin/[[...segments]]/page.tsx
├── collections/
│ ├── Posts.ts
│ ├── Media.ts
│ └── Users.ts
├── globals/
│ └── Header.ts
├── components/
│ └── CustomField.tsx
├── hooks/
│ └── slugify.ts
└── payload.config.ts
```
## Type Generation
```ts
// payload.config.ts
export default buildConfig({
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
// ...
})
// Usage
import type { Post, User } from '@/payload-types'
```
## Common Gotchas
1. **Local API bypasses access control** unless you pass `overrideAccess: false`
2. **Missing `req` in nested operations** breaks transaction atomicity
3. **Hook loops** — operations in hooks can re-trigger the same hooks; use `req.context` flags
4. **Field-level access** returns boolean only, no query constraints
5. **Relationship depth** defaults to 2; set `depth: 0` for IDs only
6. **Draft status**`_status` field is auto-injected when drafts are enabled
7. **Types are stale** until you run `generate:types`
8. **MongoDB transactions** require replica set configuration
9. **SQLite transactions** are disabled by default; enable with `transactionOptions: {}`
10. **Point fields** are not supported in SQLite
## Best Practices
### Security
- Default to restrictive access, gradually add permissions
- Use `overrideAccess: false` when passing `user` to Local API
- Field-level access only returns boolean (no query constraints)
- Never trust client-provided data
- Use `saveToJWT: true` for roles to avoid database lookups
### Performance
- Index frequently queried fields
- Use `select` to limit returned fields
- Set `maxDepth` on relationships to prevent over-fetching
- Prefer query constraints over async operations in access control
- Cache expensive operations in `req.context`
### Data Integrity
- Always pass `req` to nested operations in hooks
- Use context flags to prevent infinite hook loops
- Enable transactions for MongoDB (requires replica set) and Postgres
- Use `beforeValidate` for data formatting
- Use `beforeChange` for business logic
### Type Safety
- Run `generate:types` after schema changes
- Import types from generated `payload-types.ts`
- Type your user object: `import type { User } from '@/payload-types'`
- Use `as const` for field options
- Use field type guards for runtime type checking
### Organization
- Keep collections in separate files
- Extract access control to `access/` directory
- Extract hooks to `hooks/` directory
- Use reusable field factories for common patterns
- Document complex access control with comments
## Reference Documentation
- **[FIELDS.md](reference/FIELDS.md)** - All field types, validation, admin options
- **[FIELD-TYPE-GUARDS.md](reference/FIELD-TYPE-GUARDS.md)** - Type guards for runtime field type checking and narrowing
- **[COLLECTIONS.md](reference/COLLECTIONS.md)** - Collection configs, auth, upload, drafts, live preview
- **[HOOKS.md](reference/HOOKS.md)** - Collection hooks, field hooks, context patterns
- **[ACCESS-CONTROL.md](reference/ACCESS-CONTROL.md)** - Collection, field, global access control, RBAC, multi-tenant
- **[ACCESS-CONTROL-ADVANCED.md](reference/ACCESS-CONTROL-ADVANCED.md)** - Context-aware, time-based, subscription-based access, factory functions, templates
- **[QUERIES.md](reference/QUERIES.md)** - Query operators, Local/REST/GraphQL APIs
- **[ENDPOINTS.md](reference/ENDPOINTS.md)** - Custom API endpoints: authentication, helpers, request/response patterns
- **[ADAPTERS.md](reference/ADAPTERS.md)** - Database, storage, email adapters, transactions
- **[ADVANCED.md](reference/ADVANCED.md)** - Authentication, jobs, endpoints, components, plugins, localization
- **[PLUGIN-DEVELOPMENT.md](reference/PLUGIN-DEVELOPMENT.md)** - Plugin architecture, monorepo structure, patterns, best practices
## Resources
- llms-full.txt: <https://payloadcms.com/llms-full.txt>
- Docs: <https://payloadcms.com/docs>
- GitHub: <https://github.com/payloadcms/payload>
- Examples: <https://github.com/payloadcms/payload/tree/main/examples>
- Templates: <https://github.com/payloadcms/payload/tree/main/templates>

View File

@@ -1,704 +0,0 @@
# Payload CMS Access Control - Advanced Patterns
Advanced access control patterns including context-aware access, time-based restrictions, factory functions, and production templates.
## Context-Aware Access Patterns
### Locale-Specific Access
Control access based on user locale for internationalized content.
```ts
import type { Access } from 'payload'
export const localeSpecificAccess: Access = ({ req: { user, locale } }) => {
// Authenticated users can access all locales
if (user) return true
// Public users can only access English content
if (locale === 'en') return true
return false
}
// Usage in collection
export const Posts: CollectionConfig = {
slug: 'posts',
access: {
read: localeSpecificAccess,
},
fields: [{ name: 'title', type: 'text', localized: true }],
}
```
**Source**: `docs/access-control/overview.mdx` (req.locale argument)
### Device-Specific Access
Restrict access based on device type or user agent.
```ts
import type { Access } from 'payload'
export const mobileOnlyAccess: Access = ({ req: { headers } }) => {
const userAgent = headers?.get('user-agent') || ''
return /mobile|android|iphone/i.test(userAgent)
}
export const desktopOnlyAccess: Access = ({ req: { headers } }) => {
const userAgent = headers?.get('user-agent') || ''
return !/mobile|android|iphone/i.test(userAgent)
}
// Usage
export const MobileContent: CollectionConfig = {
slug: 'mobile-content',
access: {
read: mobileOnlyAccess,
},
fields: [{ name: 'title', type: 'text' }],
}
```
**Source**: Synthesized (headers pattern)
### IP-Based Access
Restrict access from specific IP addresses (requires middleware/proxy headers).
```ts
import type { Access } from 'payload'
export const restrictedIpAccess = (allowedIps: string[]): Access => {
return ({ req: { headers } }) => {
const ip = headers?.get('x-forwarded-for') || headers?.get('x-real-ip')
return allowedIps.includes(ip || '')
}
}
// Usage
const internalIps = ['192.168.1.0/24', '10.0.0.5']
export const InternalDocs: CollectionConfig = {
slug: 'internal-docs',
access: {
read: restrictedIpAccess(internalIps),
},
fields: [{ name: 'content', type: 'richText' }],
}
```
**Note**: Requires your server to pass IP address via headers (common with proxies/load balancers).
**Source**: Synthesized (headers pattern)
## Time-Based Access Patterns
### Today's Records Only
```ts
import type { Access } from 'payload'
export const todayOnlyAccess: Access = ({ req: { user } }) => {
if (!user) return false
const now = new Date()
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000)
return {
createdAt: {
greater_than_equal: startOfDay.toISOString(),
less_than: endOfDay.toISOString(),
},
}
}
```
**Source**: `test/access-control/config.ts` (query constraint patterns)
### Recent Records (Last N Days)
```ts
import type { Access } from 'payload'
export const recentRecordsAccess = (days: number): Access => {
return ({ req: { user } }) => {
if (!user) return false
if (user.roles?.includes('admin')) return true
const cutoff = new Date()
cutoff.setDate(cutoff.getDate() - days)
return {
createdAt: {
greater_than_equal: cutoff.toISOString(),
},
}
}
}
// Usage: Users see only last 30 days, admins see all
export const Logs: CollectionConfig = {
slug: 'logs',
access: {
read: recentRecordsAccess(30),
},
fields: [{ name: 'message', type: 'text' }],
}
```
### Scheduled Content (Publish Date Range)
```ts
import type { Access } from 'payload'
export const scheduledContentAccess: Access = ({ req: { user } }) => {
// Editors see all content
if (user?.roles?.includes('admin') || user?.roles?.includes('editor')) {
return true
}
const now = new Date().toISOString()
// Public sees only content within publish window
return {
and: [
{ publishDate: { less_than_equal: now } },
{
or: [{ unpublishDate: { exists: false } }, { unpublishDate: { greater_than: now } }],
},
],
}
}
```
**Source**: Synthesized (query constraint + date patterns)
## Subscription-Based Access
### Active Subscription Required
```ts
import type { Access } from 'payload'
export const activeSubscriptionAccess: Access = async ({ req: { user } }) => {
if (!user) return false
if (user.roles?.includes('admin')) return true
try {
const subscription = await req.payload.findByID({
collection: 'subscriptions',
id: user.subscriptionId,
})
return subscription?.status === 'active'
} catch {
return false
}
}
// Usage
export const PremiumContent: CollectionConfig = {
slug: 'premium-content',
access: {
read: activeSubscriptionAccess,
},
fields: [{ name: 'title', type: 'text' }],
}
```
### Subscription Tier-Based Access
```ts
import type { Access } from 'payload'
export const tierBasedAccess = (requiredTier: string): Access => {
const tierHierarchy = ['free', 'basic', 'pro', 'enterprise']
return async ({ req: { user } }) => {
if (!user) return false
if (user.roles?.includes('admin')) return true
try {
const subscription = await req.payload.findByID({
collection: 'subscriptions',
id: user.subscriptionId,
})
if (subscription?.status !== 'active') return false
const userTierIndex = tierHierarchy.indexOf(subscription.tier)
const requiredTierIndex = tierHierarchy.indexOf(requiredTier)
return userTierIndex >= requiredTierIndex
} catch {
return false
}
}
}
// Usage
export const EnterpriseFeatures: CollectionConfig = {
slug: 'enterprise-features',
access: {
read: tierBasedAccess('enterprise'),
},
fields: [{ name: 'feature', type: 'text' }],
}
```
**Source**: Synthesized (async + cross-collection pattern)
## Factory Functions
Reusable functions that generate access control configurations.
### createRoleBasedAccess
Generate access control for specific roles.
```ts
import type { Access } from 'payload'
export function createRoleBasedAccess(roles: string[]): Access {
return ({ req: { user } }) => {
if (!user) return false
return roles.some((role) => user.roles?.includes(role))
}
}
// Usage
const adminOrEditor = createRoleBasedAccess(['admin', 'editor'])
const moderatorAccess = createRoleBasedAccess(['admin', 'moderator'])
export const Posts: CollectionConfig = {
slug: 'posts',
access: {
create: adminOrEditor,
update: adminOrEditor,
delete: moderatorAccess,
},
fields: [{ name: 'title', type: 'text' }],
}
```
**Source**: `test/access-control/config.ts`
### createOrgScopedAccess
Generate organization-scoped access with optional admin bypass.
```ts
import type { Access } from 'payload'
export function createOrgScopedAccess(allowAdmin = true): Access {
return ({ req: { user } }) => {
if (!user) return false
if (allowAdmin && user.roles?.includes('admin')) return true
return {
organizationId: { in: user.organizationIds || [] },
}
}
}
// Usage
const orgScoped = createOrgScopedAccess() // Admins bypass
const strictOrgScoped = createOrgScopedAccess(false) // Admins also scoped
export const Projects: CollectionConfig = {
slug: 'projects',
access: {
read: orgScoped,
update: orgScoped,
delete: strictOrgScoped,
},
fields: [
{ name: 'title', type: 'text' },
{ name: 'organizationId', type: 'text', required: true },
],
}
```
**Source**: `test/access-control/config.ts`
### createTeamBasedAccess
Generate team-scoped access with configurable field name.
```ts
import type { Access } from 'payload'
export function createTeamBasedAccess(teamField = 'teamId'): Access {
return ({ req: { user } }) => {
if (!user) return false
if (user.roles?.includes('admin')) return true
return {
[teamField]: { in: user.teamIds || [] },
}
}
}
// Usage with custom field name
const projectTeamAccess = createTeamBasedAccess('projectTeam')
export const Tasks: CollectionConfig = {
slug: 'tasks',
access: {
read: projectTeamAccess,
update: projectTeamAccess,
},
fields: [
{ name: 'title', type: 'text' },
{ name: 'projectTeam', type: 'text', required: true },
],
}
```
**Source**: Synthesized (org pattern variation)
### createTimeLimitedAccess
Generate access limited to records within specified days.
```ts
import type { Access } from 'payload'
export function createTimeLimitedAccess(daysAccess: number): Access {
return ({ req: { user } }) => {
if (!user) return false
if (user.roles?.includes('admin')) return true
const cutoff = new Date()
cutoff.setDate(cutoff.getDate() - daysAccess)
return {
createdAt: {
greater_than_equal: cutoff.toISOString(),
},
}
}
}
// Usage: Users see 90 days, admins see all
export const ActivityLogs: CollectionConfig = {
slug: 'activity-logs',
access: {
read: createTimeLimitedAccess(90),
},
fields: [{ name: 'action', type: 'text' }],
}
```
**Source**: Synthesized (time + query pattern)
## Configuration Templates
Complete collection configurations for common scenarios.
### Basic Authenticated Collection
```ts
import type { CollectionConfig } from 'payload'
export const BasicCollection: CollectionConfig = {
slug: 'basic-collection',
access: {
create: ({ req: { user } }) => Boolean(user),
read: ({ req: { user } }) => Boolean(user),
update: ({ req: { user } }) => Boolean(user),
delete: ({ req: { user } }) => Boolean(user),
},
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'content', type: 'richText' },
],
}
```
**Source**: `docs/access-control/collections.mdx`
### Public + Authenticated Collection
```ts
import type { CollectionConfig } from 'payload'
export const PublicAuthCollection: CollectionConfig = {
slug: 'posts',
access: {
// Only admins/editors can create
create: ({ req: { user } }) => {
return user?.roles?.some((role) => ['admin', 'editor'].includes(role)) || false
},
// Authenticated users see all, public sees only published
read: ({ req: { user } }) => {
if (user) return true
return { _status: { equals: 'published' } }
},
// Only admins/editors can update
update: ({ req: { user } }) => {
return user?.roles?.some((role) => ['admin', 'editor'].includes(role)) || false
},
// Only admins can delete
delete: ({ req: { user } }) => {
return user?.roles?.includes('admin') || false
},
},
versions: {
drafts: true,
},
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'content', type: 'richText', required: true },
{ name: 'author', type: 'relationship', relationTo: 'users' },
],
}
```
**Source**: `templates/website/src/collections/Posts/index.ts`
### Multi-User/Self-Service Collection
```ts
import type { CollectionConfig } from 'payload'
export const SelfServiceCollection: CollectionConfig = {
slug: 'users',
auth: true,
access: {
// Admins can create users
create: ({ req: { user } }) => user?.roles?.includes('admin') || false,
// Anyone can read user profiles
read: () => true,
// Users can update self, admins can update anyone
update: ({ req: { user }, id }) => {
if (!user) return false
if (user.roles?.includes('admin')) return true
return user.id === id
},
// Only admins can delete
delete: ({ req: { user } }) => user?.roles?.includes('admin') || false,
},
fields: [
{ name: 'name', type: 'text', required: true },
{ name: 'email', type: 'email', required: true },
{
name: 'roles',
type: 'select',
hasMany: true,
options: ['admin', 'editor', 'user'],
access: {
// Only admins can read/update roles
read: ({ req: { user } }) => user?.roles?.includes('admin') || false,
update: ({ req: { user } }) => user?.roles?.includes('admin') || false,
},
},
],
}
```
**Source**: `templates/website/src/collections/Users/index.ts`
## Debugging Tips
### Log Access Check Execution
```ts
export const debugAccess: Access = ({ req: { user }, id }) => {
console.log('Access check:', {
userId: user?.id,
userRoles: user?.roles,
docId: id,
timestamp: new Date().toISOString(),
})
return true
}
```
### Verify Arguments Availability
```ts
export const checkArgsAccess: Access = (args) => {
console.log('Available arguments:', {
hasReq: 'req' in args,
hasUser: args.req?.user ? 'yes' : 'no',
hasId: args.id ? 'provided' : 'undefined',
hasData: args.data ? 'provided' : 'undefined',
})
return true
}
```
### Measure Async Operation Timing
```ts
export const timedAsyncAccess: Access = async ({ req }) => {
const start = Date.now()
const result = await fetch('https://auth-service.example.com/validate', {
headers: { userId: req.user?.id },
})
console.log(`Access check took ${Date.now() - start}ms`)
return result.ok
}
```
### Test Access Without User
```ts
// In test/development
const testAccess = await payload.find({
collection: 'posts',
overrideAccess: false, // Enforce access control
user: undefined, // Simulate no user
})
console.log('Public access result:', testAccess.docs.length)
```
**Source**: Synthesized (debugging best practices)
## Performance Considerations
### Async Operations Impact
```ts
// ❌ Slow: Multiple sequential async calls
export const slowAccess: Access = async ({ req: { user } }) => {
const org = await req.payload.findByID({ collection: 'orgs', id: user.orgId })
const team = await req.payload.findByID({ collection: 'teams', id: user.teamId })
const subscription = await req.payload.findByID({ collection: 'subs', id: user.subId })
return org.active && team.active && subscription.active
}
// ✅ Fast: Use query constraints or cache in context
export const fastAccess: Access = ({ req: { user, context } }) => {
// Cache expensive lookups
if (!context.orgStatus) {
context.orgStatus = checkOrgStatus(user.orgId)
}
return context.orgStatus
}
```
### Query Constraint Optimization
```ts
// ❌ Avoid: Non-indexed fields in constraints
export const slowQuery: Access = () => ({
'metadata.internalCode': { equals: 'ABC123' }, // Slow if not indexed
})
// ✅ Better: Use indexed fields
export const fastQuery: Access = () => ({
status: { equals: 'active' }, // Indexed field
organizationId: { in: ['org1', 'org2'] }, // Indexed field
})
```
### Field Access on Large Arrays
```ts
// ❌ Slow: Complex access on array fields
const arrayField: ArrayField = {
name: 'items',
type: 'array',
fields: [
{
name: 'secretData',
type: 'text',
access: {
read: async ({ req }) => {
// Async call runs for EVERY array item
const result = await expensiveCheck()
return result
},
},
},
],
}
// ✅ Fast: Simple checks or cache result
const optimizedArrayField: ArrayField = {
name: 'items',
type: 'array',
fields: [
{
name: 'secretData',
type: 'text',
access: {
read: ({ req: { user }, context }) => {
// Cache once, reuse for all items
if (context.canReadSecret === undefined) {
context.canReadSecret = user?.roles?.includes('admin')
}
return context.canReadSecret
},
},
},
],
}
```
### Avoid N+1 Queries
```ts
// ❌ N+1 Problem: Query per access check
export const n1Access: Access = async ({ req, id }) => {
// Runs for EACH document in list
const doc = await req.payload.findByID({ collection: 'docs', id })
return doc.isPublic
}
// ✅ Better: Use query constraint to filter at DB level
export const efficientAccess: Access = () => {
return { isPublic: { equals: true } }
}
```
**Performance Best Practices:**
1. **Minimize Async Operations**: Use query constraints over async lookups when possible
2. **Cache Expensive Checks**: Store results in `req.context` for reuse
3. **Index Query Fields**: Ensure fields in query constraints are indexed
4. **Avoid Complex Logic in Array Fields**: Simple boolean checks preferred
5. **Use Query Constraints**: Let database filter rather than loading all records
**Source**: Synthesized (operational best practices)
## Enhanced Best Practices
Comprehensive security and implementation guidelines:
1. **Default Deny**: Start with restrictive access, gradually add permissions
2. **Type Guards**: Use TypeScript for user type safety and better IDE support
3. **Validate Data**: Never trust frontend-provided IDs or data
4. **Async for Critical Checks**: Use async operations for important security decisions
5. **Consistent Logic**: Apply same rules at field and collection levels
6. **Test Edge Cases**: Test with no user, wrong user, admin user scenarios
7. **Monitor Access**: Log failed access attempts for security review
8. **Regular Audit**: Review access rules quarterly or after major changes
9. **Cache Wisely**: Use `req.context` for expensive operations
10. **Document Intent**: Add comments explaining complex access rules
11. **Avoid Secrets in Client**: Never expose sensitive logic to client-side
12. **Rate Limit External Calls**: Protect against DoS on external validation services
13. **Handle Errors Gracefully**: Access functions should return `false` on error, not throw
14. **Use Environment Vars**: Store configuration (IPs, API keys) in env vars
15. **Test Local API**: Remember to set `overrideAccess: false` when testing
16. **Consider Performance**: Measure impact of async operations on login time
17. **Version Control**: Track access control changes in git history
18. **Principle of Least Privilege**: Grant minimum access required for functionality
**Sources**: `docs/access-control/*.mdx`, synthesized best practices

View File

@@ -1,697 +0,0 @@
# Payload CMS Access Control Reference
Complete reference for access control patterns across collections, fields, and globals.
## At a Glance
| Feature | Scope | Returns | Use Case |
| --------------------- | --------------------------------------------------------- | ---------------------- | ---------------------------------- |
| **Collection Access** | create, read, update, delete, admin, unlock, readVersions | boolean \| Where query | Document-level permissions |
| **Field Access** | create, read, update | boolean only | Field-level visibility/editability |
| **Global Access** | read, update, readVersions | boolean \| Where query | Global document permissions |
## Three Layers of Access Control
Payload provides three distinct access control layers:
1. **Collection-Level**: Controls operations on entire documents (create, read, update, delete, admin, unlock, readVersions)
2. **Field-Level**: Controls access to individual fields (create, read, update)
3. **Global-Level**: Controls access to global documents (read, update, readVersions)
## Return Value Types
Access control functions can return:
- **Boolean**: `true` (allow) or `false` (deny)
- **Query Constraint**: `Where` object for row-level security (collection-level only)
Field-level access does NOT support query constraints - only boolean returns.
## Operation Decision Tree
```txt
User makes request
├─ Collection access check
│ ├─ Returns false? → Deny entire operation
│ ├─ Returns true? → Continue
│ └─ Returns Where? → Apply query constraint
├─ Field access check (if applicable)
│ ├─ Returns false? → Field omitted from result
│ └─ Returns true? → Include field
└─ Operation completed
```
## Collection Access Control
### Basic Patterns
```ts
import type { CollectionConfig, Access } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
access: {
// Boolean: Only authenticated users can create
create: ({ req: { user } }) => Boolean(user),
// Query constraint: Public sees published, users see all
read: ({ req: { user } }) => {
if (user) return true
return { status: { equals: 'published' } }
},
// User-specific: Admins or document owner
update: ({ req: { user }, id }) => {
if (user?.roles?.includes('admin')) return true
return { author: { equals: user?.id } }
},
// Async: Check related data
delete: async ({ req, id }) => {
const hasComments = await req.payload.count({
collection: 'comments',
where: { post: { equals: id } },
})
return hasComments === 0
},
// Admin panel visibility
admin: ({ req: { user } }) => {
return user?.roles?.includes('admin') || user?.roles?.includes('editor')
},
},
fields: [
{ name: 'title', type: 'text' },
{ name: 'status', type: 'select', options: ['draft', 'published'] },
{ name: 'author', type: 'relationship', relationTo: 'users' },
],
}
```
### Role-Based Access Control (RBAC) Pattern
Payload does NOT provide a roles system by default. The following is a commonly accepted pattern for implementing role-based access control in auth collections:
```ts
import type { CollectionConfig } from 'payload'
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
fields: [
{ name: 'name', type: 'text', required: true },
{ name: 'email', type: 'email', required: true },
{
name: 'roles',
type: 'select',
hasMany: true,
options: ['admin', 'editor', 'user'],
defaultValue: ['user'],
required: true,
// Save roles to JWT for access control without database lookups
saveToJWT: true,
access: {
// Only admins can update roles
update: ({ req: { user } }) => user?.roles?.includes('admin'),
},
},
],
}
```
**Important Notes:**
1. **Not Built-In**: Payload does not provide a roles system out of the box. You must add a `roles` field to your auth collection.
2. **Save to JWT**: Use `saveToJWT: true` to include roles in the JWT token, enabling role checks without database queries.
3. **Default Value**: Set a `defaultValue` to automatically assign new users a default role.
4. **Access Control**: Restrict who can modify roles (typically only admins).
5. **Role Options**: Define your own role hierarchy based on your application needs.
**Using Roles in Access Control:**
```ts
import type { Access } from 'payload'
// Check for specific role
export const adminOnly: Access = ({ req: { user } }) => {
return user?.roles?.includes('admin')
}
// Check for multiple roles
export const adminOrEditor: Access = ({ req: { user } }) => {
return Boolean(user?.roles?.some((role) => ['admin', 'editor'].includes(role)))
}
// Role hierarchy check
export const hasMinimumRole: Access = ({ req: { user } }, minRole: string) => {
const roleHierarchy = ['user', 'editor', 'admin']
const userHighestRole = Math.max(...(user?.roles?.map((r) => roleHierarchy.indexOf(r)) || [-1]))
const requiredRoleIndex = roleHierarchy.indexOf(minRole)
return userHighestRole >= requiredRoleIndex
}
```
### Reusable Access Functions
```ts
import type { Access } from 'payload'
// Anyone (public)
export const anyone: Access = () => true
// Authenticated only
export const authenticated: Access = ({ req: { user } }) => Boolean(user)
// Authenticated or published content
export const authenticatedOrPublished: Access = ({ req: { user } }) => {
if (user) return true
return { _status: { equals: 'published' } }
}
// Admin only
export const admins: Access = ({ req: { user } }) => {
return user?.roles?.includes('admin')
}
// Admin or editor
export const adminsOrEditors: Access = ({ req: { user } }) => {
return Boolean(user?.roles?.some((role) => ['admin', 'editor'].includes(role)))
}
// Self or admin
export const adminsOrSelf: Access = ({ req: { user } }) => {
if (user?.roles?.includes('admin')) return true
return { id: { equals: user?.id } }
}
// Usage
export const Posts: CollectionConfig = {
slug: 'posts',
access: {
create: authenticated,
read: authenticatedOrPublished,
update: adminsOrEditors,
delete: admins,
},
fields: [{ name: 'title', type: 'text' }],
}
```
### Row-Level Security with Complex Queries
```ts
import type { Access } from 'payload'
// Organization-scoped access
export const organizationScoped: Access = ({ req: { user } }) => {
if (user?.roles?.includes('admin')) return true
// Users see only their organization's data
return {
organization: {
equals: user?.organization,
},
}
}
// Multiple conditions with AND
export const complexAccess: Access = ({ req: { user } }) => {
return {
and: [
{ status: { equals: 'published' } },
{ 'author.isActive': { equals: true } },
{
or: [{ visibility: { equals: 'public' } }, { author: { equals: user?.id } }],
},
],
}
}
// Team-based access
export const teamMemberAccess: Access = ({ req: { user } }) => {
if (!user) return false
if (user.roles?.includes('admin')) return true
return {
'team.members': {
contains: user.id,
},
}
}
```
### Header-Based Access (API Keys)
```ts
import type { Access } from 'payload'
export const apiKeyAccess: Access = ({ req }) => {
const apiKey = req.headers.get('x-api-key')
if (!apiKey) return false
// Validate against stored keys
return apiKey === process.env.VALID_API_KEY
}
// Bearer token validation
export const bearerTokenAccess: Access = async ({ req }) => {
const auth = req.headers.get('authorization')
if (!auth?.startsWith('Bearer ')) return false
const token = auth.slice(7)
const isValid = await validateToken(token)
return isValid
}
```
## Field Access Control
Field access does NOT support query constraints - only boolean returns.
### Basic Field Access
```ts
import type { NumberField, FieldAccess } from 'payload'
const salaryReadAccess: FieldAccess = ({ req: { user }, doc }) => {
// Self can read own salary
if (user?.id === doc?.id) return true
// Admin can read all salaries
return user?.roles?.includes('admin')
}
const salaryUpdateAccess: FieldAccess = ({ req: { user } }) => {
// Only admins can update salary
return user?.roles?.includes('admin')
}
const salaryField: NumberField = {
name: 'salary',
type: 'number',
access: {
read: salaryReadAccess,
update: salaryUpdateAccess,
},
}
```
### Sibling Data Access
```ts
import type { ArrayField, FieldAccess } from 'payload'
const contentReadAccess: FieldAccess = ({ req: { user }, siblingData }) => {
// Authenticated users see all
if (user) return true
// Public sees only if marked public
return siblingData?.isPublic === true
}
const arrayField: ArrayField = {
name: 'sections',
type: 'array',
fields: [
{
name: 'isPublic',
type: 'checkbox',
defaultValue: false,
},
{
name: 'content',
type: 'text',
access: {
read: contentReadAccess,
},
},
],
}
```
### Nested Field Access
```ts
import type { GroupField, FieldAccess } from 'payload'
const internalOnlyAccess: FieldAccess = ({ req: { user } }) => {
return user?.roles?.includes('admin') || user?.roles?.includes('internal')
}
const groupField: GroupField = {
name: 'internalMetadata',
type: 'group',
access: {
read: internalOnlyAccess,
update: internalOnlyAccess,
},
fields: [
{ name: 'internalNotes', type: 'textarea' },
{ name: 'priority', type: 'select', options: ['low', 'medium', 'high'] },
],
}
```
### Hiding Admin Fields
```ts
import type { CollectionConfig } from 'payload'
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
fields: [
{ name: 'name', type: 'text', required: true },
{ name: 'email', type: 'email', required: true },
{
name: 'roles',
type: 'select',
hasMany: true,
options: ['admin', 'editor', 'user'],
access: {
// Hide from UI, but still saved/queried
read: ({ req: { user } }) => user?.roles?.includes('admin'),
// Only admins can update roles
update: ({ req: { user } }) => user?.roles?.includes('admin'),
},
},
],
}
```
## Global Access Control
```ts
import type { GlobalConfig, Access } from 'payload'
const adminOnly: Access = ({ req: { user } }) => {
return user?.roles?.includes('admin')
}
export const SiteSettings: GlobalConfig = {
slug: 'site-settings',
access: {
read: () => true, // Anyone can read settings
update: adminOnly, // Only admins can update
readVersions: adminOnly, // Only admins can see version history
},
fields: [
{ name: 'siteName', type: 'text' },
{ name: 'maintenanceMode', type: 'checkbox' },
],
}
```
## Multi-Tenant Access Control
```ts
import type { Access, CollectionConfig } from 'payload'
// Add tenant field to user type
interface User {
id: string
tenantId: string
roles?: string[]
}
// Tenant-scoped access
const tenantAccess: Access = ({ req: { user } }) => {
// No user = no access
if (!user) return false
// Super admin sees all
if (user.roles?.includes('super-admin')) return true
// Users see only their tenant's data
return {
tenant: {
equals: (user as User).tenantId,
},
}
}
export const Posts: CollectionConfig = {
slug: 'posts',
access: {
create: tenantAccess,
read: tenantAccess,
update: tenantAccess,
delete: tenantAccess,
},
fields: [
{ name: 'title', type: 'text' },
{
name: 'tenant',
type: 'text',
required: true,
access: {
// Tenant field hidden from non-admins
update: ({ req: { user } }) => user?.roles?.includes('super-admin'),
},
hooks: {
// Auto-set tenant on create
beforeChange: [
({ req, operation, value }) => {
if (operation === 'create' && !value) {
return (req.user as User)?.tenantId
}
return value
},
],
},
},
],
}
```
## Auth Collection Patterns
### Self or Admin Pattern
```ts
import type { CollectionConfig } from 'payload'
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
access: {
// Anyone can read user profiles
read: () => true,
// Users can update themselves, admins can update anyone
update: ({ req: { user }, id }) => {
if (user?.roles?.includes('admin')) return true
return user?.id === id
},
// Only admins can delete
delete: ({ req: { user } }) => user?.roles?.includes('admin'),
},
fields: [
{ name: 'name', type: 'text' },
{ name: 'email', type: 'email' },
],
}
```
### Restrict Self-Updates
```ts
import type { CollectionConfig, FieldAccess } from 'payload'
const preventSelfRoleChange: FieldAccess = ({ req: { user }, id }) => {
// Admins can change anyone's roles
if (user?.roles?.includes('admin')) return true
// Users cannot change their own roles
if (user?.id === id) return false
return false
}
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
fields: [
{
name: 'roles',
type: 'select',
hasMany: true,
options: ['admin', 'editor', 'user'],
access: {
update: preventSelfRoleChange,
},
},
],
}
```
## Cross-Collection Validation
```ts
import type { Access } from 'payload'
// Check if user is a project member before allowing access
export const projectMemberAccess: Access = async ({ req, id }) => {
const { user, payload } = req
if (!user) return false
if (user.roles?.includes('admin')) return true
// Check if document exists and user is member
const project = await payload.findByID({
collection: 'projects',
id: id as string,
depth: 0,
})
return project.members?.includes(user.id)
}
// Prevent deletion if document has dependencies
export const preventDeleteWithDependencies: Access = async ({ req, id }) => {
const { payload } = req
const dependencyCount = await payload.count({
collection: 'related-items',
where: {
parent: { equals: id },
},
})
return dependencyCount === 0
}
```
## Access Control Function Arguments
### Collection Create
```ts
create: ({ req, data }) => boolean | Where
// req: PayloadRequest
// - req.user: Authenticated user (if any)
// - req.payload: Payload instance for queries
// - req.headers: Request headers
// - req.locale: Current locale
// data: The data being created
```
### Collection Read
```ts
read: ({ req, id }) => boolean | Where
// req: PayloadRequest
// id: Document ID being read
// - undefined during Access Operation (login check)
// - string when reading specific document
```
### Collection Update
```ts
update: ({ req, id, data }) => boolean | Where
// req: PayloadRequest
// id: Document ID being updated
// data: New values being applied
```
### Collection Delete
```ts
delete: ({ req, id }) => boolean | Where
// req: PayloadRequest
// id: Document ID being deleted
```
### Field Create
```ts
access: {
create: ({ req, data, siblingData }) => boolean
}
// req: PayloadRequest
// data: Full document data
// siblingData: Adjacent field values at same level
```
### Field Read
```ts
access: {
read: ({ req, id, doc, siblingData }) => boolean
}
// req: PayloadRequest
// id: Document ID
// doc: Full document
// siblingData: Adjacent field values
```
### Field Update
```ts
access: {
update: ({ req, id, data, doc, siblingData }) => boolean
}
// req: PayloadRequest
// id: Document ID
// data: New values
// doc: Current document
// siblingData: Adjacent field values
```
## Important Notes
1. **Local API Default**: Access control is **skipped by default** in Local API (`overrideAccess: true`). When passing a `user` parameter, you almost always want to set `overrideAccess: false` to respect that user's permissions:
```ts
// ❌ WRONG: Passes user but bypasses access control (default behavior)
await payload.find({
collection: 'posts',
user: someUser, // User is ignored for access control!
})
// ✅ CORRECT: Respects the user's permissions
await payload.find({
collection: 'posts',
user: someUser,
overrideAccess: false, // Required to enforce access control
})
```
**Why this matters**: If you pass `user` without `overrideAccess: false`, the operation runs with admin privileges regardless of the user's actual permissions. This is a common security mistake.
2. **Field Access Limitations**: Field-level access does NOT support query constraints - only boolean returns.
3. **Admin Panel Visibility**: The `admin` access control determines if a collection appears in the admin panel for a user.
4. **Access Before Hooks**: Access control executes BEFORE hooks run, so hooks cannot modify access behavior.
5. **Query Constraints**: Only collection-level `read` access supports query constraints. All other operations and field-level access require boolean returns.
## Best Practices
1. **Reusable Functions**: Create named access functions for common patterns
2. **Fail Secure**: Default to `false` for sensitive operations
3. **Cache Checks**: Use `req.context` to cache expensive validation
4. **Type Safety**: Type your user object for better IDE support
5. **Test Thoroughly**: Write tests for complex access control logic
6. **Document Intent**: Add comments explaining access rules
7. **Audit Logs**: Track access control decisions for security review
8. **Performance**: Avoid N+1 queries in access functions
9. **Error Handling**: Access functions should not throw - return `false` instead
10. **Tenant Hooks**: Auto-set tenant fields in `beforeChange` hooks
## Advanced Patterns
For advanced access control patterns including context-aware access, time-based restrictions, subscription-based access, factory functions, configuration templates, debugging tips, and performance optimization, see [ACCESS-CONTROL-ADVANCED.md](ACCESS-CONTROL-ADVANCED.md).

View File

@@ -1,326 +0,0 @@
# Payload CMS Adapters Reference
Complete reference for database, storage, and email adapters.
## Database Adapters
### MongoDB
```ts
import { mongooseAdapter } from '@payloadcms/db-mongodb'
export default buildConfig({
db: mongooseAdapter({
url: process.env.DATABASE_URL,
}),
})
```
### Postgres
```ts
import { postgresAdapter } from '@payloadcms/db-postgres'
export default buildConfig({
db: postgresAdapter({
pool: {
connectionString: process.env.DATABASE_URL,
},
push: false, // Don't auto-push schema changes
migrationDir: './migrations',
}),
})
```
### SQLite
```ts
import { sqliteAdapter } from '@payloadcms/db-sqlite'
export default buildConfig({
db: sqliteAdapter({
client: {
url: 'file:./payload.db',
},
transactionOptions: {}, // Enable transactions (disabled by default)
}),
})
```
## Transactions
Payload automatically uses transactions for all-or-nothing database operations. Pass `req` to include operations in the same transaction.
```ts
import type { CollectionAfterChangeHook } from 'payload'
const afterChange: CollectionAfterChangeHook = async ({ req, doc }) => {
// This will be part of the same transaction
await req.payload.create({
req, // Pass req to use same transaction
collection: 'audit-log',
data: { action: 'created', docId: doc.id },
})
}
// Manual transaction control
const transactionID = await payload.db.beginTransaction()
try {
await payload.create({
collection: 'orders',
data: orderData,
req: { transactionID },
})
await payload.update({
collection: 'inventory',
id: itemId,
data: { stock: newStock },
req: { transactionID },
})
await payload.db.commitTransaction(transactionID)
} catch (error) {
await payload.db.rollbackTransaction(transactionID)
throw error
}
```
**Note**: MongoDB requires replicaset for transactions. SQLite requires `transactionOptions: {}` to enable.
### Threading req Through Operations
**Critical**: When performing nested operations in hooks, always pass `req` to maintain transaction context. Failing to do so breaks atomicity and can cause partial updates.
```ts
import type { CollectionAfterChangeHook } from 'payload'
// ✅ CORRECT: Thread req through nested operations
const resaveChildren: CollectionAfterChangeHook = async ({ collection, doc, req }) => {
// Find children - pass req
const children = await req.payload.find({
collection: 'children',
where: { parent: { equals: doc.id } },
req, // Maintains transaction context
})
// Update each child - pass req
for (const child of children.docs) {
await req.payload.update({
id: child.id,
collection: 'children',
data: { updatedField: 'value' },
req, // Same transaction as parent operation
})
}
}
// ❌ WRONG: Missing req breaks transaction
const brokenHook: CollectionAfterChangeHook = async ({ collection, doc, req }) => {
const children = await req.payload.find({
collection: 'children',
where: { parent: { equals: doc.id } },
// Missing req - separate transaction or no transaction
})
for (const child of children.docs) {
await req.payload.update({
id: child.id,
collection: 'children',
data: { updatedField: 'value' },
// Missing req - if parent operation fails, these updates persist
})
}
}
```
**Why This Matters:**
- **MongoDB (with replica sets)**: Creates atomic session across operations
- **PostgreSQL**: All operations use same Drizzle transaction
- **SQLite (with transactions enabled)**: Ensures rollback on errors
- **Without req**: Each operation runs independently, breaking atomicity
**When req is Required:**
- All mutating operations in hooks (create, update, delete)
- Operations that must succeed/fail together
- When using MongoDB replica sets or Postgres
- Any operation that relies on `req.context` or `req.user`
**When req is Optional:**
- Read-only lookups independent of current transaction
- Operations with `disableTransaction: true`
- Administrative operations with `overrideAccess: true`
## Storage Adapters
Available storage adapters:
- **@payloadcms/storage-s3** - AWS S3
- **@payloadcms/storage-azure** - Azure Blob Storage
- **@payloadcms/storage-gcs** - Google Cloud Storage
- **@payloadcms/storage-r2** - Cloudflare R2
- **@payloadcms/storage-vercel-blob** - Vercel Blob
- **@payloadcms/storage-uploadthing** - Uploadthing
### AWS S3
```ts
import { s3Storage } from '@payloadcms/storage-s3'
export default buildConfig({
plugins: [
s3Storage({
collections: {
media: true,
},
bucket: process.env.S3_BUCKET,
config: {
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
},
region: process.env.S3_REGION,
},
}),
],
})
```
### Azure Blob Storage
```ts
import { azureStorage } from '@payloadcms/storage-azure'
export default buildConfig({
plugins: [
azureStorage({
collections: {
media: true,
},
connectionString: process.env.AZURE_STORAGE_CONNECTION_STRING,
containerName: process.env.AZURE_STORAGE_CONTAINER_NAME,
}),
],
})
```
### Google Cloud Storage
```ts
import { gcsStorage } from '@payloadcms/storage-gcs'
export default buildConfig({
plugins: [
gcsStorage({
collections: {
media: true,
},
bucket: process.env.GCS_BUCKET,
options: {
projectId: process.env.GCS_PROJECT_ID,
credentials: JSON.parse(process.env.GCS_CREDENTIALS),
},
}),
],
})
```
### Cloudflare R2
```ts
import { r2Storage } from '@payloadcms/storage-r2'
export default buildConfig({
plugins: [
r2Storage({
collections: {
media: true,
},
bucket: process.env.R2_BUCKET,
config: {
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
},
region: 'auto',
endpoint: process.env.R2_ENDPOINT,
},
}),
],
})
```
### Vercel Blob
```ts
import { vercelBlobStorage } from '@payloadcms/storage-vercel-blob'
export default buildConfig({
plugins: [
vercelBlobStorage({
collections: {
media: true,
},
token: process.env.BLOB_READ_WRITE_TOKEN,
}),
],
})
```
### Uploadthing
```ts
import { uploadthingStorage } from '@payloadcms/storage-uploadthing'
export default buildConfig({
plugins: [
uploadthingStorage({
collections: {
media: true,
},
options: {
token: process.env.UPLOADTHING_TOKEN,
acl: 'public-read',
},
}),
],
})
```
## Email Adapters
### Nodemailer (SMTP)
```ts
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'
export default buildConfig({
email: nodemailerAdapter({
defaultFromAddress: 'noreply@example.com',
defaultFromName: 'My App',
transportOptions: {
host: process.env.SMTP_HOST,
port: 587,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
},
}),
})
```
### Resend
```ts
import { resendAdapter } from '@payloadcms/email-resend'
export default buildConfig({
email: resendAdapter({
defaultFromAddress: 'noreply@example.com',
defaultFromName: 'My App',
apiKey: process.env.RESEND_API_KEY,
}),
})
```

View File

@@ -1,386 +0,0 @@
# Payload CMS Advanced Features
Complete reference for authentication, jobs, custom endpoints, components, plugins, and localization.
## Authentication
### Login
```ts
// REST API
const response = await fetch('/api/users/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'user@example.com',
password: 'password',
}),
})
// Local API
const result = await payload.login({
collection: 'users',
data: {
email: 'user@example.com',
password: 'password',
},
})
```
### Forgot Password
```ts
await payload.forgotPassword({
collection: 'users',
data: {
email: 'user@example.com',
},
})
```
### Custom Strategy
```ts
import type { CollectionConfig, Strategy } from 'payload'
const customStrategy: Strategy = {
name: 'custom',
authenticate: async ({ payload, headers }) => {
const token = headers.get('authorization')?.split(' ')[1]
if (!token) return { user: null }
const user = await verifyToken(token)
return { user }
},
}
export const Users: CollectionConfig = {
slug: 'users',
auth: {
strategies: [customStrategy],
},
fields: [],
}
```
### API Keys
```ts
import type { CollectionConfig } from 'payload'
export const APIKeys: CollectionConfig = {
slug: 'api-keys',
auth: {
disableLocalStrategy: true,
useAPIKey: true,
},
fields: [],
}
```
## Jobs Queue
Offload long-running or scheduled tasks to background workers.
### Tasks
```ts
import { buildConfig } from 'payload'
import type { TaskConfig } from 'payload'
export default buildConfig({
jobs: {
tasks: [
{
slug: 'sendWelcomeEmail',
inputSchema: [
{ name: 'userEmail', type: 'text', required: true },
{ name: 'userName', type: 'text', required: true },
],
outputSchema: [{ name: 'emailSent', type: 'checkbox', required: true }],
retries: 2, // Retry up to 2 times on failure
handler: async ({ input, req }) => {
await sendEmail({
to: input.userEmail,
subject: `Welcome ${input.userName}`,
})
return { output: { emailSent: true } }
},
} as TaskConfig<'sendWelcomeEmail'>,
],
},
})
```
### Queueing Jobs
```ts
// In a hook or endpoint
await req.payload.jobs.queue({
task: 'sendWelcomeEmail',
input: {
userEmail: 'user@example.com',
userName: 'John',
},
waitUntil: new Date('2024-12-31'), // Optional: schedule for future
})
```
### Workflows
Multi-step jobs that run in sequence:
```ts
{
slug: 'onboardUser',
inputSchema: [{ name: 'userId', type: 'text' }],
handler: async ({ job, req }) => {
const results = await job.runInlineTask({
task: async ({ input }) => {
// Step 1: Send welcome email
await sendEmail(input.userId)
return { output: { emailSent: true } }
},
})
await job.runInlineTask({
task: async () => {
// Step 2: Create onboarding tasks
await createTasks()
return { output: { tasksCreated: true } }
},
})
},
}
```
## Custom Endpoints
Add custom REST API routes to collections, globals, or root config. See [ENDPOINTS.md](ENDPOINTS.md) for detailed patterns, authentication, helpers, and real-world examples.
### Root Endpoints
```ts
import { buildConfig } from 'payload'
import type { Endpoint } from 'payload'
const helloEndpoint: Endpoint = {
path: '/hello',
method: 'get',
handler: () => {
return Response.json({ message: 'Hello!' })
},
}
const greetEndpoint: Endpoint = {
path: '/greet/:name',
method: 'get',
handler: (req) => {
return Response.json({
message: `Hello ${req.routeParams.name}!`,
})
},
}
export default buildConfig({
endpoints: [helloEndpoint, greetEndpoint],
collections: [],
secret: process.env.PAYLOAD_SECRET || '',
})
```
### Collection Endpoints
```ts
import type { CollectionConfig, Endpoint } from 'payload'
const featuredEndpoint: Endpoint = {
path: '/featured',
method: 'get',
handler: async (req) => {
const posts = await req.payload.find({
collection: 'posts',
where: { featured: { equals: true } },
})
return Response.json(posts)
},
}
export const Posts: CollectionConfig = {
slug: 'posts',
endpoints: [featuredEndpoint],
fields: [
{ name: 'title', type: 'text' },
{ name: 'featured', type: 'checkbox' },
],
}
```
## Custom Components
### Field Component (Client)
```tsx
'use client'
import { useField } from '@payloadcms/ui'
import type { TextFieldClientComponent } from 'payload'
export const CustomField: TextFieldClientComponent = () => {
const { value, setValue } = useField()
return <input value={value || ''} onChange={(e) => setValue(e.target.value)} />
}
```
### Custom View
```tsx
'use client'
import { DefaultTemplate } from '@payloadcms/next/templates'
export const CustomView = () => {
return (
<DefaultTemplate>
<h1>Custom Dashboard</h1>
{/* Your content */}
</DefaultTemplate>
)
}
```
### Admin Config
```ts
import { buildConfig } from 'payload'
export default buildConfig({
admin: {
components: {
beforeDashboard: ['/components/BeforeDashboard'],
beforeLogin: ['/components/BeforeLogin'],
views: {
custom: {
Component: '/views/Custom',
path: '/custom',
},
},
},
},
collections: [],
secret: process.env.PAYLOAD_SECRET || '',
})
```
## Plugins
### Available Plugins
- **@payloadcms/plugin-seo** - SEO fields with meta title/description, Open Graph, preview generation
- **@payloadcms/plugin-redirects** - Manage URL redirects (301/302) for Next.js apps
- **@payloadcms/plugin-nested-docs** - Hierarchical document structures with breadcrumbs
- **@payloadcms/plugin-form-builder** - Dynamic form builder with submissions and validation
- **@payloadcms/plugin-search** - Full-text search integration (Algolia support)
- **@payloadcms/plugin-stripe** - Stripe payments, subscriptions, webhooks
- **@payloadcms/plugin-ecommerce** - Complete ecommerce solution (products, variants, carts, orders)
- **@payloadcms/plugin-import-export** - Import/export data via CSV
- **@payloadcms/plugin-multi-tenant** - Multi-tenancy with tenant isolation
- **@payloadcms/plugin-sentry** - Sentry error tracking integration
- **@payloadcms/plugin-mcp** - Model Context Protocol for AI integrations
### Using Plugins
```ts
import { buildConfig } from 'payload'
import { seoPlugin } from '@payloadcms/plugin-seo'
import { redirectsPlugin } from '@payloadcms/plugin-redirects'
export default buildConfig({
plugins: [
seoPlugin({
collections: ['posts', 'pages'],
}),
redirectsPlugin({
collections: ['pages'],
}),
],
collections: [],
secret: process.env.PAYLOAD_SECRET || '',
})
```
### Creating Plugins
```ts
import type { Config } from 'payload'
interface PluginOptions {
enabled?: boolean
}
export const myPlugin =
(options: PluginOptions) =>
(config: Config): Config => ({
...config,
collections: [
...(config.collections || []),
{
slug: 'plugin-collection',
fields: [{ name: 'title', type: 'text' }],
},
],
onInit: async (payload) => {
if (config.onInit) await config.onInit(payload)
// Plugin initialization
},
})
```
## Localization
```ts
import { buildConfig } from 'payload'
import type { Field, Payload } from 'payload'
export default buildConfig({
localization: {
locales: ['en', 'es', 'de'],
defaultLocale: 'en',
fallback: true,
},
collections: [],
secret: process.env.PAYLOAD_SECRET || '',
})
// Localized field
const localizedField: TextField = {
name: 'title',
type: 'text',
localized: true,
}
// Query with locale
const posts = await payload.find({
collection: 'posts',
locale: 'es',
})
```
## TypeScript Type References
For complete TypeScript type definitions and signatures, reference these files from the Payload source:
### Core Configuration Types
- **[All Commonly-Used Types](https://github.com/payloadcms/payload/blob/main/packages/payload/src/index.ts)** - Check here first for commonly used types and interfaces. All core types are exported from this file.
### Database & Adapters
- **[Database Adapter Types](https://github.com/payloadcms/payload/blob/main/packages/payload/src/database/types.ts)** - Base adapter interface
- **[MongoDB Adapter](https://github.com/payloadcms/payload/blob/main/packages/db-mongodb/src/index.ts)** - MongoDB-specific options
- **[Postgres Adapter](https://github.com/payloadcms/payload/blob/main/packages/db-postgres/src/index.ts)** - Postgres-specific options
### Rich Text & Plugins
- **[Lexical Types](https://github.com/payloadcms/payload/blob/main/packages/richtext-lexical/src/exports/server/index.ts)** - Lexical editor configuration
When users need detailed type information, fetch these URLs to provide complete signatures and optional parameters.

View File

@@ -1,303 +0,0 @@
# Payload CMS Collections Reference
Complete reference for collection configurations and patterns.
## Basic Collection
```ts
import type { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
labels: {
singular: 'Post',
plural: 'Posts',
},
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'author', 'status', 'createdAt'],
group: 'Content', // Organize in admin sidebar
description: 'Blog posts and articles',
listSearchableFields: ['title', 'slug'],
},
fields: [
{
name: 'title',
type: 'text',
required: true,
index: true,
},
{
name: 'slug',
type: 'text',
unique: true,
index: true,
admin: { position: 'sidebar' },
},
{
name: 'status',
type: 'select',
options: ['draft', 'published'],
defaultValue: 'draft',
},
],
defaultSort: '-createdAt',
timestamps: true,
}
```
## Auth Collection
```ts
export const Users: CollectionConfig = {
slug: 'users',
auth: {
tokenExpiration: 7200, // 2 hours
verify: true,
maxLoginAttempts: 5,
lockTime: 600000, // 10 minutes
useAPIKey: true,
},
admin: {
useAsTitle: 'email',
},
fields: [
{
name: 'roles',
type: 'select',
hasMany: true,
options: ['admin', 'editor', 'user'],
required: true,
defaultValue: ['user'],
saveToJWT: true,
},
{
name: 'name',
type: 'text',
required: true,
},
],
}
```
## Upload Collection
```ts
export const Media: CollectionConfig = {
slug: 'media',
upload: {
staticDir: 'media',
mimeTypes: ['image/*'],
imageSizes: [
{
name: 'thumbnail',
width: 400,
height: 300,
position: 'centre',
},
{
name: 'card',
width: 768,
height: 1024,
},
],
adminThumbnail: 'thumbnail',
focalPoint: true,
crop: true,
},
access: {
read: () => true,
},
fields: [
{
name: 'alt',
type: 'text',
required: true,
},
{
name: 'caption',
type: 'text',
localized: true,
},
],
}
```
## Live Preview
Enable real-time content preview during editing.
```ts
import type { CollectionConfig } from 'payload'
const generatePreviewPath = ({
slug,
collection,
req,
}: {
slug: string
collection: string
req: any
}) => {
const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL
return `${baseUrl}/api/preview?slug=${slug}&collection=${collection}`
}
export const Pages: CollectionConfig = {
slug: 'pages',
admin: {
useAsTitle: 'title',
// Live preview during editing
livePreview: {
url: ({ data, req }) =>
generatePreviewPath({
slug: data?.slug as string,
collection: 'pages',
req,
}),
},
// Static preview button
preview: (data, { req }) =>
generatePreviewPath({
slug: data?.slug as string,
collection: 'pages',
req,
}),
},
fields: [
{ name: 'title', type: 'text' },
{ name: 'slug', type: 'text' },
],
}
```
## Versioning & Drafts
Payload maintains version history and supports draft/publish workflows.
```ts
import type { CollectionConfig } from 'payload'
// Basic versioning (audit log only)
export const Users: CollectionConfig = {
slug: 'users',
versions: true, // or { maxPerDoc: 100 }
fields: [{ name: 'name', type: 'text' }],
}
// Drafts enabled (draft/publish workflow)
export const Posts: CollectionConfig = {
slug: 'posts',
versions: {
drafts: true, // Enables _status field
maxPerDoc: 50,
},
fields: [{ name: 'title', type: 'text' }],
}
// Full configuration with autosave and scheduled publish
export const Pages: CollectionConfig = {
slug: 'pages',
versions: {
drafts: {
autosave: true, // Auto-save while editing
schedulePublish: true, // Schedule future publish/unpublish
validate: false, // Don't validate drafts (default)
},
maxPerDoc: 100, // Keep last 100 versions (0 = unlimited)
},
fields: [{ name: 'title', type: 'text' }],
}
```
### Draft API Usage
```ts
// Create draft
await payload.create({
collection: 'posts',
data: { title: 'Draft Post' },
draft: true, // Saves as draft, skips required field validation
})
// Update as draft
await payload.update({
collection: 'posts',
id: '123',
data: { title: 'Updated Draft' },
draft: true,
})
// Read with drafts (returns newest draft if available)
const post = await payload.findByID({
collection: 'posts',
id: '123',
draft: true, // Returns draft version if exists
})
// Query only published (REST API)
// GET /api/posts (returns only _status: 'published')
// Access control for drafts
export const Posts: CollectionConfig = {
slug: 'posts',
versions: { drafts: true },
access: {
read: ({ req: { user } }) => {
// Public can only see published
if (!user) return { _status: { equals: 'published' } }
// Authenticated can see all
return true
},
},
fields: [{ name: 'title', type: 'text' }],
}
```
### Document Status
The `_status` field is auto-injected when drafts are enabled:
- `draft` - Never published
- `published` - Published with no newer drafts
- `changed` - Published but has newer unpublished drafts
## Globals
Globals are single-instance documents (not collections).
```ts
import type { GlobalConfig } from 'payload'
export const Header: GlobalConfig = {
slug: 'header',
label: 'Header',
admin: {
group: 'Settings',
},
fields: [
{
name: 'logo',
type: 'upload',
relationTo: 'media',
required: true,
},
{
name: 'nav',
type: 'array',
maxRows: 8,
fields: [
{
name: 'link',
type: 'relationship',
relationTo: 'pages',
},
{
name: 'label',
type: 'text',
},
],
},
],
}
```

View File

@@ -1,634 +0,0 @@
# Payload Custom API Endpoints Reference
Custom REST API endpoints extend Payload's auto-generated CRUD operations with custom logic, authentication flows, webhooks, and integrations.
## Quick Reference
### Endpoint Configuration
| Property | Type | Description |
| --------- | ------------------------------------------------- | --------------------------------------------------------------- |
| `path` | `string` | Route path after collection/global slug (e.g., `/:id/tracking`) |
| `method` | `'get' \| 'post' \| 'put' \| 'patch' \| 'delete'` | HTTP method (lowercase) |
| `handler` | `(req: PayloadRequest) => Promise<Response>` | Async function returning Web API Response |
| `custom` | `Record<string, any>` | Extension point for plugins/metadata |
### Request Context
| Property | Type | Description |
| ----------------- | ----------------------- | ------------------------------------------------------ |
| `req.user` | `User \| null` | Authenticated user (null if not authenticated) |
| `req.payload` | `Payload` | Payload instance for operations (find, create...) |
| `req.routeParams` | `Record<string, any>` | Path parameters (e.g., `:id`) |
| `req.url` | `string` | Full request URL |
| `req.method` | `string` | HTTP method |
| `req.headers` | `Headers` | Request headers |
| `req.json()` | `() => Promise<any>` | Parse JSON body |
| `req.text()` | `() => Promise<string>` | Read body as text |
| `req.data` | `any` | Parsed body (after `addDataAndFileToRequest()`) |
| `req.file` | `File` | Uploaded file (after `addDataAndFileToRequest()`) |
| `req.locale` | `string` | Request locale (after `addLocalesToRequestFromData()`) |
| `req.i18n` | `I18n` | i18n instance |
| `req.t` | `TFunction` | Translation function |
## Common Patterns
### Authentication Check
Custom endpoints are **not authenticated by default**. Check `req.user` to enforce authentication.
```ts
import { APIError } from 'payload'
export const authenticatedEndpoint = {
path: '/protected',
method: 'get',
handler: async (req) => {
if (!req.user) {
throw new APIError('Unauthorized', 401)
}
// User is authenticated
return Response.json({ message: 'Access granted' })
},
}
```
### Using Payload Operations
Use `req.payload` for database operations with access control and hooks.
```ts
export const getRelatedPosts = {
path: '/:id/related',
method: 'get',
handler: async (req) => {
const { id } = req.routeParams
// Find related posts
const posts = await req.payload.find({
collection: 'posts',
where: {
category: {
equals: id,
},
},
limit: 5,
sort: '-createdAt',
})
return Response.json(posts)
},
}
```
### Route Parameters
Access path parameters via `req.routeParams`.
```ts
export const getTrackingEndpoint = {
path: '/:id/tracking',
method: 'get',
handler: async (req) => {
const orderId = req.routeParams.id
const tracking = await getTrackingInfo(orderId)
if (!tracking) {
return Response.json({ error: 'not found' }, { status: 404 })
}
return Response.json(tracking)
},
}
```
### Request Body Handling
**Option 1: Manual JSON parsing**
```ts
export const createEndpoint = {
path: '/create',
method: 'post',
handler: async (req) => {
const data = await req.json()
const result = await req.payload.create({
collection: 'posts',
data,
})
return Response.json(result)
},
}
```
**Option 2: Using helper (handles JSON + files)**
```ts
import { addDataAndFileToRequest } from 'payload'
export const uploadEndpoint = {
path: '/upload',
method: 'post',
handler: async (req) => {
await addDataAndFileToRequest(req)
// req.data now contains parsed body
// req.file contains uploaded file (if multipart)
const result = await req.payload.create({
collection: 'media',
data: req.data,
file: req.file,
})
return Response.json(result)
},
}
```
### CORS Headers
Use `headersWithCors` helper to apply config CORS settings.
```ts
import { headersWithCors } from 'payload'
export const corsEndpoint = {
path: '/public-data',
method: 'get',
handler: async (req) => {
const data = await fetchPublicData()
return Response.json(data, {
headers: headersWithCors({
headers: new Headers(),
req,
}),
})
},
}
```
### Error Handling
Throw `APIError` with status codes for proper error responses.
```ts
import { APIError } from 'payload'
export const validateEndpoint = {
path: '/validate',
method: 'post',
handler: async (req) => {
const data = await req.json()
if (!data.email) {
throw new APIError('Email is required', 400)
}
// Validation passed
return Response.json({ valid: true })
},
}
```
### Query Parameters
Extract query params from URL.
```ts
export const searchEndpoint = {
path: '/search',
method: 'get',
handler: async (req) => {
const url = new URL(req.url)
const query = url.searchParams.get('q')
const limit = parseInt(url.searchParams.get('limit') || '10')
const results = await req.payload.find({
collection: 'posts',
where: {
title: {
contains: query,
},
},
limit,
})
return Response.json(results)
},
}
```
## Helper Functions
### addDataAndFileToRequest
Parses request body and attaches to `req.data` and `req.file`.
```ts
import { addDataAndFileToRequest } from 'payload'
export const endpoint = {
path: '/process',
method: 'post',
handler: async (req) => {
await addDataAndFileToRequest(req)
// req.data: parsed JSON or form data
// req.file: uploaded file (if multipart)
console.log(req.data) // { title: 'My Post' }
console.log(req.file) // File object or undefined
},
}
```
**Handles:**
- JSON bodies (`Content-Type: application/json`)
- Form data (`Content-Type: multipart/form-data`)
- File uploads
### addLocalesToRequestFromData
Extracts locale from request data and validates against config.
```ts
import { addLocalesToRequestFromData } from 'payload'
export const endpoint = {
path: '/translate',
method: 'post',
handler: async (req) => {
await addLocalesToRequestFromData(req)
// req.locale: validated locale string
// req.fallbackLocale: fallback locale string
const result = await req.payload.find({
collection: 'posts',
locale: req.locale,
})
return Response.json(result)
},
}
```
### headersWithCors
Applies CORS headers from Payload config.
```ts
import { headersWithCors } from 'payload'
export const endpoint = {
path: '/data',
method: 'get',
handler: async (req) => {
const data = { message: 'Hello' }
return Response.json(data, {
headers: headersWithCors({
headers: new Headers({
'Cache-Control': 'public, max-age=3600',
}),
req,
}),
})
},
}
```
## Real-World Examples
### Multi-Tenant Login Endpoint
From `examples/multi-tenant`:
```ts
import { APIError, generatePayloadCookie, headersWithCors } from 'payload'
export const externalUsersLogin = {
path: '/login-external',
method: 'post',
handler: async (req) => {
const { email, password, tenant } = await req.json()
if (!email || !password || !tenant) {
throw new APIError('Missing credentials', 400)
}
// Find user with tenant constraint
const userQuery = await req.payload.find({
collection: 'users',
where: {
and: [
{ email: { equals: email } },
{
or: [{ tenants: { equals: tenant } }, { 'tenants.tenant': { equals: tenant } }],
},
],
},
})
if (!userQuery.docs.length) {
throw new APIError('Invalid credentials', 401)
}
// Authenticate user
const result = await req.payload.login({
collection: 'users',
data: { email, password },
})
return Response.json(result, {
headers: headersWithCors({
headers: new Headers({
'Set-Cookie': generatePayloadCookie({
collectionAuthConfig: req.payload.config.collections.find((c) => c.slug === 'users')
.auth,
cookiePrefix: req.payload.config.cookiePrefix,
token: result.token,
}),
}),
req,
}),
})
},
}
```
### Webhook Handler (Stripe)
From `packages/plugin-ecommerce`:
```ts
export const webhookEndpoint = {
path: '/webhooks',
method: 'post',
handler: async (req) => {
const body = await req.text()
const signature = req.headers.get('stripe-signature')
try {
const event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
// Process event
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSuccess(req.payload, event.data.object)
break
case 'payment_intent.failed':
await handlePaymentFailure(req.payload, event.data.object)
break
}
return Response.json({ received: true })
} catch (err) {
req.payload.logger.error(`Webhook error: ${err.message}`)
return Response.json({ error: err.message }, { status: 400 })
}
},
}
```
### Data Preview Endpoint
From `packages/plugin-import-export`:
```ts
import { addDataAndFileToRequest } from 'payload'
export const previewEndpoint = {
path: '/preview',
method: 'post',
handler: async (req) => {
if (!req.user) {
throw new APIError('Unauthorized', 401)
}
await addDataAndFileToRequest(req)
const { collection, where, limit = 10 } = req.data
// Validate collection exists
const collectionConfig = req.payload.config.collections.find((c) => c.slug === collection)
if (!collectionConfig) {
throw new APIError('Collection not found', 404)
}
// Preview data
const results = await req.payload.find({
collection,
where,
limit,
depth: 0,
})
return Response.json({
docs: results.docs,
totalDocs: results.totalDocs,
fields: collectionConfig.fields,
})
},
}
```
### Reindex Action Endpoint
From `packages/plugin-search`:
```ts
export const reindexEndpoint = (pluginConfig) => ({
path: '/reindex',
method: 'post',
handler: async (req) => {
if (!req.user) {
throw new APIError('Unauthorized', 401)
}
const { collection } = req.routeParams
// Reindex collection
const result = await reindexCollection(req.payload, collection, pluginConfig)
return Response.json({
message: `Reindexed ${result.count} documents`,
count: result.count,
})
},
})
```
## Endpoint Placement
### Collection Endpoints
Mounted at `/api/{collection-slug}/{path}`.
```ts
import type { CollectionConfig } from 'payload'
export const Orders: CollectionConfig = {
slug: 'orders',
fields: [
/* ... */
],
endpoints: [
{
path: '/:id/tracking',
method: 'get',
handler: async (req) => {
// Available at: /api/orders/:id/tracking
const orderId = req.routeParams.id
return Response.json({ orderId })
},
},
],
}
```
### Global Endpoints
Mounted at `/api/globals/{global-slug}/{path}`.
```ts
import type { GlobalConfig } from 'payload'
export const Settings: GlobalConfig = {
slug: 'settings',
fields: [
/* ... */
],
endpoints: [
{
path: '/clear-cache',
method: 'post',
handler: async (req) => {
// Available at: /api/globals/settings/clear-cache
await clearCache()
return Response.json({ message: 'Cache cleared' })
},
},
],
}
```
## Advanced Patterns
### Factory Functions
Create reusable endpoint factories for plugins.
```ts
export const createWebhookEndpoint = (config) => ({
path: '/webhook',
method: 'post',
handler: async (req) => {
const signature = req.headers.get('x-webhook-signature')
if (!verifySignature(signature, config.secret)) {
throw new APIError('Invalid signature', 401)
}
const data = await req.json()
await processWebhook(req.payload, data, config)
return Response.json({ received: true })
},
})
```
### Conditional Endpoints
Add endpoints based on config options.
```ts
export const MyCollection: CollectionConfig = {
slug: 'posts',
fields: [
/* ... */
],
endpoints: [
// Always included
{
path: '/public',
method: 'get',
handler: async (req) => Response.json({ data: [] }),
},
// Conditionally included
...(process.env.ENABLE_ANALYTICS
? [
{
path: '/analytics',
method: 'get',
handler: async (req) => Response.json({ analytics: [] }),
},
]
: []),
],
}
```
### OpenAPI Documentation
Use `custom` property for API documentation metadata.
```ts
export const endpoint = {
path: '/search',
method: 'get',
handler: async (req) => {
// Handler implementation
},
custom: {
openapi: {
summary: 'Search posts',
parameters: [
{
name: 'q',
in: 'query',
required: true,
schema: { type: 'string' },
},
],
responses: {
200: {
description: 'Search results',
content: {
'application/json': {
schema: { type: 'array' },
},
},
},
},
},
},
}
```
## Best Practices
1. **Always check authentication** - Custom endpoints are not authenticated by default
2. **Use `req.payload` for operations** - Ensures access control and hooks execute
3. **Use helpers for common tasks** - `addDataAndFileToRequest`, `headersWithCors`, etc.
4. **Throw `APIError` for errors** - Provides consistent error responses
5. **Return Web API `Response`** - Use `Response.json()` for consistent responses
6. **Validate input** - Check required fields, validate types
7. **Handle CORS** - Use `headersWithCors` for cross-origin requests
8. **Log errors** - Use `req.payload.logger` for debugging
9. **Document with `custom`** - Add OpenAPI metadata for API docs
10. **Factory pattern for reuse** - Create endpoint factories for plugins
## Resources
- REST API Overview: <https://payloadcms.com/docs/rest-api/overview>
- Custom Endpoints: <https://payloadcms.com/docs/rest-api/overview#custom-endpoints>
- Access Control: <https://payloadcms.com/docs/access-control/overview>
- Local API: <https://payloadcms.com/docs/local-api/overview>

View File

@@ -1,553 +0,0 @@
# Payload Field Type Guards Reference
Complete reference with detailed examples and patterns. See [FIELDS.md](FIELDS.md#field-type-guards) for quick reference table of all guards.
## Structural Guards
### fieldHasSubFields
Checks if field contains nested fields (group, array, row, or collapsible).
```ts
import type { Field } from 'payload'
import { fieldHasSubFields } from 'payload'
function traverseFields(fields: Field[]): void {
fields.forEach((field) => {
if (fieldHasSubFields(field)) {
// Safe to access field.fields
traverseFields(field.fields)
}
})
}
```
**Signature:**
```ts
fieldHasSubFields<TField extends ClientField | Field>(
field: TField
): field is TField & (FieldWithSubFieldsClient | FieldWithSubFields)
```
**Common Pattern - Exclude Arrays:**
```ts
if (fieldHasSubFields(field) && !fieldIsArrayType(field)) {
// Groups, rows, collapsibles only (not arrays)
}
```
### fieldIsArrayType
Checks if field type is `'array'`.
```ts
import { fieldIsArrayType } from 'payload'
if (fieldIsArrayType(field)) {
// field.type === 'array'
console.log(`Min rows: ${field.minRows}`)
console.log(`Max rows: ${field.maxRows}`)
}
```
**Signature:**
```ts
fieldIsArrayType<TField extends ClientField | Field>(
field: TField
): field is TField & (ArrayFieldClient | ArrayField)
```
### fieldIsBlockType
Checks if field type is `'blocks'`.
```ts
import { fieldIsBlockType } from 'payload'
if (fieldIsBlockType(field)) {
// field.type === 'blocks'
field.blocks.forEach((block) => {
console.log(`Block: ${block.slug}`)
})
}
```
**Signature:**
```ts
fieldIsBlockType<TField extends ClientField | Field>(
field: TField
): field is TField & (BlocksFieldClient | BlocksField)
```
**Common Pattern - Distinguish Containers:**
```ts
if (fieldIsArrayType(field)) {
// Handle array rows
} else if (fieldIsBlockType(field)) {
// Handle block types
}
```
### fieldIsGroupType
Checks if field type is `'group'`.
```ts
import { fieldIsGroupType } from 'payload'
if (fieldIsGroupType(field)) {
// field.type === 'group'
console.log(`Interface: ${field.interfaceName}`)
}
```
**Signature:**
```ts
fieldIsGroupType<TField extends ClientField | Field>(
field: TField
): field is TField & (GroupFieldClient | GroupField)
```
## Capability Guards
### fieldSupportsMany
Checks if field can have multiple values (select, relationship, or upload with `hasMany`).
```ts
import { fieldSupportsMany } from 'payload'
if (fieldSupportsMany(field)) {
// field.type is 'select' | 'relationship' | 'upload'
// Safe to check field.hasMany
if (field.hasMany) {
console.log('Field accepts multiple values')
}
}
```
**Signature:**
```ts
fieldSupportsMany<TField extends ClientField | Field>(
field: TField
): field is TField & (FieldWithManyClient | FieldWithMany)
```
### fieldHasMaxDepth
Checks if field is relationship/upload/join with numeric `maxDepth` property.
```ts
import { fieldHasMaxDepth } from 'payload'
if (fieldHasMaxDepth(field)) {
// field.type is 'upload' | 'relationship' | 'join'
// AND field.maxDepth is number
const remainingDepth = field.maxDepth - currentDepth
}
```
**Signature:**
```ts
fieldHasMaxDepth<TField extends ClientField | Field>(
field: TField
): field is TField & (FieldWithMaxDepthClient | FieldWithMaxDepth)
```
### fieldShouldBeLocalized
Checks if field needs localization handling (accounts for parent localization).
```ts
import { fieldShouldBeLocalized } from 'payload'
function processField(field: Field, parentIsLocalized: boolean) {
if (fieldShouldBeLocalized({ field, parentIsLocalized })) {
// Create locale-specific table or index
}
}
```
**Signature:**
```ts
fieldShouldBeLocalized({
field,
parentIsLocalized,
}: {
field: ClientField | ClientTab | Field | Tab
parentIsLocalized: boolean
}): boolean
```
```ts
// Accounts for parent localization
if (fieldShouldBeLocalized({ field, parentIsLocalized: false })) {
/* ... */
}
```
### fieldIsVirtual
Checks if field is virtual (computed or virtual relationship).
```ts
import { fieldIsVirtual } from 'payload'
if (fieldIsVirtual(field)) {
// field.virtual is truthy
if (typeof field.virtual === 'string') {
// Virtual relationship path
console.log(`Virtual path: ${field.virtual}`)
} else {
// Computed virtual field (uses hooks)
}
}
```
**Signature:**
```ts
fieldIsVirtual(field: Field | Tab): boolean
```
## Data Guards
### fieldAffectsData
**Most commonly used guard.** Checks if field stores data (has name and is not UI-only).
```ts
import { fieldAffectsData } from 'payload'
function generateSchema(fields: Field[]) {
fields.forEach((field) => {
if (fieldAffectsData(field)) {
// Safe to access field.name
schema[field.name] = getFieldType(field)
}
})
}
```
**Signature:**
```ts
fieldAffectsData<TField extends ClientField | Field | TabAsField | TabAsFieldClient>(
field: TField
): field is TField & (FieldAffectingDataClient | FieldAffectingData)
```
**Pattern - Data Fields Only:**
```ts
const dataFields = fields.filter(fieldAffectsData)
```
### fieldIsPresentationalOnly
Checks if field is UI-only (type `'ui'`).
```ts
import { fieldIsPresentationalOnly } from 'payload'
if (fieldIsPresentationalOnly(field)) {
// field.type === 'ui'
// Skip in data operations, GraphQL schema, etc.
return
}
```
**Signature:**
```ts
fieldIsPresentationalOnly<TField extends ClientField | Field | TabAsField | TabAsFieldClient>(
field: TField
): field is TField & (UIFieldClient | UIField)
```
### fieldIsID
Checks if field name is exactly `'id'`.
```ts
import { fieldIsID } from 'payload'
if (fieldIsID(field)) {
// field.name === 'id'
// Special handling for ID field
}
```
**Signature:**
```ts
fieldIsID<TField extends ClientField | Field>(
field: TField
): field is { name: 'id' } & TField
```
### fieldIsHiddenOrDisabled
Checks if field is hidden or admin-disabled.
```ts
import { fieldIsHiddenOrDisabled } from 'payload'
const visibleFields = fields.filter((field) => !fieldIsHiddenOrDisabled(field))
```
**Signature:**
```ts
fieldIsHiddenOrDisabled<TField extends ClientField | Field | TabAsField | TabAsFieldClient>(
field: TField
): field is { admin: { hidden: true } } & TField
```
## Layout Guards
### fieldIsSidebar
Checks if field is positioned in sidebar.
```ts
import { fieldIsSidebar } from 'payload'
const [mainFields, sidebarFields] = fields.reduce(
([main, sidebar], field) => {
if (fieldIsSidebar(field)) {
return [main, [...sidebar, field]]
}
return [[...main, field], sidebar]
},
[[], []],
)
```
**Signature:**
```ts
fieldIsSidebar<TField extends ClientField | Field | TabAsField | TabAsFieldClient>(
field: TField
): field is { admin: { position: 'sidebar' } } & TField
```
## Tab & Group Guards
### tabHasName
Checks if tab is named (stores data under tab name).
```ts
import { tabHasName } from 'payload'
tabs.forEach((tab) => {
if (tabHasName(tab)) {
// tab.name exists
dataPath.push(tab.name)
}
// Process tab.fields
})
```
**Signature:**
```ts
tabHasName<TField extends ClientTab | Tab>(
tab: TField
): tab is NamedTab & TField
```
### groupHasName
Checks if group is named (stores data under group name).
```ts
import { groupHasName } from 'payload'
if (groupHasName(group)) {
// group.name exists
return data[group.name]
}
```
**Signature:**
```ts
groupHasName(group: Partial<NamedGroupFieldClient>): group is NamedGroupFieldClient
```
## Option & Value Guards
### optionIsObject
Checks if option is object format `{label, value}` vs string.
```ts
import { optionIsObject } from 'payload'
field.options.forEach((option) => {
if (optionIsObject(option)) {
console.log(`${option.label}: ${option.value}`)
} else {
console.log(option) // string value
}
})
```
**Signature:**
```ts
optionIsObject(option: Option): option is OptionObject
```
### optionsAreObjects
Checks if entire options array contains objects.
```ts
import { optionsAreObjects } from 'payload'
if (optionsAreObjects(field.options)) {
// All options are OptionObject[]
const labels = field.options.map((opt) => opt.label)
}
```
**Signature:**
```ts
optionsAreObjects(options: Option[]): options is OptionObject[]
```
### optionIsValue
Checks if option is string value (not object).
```ts
import { optionIsValue } from 'payload'
if (optionIsValue(option)) {
// option is string
const value = option
}
```
**Signature:**
```ts
optionIsValue(option: Option): option is string
```
### valueIsValueWithRelation
Checks if relationship value is polymorphic format `{relationTo, value}`.
```ts
import { valueIsValueWithRelation } from 'payload'
if (valueIsValueWithRelation(fieldValue)) {
// fieldValue.relationTo exists
// fieldValue.value exists
console.log(`Related to ${fieldValue.relationTo}: ${fieldValue.value}`)
}
```
**Signature:**
```ts
valueIsValueWithRelation(value: unknown): value is ValueWithRelation
```
## Common Patterns
### Recursive Field Traversal
```ts
import { fieldAffectsData, fieldHasSubFields } from 'payload'
function traverseFields(fields: Field[], callback: (field: Field) => void) {
fields.forEach((field) => {
if (fieldAffectsData(field)) {
callback(field)
}
if (fieldHasSubFields(field)) {
traverseFields(field.fields, callback)
}
})
}
```
### Filter Data-Bearing Fields
```ts
import { fieldAffectsData, fieldIsPresentationalOnly, fieldIsHiddenOrDisabled } from 'payload'
const dataFields = fields.filter(
(field) =>
fieldAffectsData(field) && !fieldIsPresentationalOnly(field) && !fieldIsHiddenOrDisabled(field),
)
```
### Container Type Switching
```ts
import { fieldIsArrayType, fieldIsBlockType, fieldHasSubFields } from 'payload'
if (fieldIsArrayType(field)) {
// Handle array-specific logic
} else if (fieldIsBlockType(field)) {
// Handle blocks-specific logic
} else if (fieldHasSubFields(field)) {
// Handle group/row/collapsible
}
```
### Safe Property Access
```ts
import { fieldSupportsMany, fieldHasMaxDepth } from 'payload'
// Without guard - TypeScript error
// if (field.hasMany) { /* ... */ }
// With guard - safe access
if (fieldSupportsMany(field) && field.hasMany) {
console.log('Multiple values supported')
}
if (fieldHasMaxDepth(field)) {
const depth = field.maxDepth // TypeScript knows this is number
}
```
## Type Preservation
All guards preserve the original type constraint:
```ts
import type { ClientField, Field } from 'payload'
import { fieldHasSubFields } from 'payload'
function processServerField(field: Field) {
if (fieldHasSubFields(field)) {
// field is Field & FieldWithSubFields (not ClientField)
}
}
function processClientField(field: ClientField) {
if (fieldHasSubFields(field)) {
// field is ClientField & FieldWithSubFieldsClient
}
}
```

View File

@@ -1,744 +0,0 @@
# Payload CMS Field Types Reference
Complete reference for all Payload field types with examples.
## Text Field
```ts
import type { TextField } from 'payload'
const textField: TextField = {
name: 'title',
type: 'text',
required: true,
unique: true,
minLength: 5,
maxLength: 100,
index: true,
localized: true,
defaultValue: 'Default Title',
validate: (value) => Boolean(value) || 'Required',
admin: {
placeholder: 'Enter title...',
position: 'sidebar',
condition: (data) => data.showTitle === true,
},
}
```
### Slug Field Helper
Built-in helper for auto-generating slugs:
```ts
import { slugField } from 'payload'
import type { CollectionConfig } from 'payload'
export const Pages: CollectionConfig = {
slug: 'pages',
fields: [
{ name: 'title', type: 'text', required: true },
slugField({
name: 'slug', // defaults to 'slug'
useAsSlug: 'title', // defaults to 'title'
checkboxName: 'generateSlug', // defaults to 'generateSlug'
localized: true,
required: true,
overrides: (defaultField) => {
// Customize the generated fields if needed
return defaultField
},
}),
],
}
```
## Rich Text (Lexical)
```ts
import type { RichTextField } from 'payload'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { HeadingFeature, LinkFeature } from '@payloadcms/richtext-lexical'
const richTextField: RichTextField = {
name: 'content',
type: 'richText',
required: true,
localized: true,
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
HeadingFeature({
enabledHeadingSizes: ['h1', 'h2', 'h3'],
}),
LinkFeature({
enabledCollections: ['posts', 'pages'],
}),
],
}),
}
```
### Advanced Lexical Configuration
```ts
import {
BoldFeature,
EXPERIMENTAL_TableFeature,
FixedToolbarFeature,
HeadingFeature,
IndentFeature,
InlineToolbarFeature,
ItalicFeature,
LinkFeature,
OrderedListFeature,
UnderlineFeature,
UnorderedListFeature,
lexicalEditor,
} from '@payloadcms/richtext-lexical'
// Global editor config with full features
export default buildConfig({
editor: lexicalEditor({
features: () => {
return [
UnderlineFeature(),
BoldFeature(),
ItalicFeature(),
OrderedListFeature(),
UnorderedListFeature(),
LinkFeature({
enabledCollections: ['pages'],
fields: ({ defaultFields }) => {
const defaultFieldsWithoutUrl = defaultFields.filter((field) => {
if ('name' in field && field.name === 'url') return false
return true
})
return [
...defaultFieldsWithoutUrl,
{
name: 'url',
type: 'text',
admin: {
condition: ({ linkType }) => linkType !== 'internal',
},
label: ({ t }) => t('fields:enterURL'),
required: true,
},
]
},
}),
IndentFeature(),
EXPERIMENTAL_TableFeature(),
]
},
}),
})
// Field-specific editor with custom toolbar
const richTextWithToolbars: RichTextField = {
name: 'richText',
type: 'richText',
editor: lexicalEditor({
features: ({ rootFeatures }) => {
return [
...rootFeatures,
HeadingFeature({ enabledHeadingSizes: ['h2', 'h3', 'h4'] }),
FixedToolbarFeature(),
InlineToolbarFeature(),
]
},
}),
label: false,
}
```
## Relationship
```ts
import type { RelationshipField } from 'payload'
// Single relationship
const singleRelationship: RelationshipField = {
name: 'author',
type: 'relationship',
relationTo: 'users',
required: true,
maxDepth: 2,
}
// Multiple relationships (hasMany)
const multipleRelationship: RelationshipField = {
name: 'categories',
type: 'relationship',
relationTo: 'categories',
hasMany: true,
filterOptions: {
active: { equals: true },
},
}
// Polymorphic relationship
const polymorphicRelationship: PolymorphicRelationshipField = {
name: 'relatedContent',
type: 'relationship',
relationTo: ['posts', 'pages'],
hasMany: true,
}
```
## Array
```ts
import type { ArrayField } from 'payload'
const arrayField: ArrayField = {
name: 'slides',
type: 'array',
minRows: 2,
maxRows: 10,
labels: {
singular: 'Slide',
plural: 'Slides',
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'image',
type: 'upload',
relationTo: 'media',
},
],
admin: {
initCollapsed: true,
},
}
```
## Blocks
```ts
import type { BlocksField, Block } from 'payload'
const HeroBlock: Block = {
slug: 'hero',
interfaceName: 'HeroBlock',
fields: [
{
name: 'heading',
type: 'text',
required: true,
},
{
name: 'background',
type: 'upload',
relationTo: 'media',
},
],
}
const ContentBlock: Block = {
slug: 'content',
fields: [
{
name: 'text',
type: 'richText',
},
],
}
const blocksField: BlocksField = {
name: 'layout',
type: 'blocks',
blocks: [HeroBlock, ContentBlock],
}
```
## Select
```ts
import type { SelectField } from 'payload'
const selectField: SelectField = {
name: 'status',
type: 'select',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
defaultValue: 'draft',
required: true,
}
// Multiple select
const multiSelectField: SelectField = {
name: 'tags',
type: 'select',
hasMany: true,
options: ['tech', 'news', 'sports'],
}
```
## Upload
```ts
import type { UploadField } from 'payload'
const uploadField: UploadField = {
name: 'featuredImage',
type: 'upload',
relationTo: 'media',
required: true,
filterOptions: {
mimeType: { contains: 'image' },
},
}
```
## Point (Geolocation)
Point fields store geographic coordinates with automatic 2dsphere indexing for geospatial queries.
```ts
import type { PointField } from 'payload'
const locationField: PointField = {
name: 'location',
type: 'point',
label: 'Location',
required: true,
}
// Returns [longitude, latitude]
// Example: [-122.4194, 37.7749] for San Francisco
```
### Geospatial Queries
```ts
// Query by distance (sorted by nearest first)
const nearbyLocations = await payload.find({
collection: 'stores',
where: {
location: {
near: [10, 20], // [longitude, latitude]
maxDistance: 5000, // in meters
minDistance: 1000,
},
},
})
// Query within polygon area
const polygon: Point[] = [
[9.0, 19.0], // bottom-left
[9.0, 21.0], // top-left
[11.0, 21.0], // top-right
[11.0, 19.0], // bottom-right
[9.0, 19.0], // closing point
]
const withinArea = await payload.find({
collection: 'stores',
where: {
location: {
within: {
type: 'Polygon',
coordinates: [polygon],
},
},
},
})
// Query intersecting area
const intersecting = await payload.find({
collection: 'stores',
where: {
location: {
intersects: {
type: 'Polygon',
coordinates: [polygon],
},
},
},
})
```
**Note**: Point fields are not supported in SQLite.
## Join Fields
Join fields create reverse relationships, allowing you to access related documents from the "other side" of a relationship.
```ts
import type { JoinField } from 'payload'
// From Users collection - show user's orders
const ordersJoinField: JoinField = {
name: 'orders',
type: 'join',
collection: 'orders',
on: 'customer', // The field in 'orders' that references this user
admin: {
allowCreate: false,
defaultColumns: ['id', 'createdAt', 'total', 'currency', 'items'],
},
}
// From Users collection - show user's cart
const cartJoinField: JoinField = {
name: 'cart',
type: 'join',
collection: 'carts',
on: 'customer',
admin: {
allowCreate: false,
defaultColumns: ['id', 'createdAt', 'total', 'currency'],
},
}
```
## Virtual Fields
```ts
import type { TextField } from 'payload'
// Computed from siblings
const computedVirtualField: TextField = {
name: 'fullName',
type: 'text',
virtual: true,
hooks: {
afterRead: [({ siblingData }) => `${siblingData.firstName} ${siblingData.lastName}`],
},
}
// From relationship path
const pathVirtualField: TextField = {
name: 'authorName',
type: 'text',
virtual: 'author.name',
}
```
## Conditional Fields
```ts
import type { UploadField, CheckboxField } from 'payload'
// Simple boolean condition
const enableFeatureField: CheckboxField = {
name: 'enableFeature',
type: 'checkbox',
}
const conditionalField: TextField = {
name: 'featureText',
type: 'text',
admin: {
condition: (data) => data.enableFeature === true,
},
}
// Sibling data condition (from hero field pattern)
const typeField: SelectField = {
name: 'type',
type: 'select',
options: ['none', 'highImpact', 'mediumImpact', 'lowImpact'],
defaultValue: 'lowImpact',
}
const mediaField: UploadField = {
name: 'media',
type: 'upload',
relationTo: 'media',
admin: {
condition: (_, { type } = {}) => ['highImpact', 'mediumImpact'].includes(type),
},
required: true,
}
```
## Radio
Radio fields present options as radio buttons for single selection.
```ts
import type { RadioField } from 'payload'
const radioField: RadioField = {
name: 'priority',
type: 'radio',
options: [
{ label: 'Low', value: 'low' },
{ label: 'Medium', value: 'medium' },
{ label: 'High', value: 'high' },
],
defaultValue: 'medium',
admin: {
layout: 'horizontal', // or 'vertical'
},
}
```
## Row (Layout)
Row fields arrange fields horizontally in the admin panel (presentational only).
```ts
import type { RowField } from 'payload'
const rowField: RowField = {
type: 'row',
fields: [
{
name: 'firstName',
type: 'text',
admin: { width: '50%' },
},
{
name: 'lastName',
type: 'text',
admin: { width: '50%' },
},
],
}
```
## Collapsible (Layout)
Collapsible fields group fields in an expandable/collapsible section.
```ts
import type { CollapsibleField } from 'payload'
const collapsibleField: CollapsibleField = {
label: ({ data }) => data?.title || 'Advanced Options',
type: 'collapsible',
admin: {
initCollapsed: true,
},
fields: [
{ name: 'customCSS', type: 'textarea' },
{ name: 'customJS', type: 'code' },
],
}
```
## UI (Custom Components)
UI fields allow fully custom React components in the admin (no data stored).
```ts
import type { UIField } from 'payload'
const uiField: UIField = {
name: 'customMessage',
type: 'ui',
admin: {
components: {
Field: '/path/to/CustomFieldComponent',
Cell: '/path/to/CustomCellComponent', // For list view
},
},
}
```
## Tabs & Groups
```ts
import type { TabsField, GroupField } from 'payload'
// Tabs
const tabsField: TabsField = {
type: 'tabs',
tabs: [
{
label: 'Content',
fields: [
{ name: 'title', type: 'text' },
{ name: 'body', type: 'richText' },
],
},
{
label: 'SEO',
fields: [
{ name: 'metaTitle', type: 'text' },
{ name: 'metaDescription', type: 'textarea' },
],
},
],
}
// Group (named)
const groupField: GroupField = {
name: 'meta',
type: 'group',
fields: [
{ name: 'title', type: 'text' },
{ name: 'description', type: 'textarea' },
],
}
```
## Reusable Field Factories
Create composable field patterns that can be customized with overrides.
```ts
import type { Field, GroupField } from 'payload'
// Utility for deep merging
const deepMerge = <T>(target: T, source: Partial<T>): T => {
// Implementation would deeply merge objects
return { ...target, ...source }
}
// Reusable link field factory
type LinkType = (options?: {
appearances?: ('default' | 'outline')[] | false
disableLabel?: boolean
overrides?: Record<string, unknown>
}) => GroupField
export const link: LinkType = ({ appearances, disableLabel = false, overrides = {} } = {}) => {
const linkField: GroupField = {
name: 'link',
type: 'group',
admin: {
hideGutter: true,
},
fields: [
{
type: 'row',
fields: [
{
name: 'type',
type: 'radio',
options: [
{ label: 'Internal link', value: 'reference' },
{ label: 'Custom URL', value: 'custom' },
],
defaultValue: 'reference',
admin: {
layout: 'horizontal',
width: '50%',
},
},
{
name: 'newTab',
type: 'checkbox',
label: 'Open in new tab',
admin: {
width: '50%',
style: {
alignSelf: 'flex-end',
},
},
},
],
},
{
name: 'reference',
type: 'relationship',
relationTo: ['pages'],
required: true,
maxDepth: 1,
admin: {
condition: (_, siblingData) => siblingData?.type === 'reference',
},
},
{
name: 'url',
type: 'text',
label: 'Custom URL',
required: true,
admin: {
condition: (_, siblingData) => siblingData?.type === 'custom',
},
},
],
}
if (!disableLabel) {
linkField.fields.push({
name: 'label',
type: 'text',
required: true,
})
}
if (appearances !== false) {
linkField.fields.push({
name: 'appearance',
type: 'select',
defaultValue: 'default',
options: [
{ label: 'Default', value: 'default' },
{ label: 'Outline', value: 'outline' },
],
})
}
return deepMerge(linkField, overrides) as GroupField
}
// Usage
const navItem = link({ appearances: false })
const ctaButton = link({
overrides: {
name: 'cta',
admin: {
description: 'Call to action button',
},
},
})
```
## Field Type Guards
Type guards for runtime field type checking and safe type narrowing.
| Type Guard | Checks For | Use When |
| --------------------------- | ----------------------------------------------------------- | ---------------------------------------- |
| `fieldAffectsData` | Field stores data (has name, not UI-only) | Need to access field data or name |
| `fieldHasSubFields` | Field contains nested fields (group/array/row/collapsible) | Need to recursively traverse fields |
| `fieldIsArrayType` | Field is array type | Distinguish arrays from other containers |
| `fieldIsBlockType` | Field is blocks type | Handle blocks-specific logic |
| `fieldIsGroupType` | Field is group type | Handle group-specific logic |
| `fieldSupportsMany` | Field can have multiple values (select/relationship/upload) | Check for `hasMany` support |
| `fieldHasMaxDepth` | Field supports population depth control | Control relationship/upload/join depth |
| `fieldIsPresentationalOnly` | Field is UI-only (no data storage) | Exclude from data operations |
| `fieldIsSidebar` | Field positioned in sidebar | Separate sidebar rendering |
| `fieldIsID` | Field name is 'id' | Special ID field handling |
| `fieldIsHiddenOrDisabled` | Field is hidden or disabled | Filter from UI operations |
| `fieldShouldBeLocalized` | Field needs localization handling | Proper locale table checks |
| `fieldIsVirtual` | Field is virtual (computed/no DB column) | Skip in database transforms |
| `tabHasName` | Tab is named (stores data) | Distinguish named vs unnamed tabs |
| `groupHasName` | Group is named (stores data) | Distinguish named vs unnamed groups |
| `optionIsObject` | Option is `{label, value}` format | Access option properties safely |
| `optionsAreObjects` | All options are objects | Batch option processing |
| `optionIsValue` | Option is string value | Handle string options |
| `valueIsValueWithRelation` | Value is polymorphic relationship | Handle polymorphic relationships |
```ts
import { fieldAffectsData, fieldHasSubFields, fieldIsArrayType } from 'payload'
function processField(field: Field) {
if (fieldAffectsData(field)) {
// Safe to access field.name
console.log(field.name)
}
if (fieldHasSubFields(field)) {
// Safe to access field.fields
field.fields.forEach(processField)
}
}
```
See [FIELD-TYPE-GUARDS.md](FIELD-TYPE-GUARDS.md) for detailed usage patterns.

View File

@@ -1,186 +0,0 @@
# Payload CMS Hooks Reference
Complete reference for collection hooks, field hooks, and hook context patterns.
## Collection Hooks
```ts
export const Posts: CollectionConfig = {
slug: 'posts',
hooks: {
// Before validation
beforeValidate: [
async ({ data, operation }) => {
if (operation === 'create') {
data.slug = slugify(data.title)
}
return data
},
],
// Before save
beforeChange: [
async ({ data, req, operation, originalDoc }) => {
if (operation === 'update' && data.status === 'published') {
data.publishedAt = new Date()
}
return data
},
],
// After save
afterChange: [
async ({ doc, req, operation, previousDoc }) => {
if (operation === 'create') {
await sendNotification(doc)
}
return doc
},
],
// After read
afterRead: [
async ({ doc, req }) => {
doc.viewCount = await getViewCount(doc.id)
return doc
},
],
// Before delete
beforeDelete: [
async ({ req, id }) => {
await cleanupRelatedData(id)
},
],
},
}
```
## Field Hooks
```ts
import type { EmailField, FieldHook } from 'payload'
const beforeValidateHook: FieldHook = ({ value }) => {
return value.trim().toLowerCase()
}
const afterReadHook: FieldHook = ({ value, req }) => {
// Hide email from non-admins
if (!req.user?.roles?.includes('admin')) {
return value.replace(/(.{2})(.*)(@.*)/, '$1***$3')
}
return value
}
const emailField: EmailField = {
name: 'email',
type: 'email',
hooks: {
beforeValidate: [beforeValidateHook],
afterRead: [afterReadHook],
},
}
```
## Hook Context
Share data between hooks or control hook behavior using request context:
```ts
import type { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
hooks: {
beforeChange: [
async ({ context }) => {
context.expensiveData = await fetchExpensiveData()
},
],
afterChange: [
async ({ context, doc }) => {
// Reuse from previous hook
await processData(doc, context.expensiveData)
},
],
},
fields: [{ name: 'title', type: 'text' }],
}
```
## Next.js Revalidation with Context Control
```ts
import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'
import { revalidatePath } from 'next/cache'
import type { Page } from '../payload-types'
export const revalidatePage: CollectionAfterChangeHook<Page> = ({
doc,
previousDoc,
req: { payload, context },
}) => {
if (!context.disableRevalidate) {
if (doc._status === 'published') {
const path = doc.slug === 'home' ? '/' : `/${doc.slug}`
payload.logger.info(`Revalidating page at path: ${path}`)
revalidatePath(path)
}
// Revalidate old path if unpublished
if (previousDoc?._status === 'published' && doc._status !== 'published') {
const oldPath = previousDoc.slug === 'home' ? '/' : `/${previousDoc.slug}`
payload.logger.info(`Revalidating old page at path: ${oldPath}`)
revalidatePath(oldPath)
}
}
return doc
}
export const revalidateDelete: CollectionAfterDeleteHook<Page> = ({ doc, req: { context } }) => {
if (!context.disableRevalidate) {
const path = doc?.slug === 'home' ? '/' : `/${doc?.slug}`
revalidatePath(path)
}
return doc
}
```
## Date Field Auto-Set
Automatically set date when document is published:
```ts
import type { DateField } from 'payload'
const publishedOnField: DateField = {
name: 'publishedOn',
type: 'date',
admin: {
date: {
pickerAppearance: 'dayAndTime',
},
position: 'sidebar',
},
hooks: {
beforeChange: [
({ siblingData, value }) => {
if (siblingData._status === 'published' && !value) {
return new Date()
}
return value
},
],
},
}
```
## Hook Patterns Best Practices
- Use `beforeValidate` for data formatting
- Use `beforeChange` for business logic
- Use `afterChange` for side effects
- Use `afterRead` for computed fields
- Store expensive operations in `context`
- Pass `req` to nested operations for transaction safety (see [ADAPTERS.md#threading-req-through-operations](ADAPTERS.md#threading-req-through-operations))

File diff suppressed because it is too large Load Diff

View File

@@ -1,274 +0,0 @@
# Payload CMS Querying Reference
Complete reference for querying data across Local API, REST, and GraphQL.
## Query Operators
```ts
import type { Where } from 'payload'
// Equals
const equalsQuery: Where = { color: { equals: 'blue' } }
// Not equals
const notEqualsQuery: Where = { status: { not_equals: 'draft' } }
// Greater/less than
const greaterThanQuery: Where = { price: { greater_than: 100 } }
const lessThanEqualQuery: Where = { age: { less_than_equal: 65 } }
// Contains (case-insensitive)
const containsQuery: Where = { title: { contains: 'payload' } }
// Like (all words present)
const likeQuery: Where = { description: { like: 'cms headless' } }
// In/not in
const inQuery: Where = { category: { in: ['tech', 'news'] } }
// Exists
const existsQuery: Where = { image: { exists: true } }
// Near (point fields)
const nearQuery: Where = { location: { near: '-122.4194,37.7749,10000' } }
```
## AND/OR Logic
```ts
import type { Where } from 'payload'
const complexQuery: Where = {
or: [
{ color: { equals: 'mint' } },
{
and: [{ color: { equals: 'white' } }, { featured: { equals: false } }],
},
],
}
```
## Nested Properties
```ts
import type { Where } from 'payload'
const nestedQuery: Where = {
'author.role': { equals: 'editor' },
'meta.featured': { exists: true },
}
```
## Local API
```ts
// Find documents
const posts = await payload.find({
collection: 'posts',
where: {
status: { equals: 'published' },
'author.name': { contains: 'john' },
},
depth: 2,
limit: 10,
page: 1,
sort: '-createdAt',
locale: 'en',
select: {
title: true,
author: true,
},
})
// Find by ID
const post = await payload.findByID({
collection: 'posts',
id: '123',
depth: 2,
})
// Create
const post = await payload.create({
collection: 'posts',
data: {
title: 'New Post',
status: 'draft',
},
})
// Update
await payload.update({
collection: 'posts',
id: '123',
data: {
status: 'published',
},
})
// Delete
await payload.delete({
collection: 'posts',
id: '123',
})
// Count
const count = await payload.count({
collection: 'posts',
where: {
status: { equals: 'published' },
},
})
```
### Threading req Parameter
When performing operations in hooks or nested operations, pass the `req` parameter to maintain transaction context:
```ts
// ✅ CORRECT: Pass req for transaction safety
const afterChange: CollectionAfterChangeHook = async ({ doc, req }) => {
await req.payload.create({
collection: 'audit-log',
data: { action: 'created', docId: doc.id },
req, // Maintains transaction atomicity
})
}
// ❌ WRONG: Missing req breaks transaction
const afterChange: CollectionAfterChangeHook = async ({ doc, req }) => {
await req.payload.create({
collection: 'audit-log',
data: { action: 'created', docId: doc.id },
// Missing req - runs in separate transaction
})
}
```
This is critical for MongoDB replica sets and Postgres. See [ADAPTERS.md#threading-req-through-operations](ADAPTERS.md#threading-req-through-operations) for details.
### Access Control in Local API
**Important**: Local API bypasses access control by default (`overrideAccess: true`). When passing a `user` parameter, you must explicitly set `overrideAccess: false` to respect that user's permissions.
```ts
// ❌ WRONG: User is passed but access control is bypassed
const posts = await payload.find({
collection: 'posts',
user: currentUser,
// Missing: overrideAccess: false
// Result: Operation runs with ADMIN privileges, ignoring user's permissions
})
// ✅ CORRECT: Respects user's access control permissions
const posts = await payload.find({
collection: 'posts',
user: currentUser,
overrideAccess: false, // Required to enforce access control
// Result: User only sees posts they have permission to read
})
// Administrative operation (intentionally bypass access control)
const allPosts = await payload.find({
collection: 'posts',
// No user parameter
// overrideAccess defaults to true
// Result: Returns all posts regardless of access control
})
```
**When to use `overrideAccess: false`:**
- Performing operations on behalf of a user
- Testing access control logic
- API routes that should respect user permissions
- Any operation where `user` parameter is provided
**When `overrideAccess: true` is appropriate:**
- Administrative operations (migrations, seeds, cron jobs)
- Internal system operations
- Operations explicitly intended to bypass access control
See [ACCESS-CONTROL.md#important-notes](ACCESS-CONTROL.md#important-notes) for more details.
## REST API
```ts
import { stringify } from 'qs-esm'
const query = {
status: { equals: 'published' },
}
const queryString = stringify(
{
where: query,
depth: 2,
limit: 10,
},
{ addQueryPrefix: true },
)
const response = await fetch(`https://api.example.com/api/posts${queryString}`)
const data = await response.json()
```
### REST Endpoints
```txt
GET /api/{collection} - Find documents
GET /api/{collection}/{id} - Find by ID
POST /api/{collection} - Create
PATCH /api/{collection}/{id} - Update
DELETE /api/{collection}/{id} - Delete
GET /api/{collection}/count - Count documents
GET /api/globals/{slug} - Get global
POST /api/globals/{slug} - Update global
```
## GraphQL
```graphql
query {
Posts(where: { status: { equals: published } }, limit: 10, sort: "-createdAt") {
docs {
id
title
author {
name
}
}
totalDocs
hasNextPage
}
}
mutation {
createPost(data: { title: "New Post", status: draft }) {
id
title
}
}
mutation {
updatePost(id: "123", data: { status: published }) {
id
status
}
}
mutation {
deletePost(id: "123") {
id
}
}
```
## Performance Best Practices
- Set `maxDepth` on relationships to prevent over-fetching
- Use `select` to limit returned fields
- Index frequently queried fields
- Use `virtual` fields for computed data
- Cache expensive operations in hook `context`

View File

@@ -1,488 +0,0 @@
# Payload CMS + Next.js Troubleshooting
## PostgreSQL Connection Issues
### Wrong port
- Docker container `payload-db-1` exposes MongoDB on port **27017** (default)
- Fix: Use `localhost:5555` in DATABASE_URL for local development
### Wrong database name
- Payload CMS expects database `payload` (matches `POSTGRES_DB=payload`)
- **NOT** `postgres` or `payloaddb`
- Working DATABASE_URL: `postgresql://payload:payloadpass@localhost:5555/payload`
### Wrong credentials
- Docker compose uses `POSTGRES_USER=payload` / `POSTGRES_PASSWORD=payloadpass`
- NOT the default `postgres:postgres`
### Schema not creating tables
**Symptom:** Admin page shows blank/white but HTML loads fine. Tables don't exist in DB.
**Root cause:** `payload migrate` may not have run or failed silently.
**Fix:**
```bash
# 1. Stop dev server
pkill -f "next"
# 2. Run migration
cd /path/to/project
pnpm payload migrate --yes
# OR for fresh start:
pnpm payload migrate:fresh --yes
# 3. Verify tables created
PGPASSWORD=payloadpass psql -h localhost -p 5555 -U payload -d payload -c "\dt"
# 4. Restart dev server
pnpm dev
```
## Admin Page Blank/White Screen
### Causes
1. **Browser cache from old deployment** — standalone mode serves old static file hashes
- Fix: Ctrl+Shift+R (hard refresh) or open Incognito window
2. **Static files not matching the build** — running standalone with dev `.next`
- Fix: Always `pnpm build` before running `node .next/standalone/server.js`
- OR just use `pnpm dev` for development
3. **Database tables don't exist** — Payload admin can't load without schema
- Fix: Run `pnpm payload migrate` to create tables
4. **WebSocket HMR errors** — not a real issue, just hot reload failing
- This is cosmetic and doesn't affect functionality
### Verification
```bash
# Check if admin HTML loads
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/admin
# Should return 200
# Check if JS chunks load (may 404 in dev mode - OK)
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/_next/static/chunks/0pmuyajd0waqg.js
# Check DB tables
PGPASSWORD=payloadpass psql -h localhost -p 5555 -U payload -d payload -c "\dt"
# Should show: media, payload_kv, posts, users, users_sessions, etc.
```
## Payload Migration Commands
```bash
pnpm payload migrate # Run pending migrations
pnpm payload migrate:fresh # Drop all tables and recreate (DANGEROUS)
pnpm payload migrate:reset # Reset migration history
pnpm generate:types # Generate TypeScript types
pnpm generate:importmap # Regenerate import map
```
## Payload CMS 3.x Breaking Changes
- `GRAPHQL_GET` → use `GRAPHQL_PLAYGROUND_GET` from `@payloadcms/next/routes`
- Collection config imports must use `import type { CollectionConfig } from 'payload'`
- `payload push` deprecated → use `payload migrate`
- PostgreSQL adapter in separate package: `@payloadcms/db-postgres`
- Rich text editor in separate package: `@payloadcms/richtext-lexical`
## Docker Compose for PostgreSQL
```yaml
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: payload
POSTGRES_PASSWORD: payloadpass
POSTGRES_DB: payload
ports:
- '5432:5432' # Only if not already in use
```
DATABASE_URL: `postgresql://payload:***@localhost:5432/payload`
(Port depends on what's already mapped in docker-compose)
---
## Next.js 15.3.8 + React 19 SWC Bug (Critical)
### Symptom
Build หรือ dev server compile ส่ง SyntaxError แปลกๆ เช่น:
```
SyntaxError: Unexpected token (50:3)
49 | return (
> 50 | <>
| ^
```
เกิดขึ้นกับ **เฉพาะไฟล์ที่มี**:
1. Fragment shorthand `<>` (แทน `<React.Fragment>`)
2. **Thai text หรือ non-ASCII text** ใน JSX attributes/props ของ elements ภายใน fragment
ถ้าไฟล์มี `<>` แต่ไม่มี Thai text → compile ผ่าน
ถ้าไฟล์มี Thai text แต่ใช้ `<React.Fragment>` → compile ผ่าน
### Root Cause
Next.js 15.3.8 มี SWC compiler bug ที่ค้าง stale cache ของ SyntaxError ไว้แม้หลังแก้ไขไฟล์แล้ว
### Workaround (2 วิธี)
**วิธีที่ 1 — เปลี่ยนจาก `<>` เป็น `<React.Fragment>` หรือ `<Fragment>`:**
```tsx
import { Fragment } from 'react'
// แทน:
return <>
<div>...</div>
</>
// ใช้:
return <Fragment><div>...</div></Fragment>
```
**วิธีที่ 2 — เขียน component ใหม่ทั้งหมด (แนะนำ):**
ถ้า component มี fragment shorthand + Thai text เยอะ ให้เขียนใหม่โดยใช้ pattern ที่ไม่มีปัญหา:
- ใส่ `return (...)` โดยไม่มี `<>` ครอบ
- ใช้ wrapper `<div>` แทน fragment ถ้าเป็นไปได้
- ถ้าต้องใช้ fragment ใช้ `<Fragment>`
### How to Detect
```bash
# ดูว่าไฟล์มี fragment shorthand และ Thai text หรือไม่
grep -l "<>" src/app/\(frontend\)/**/*.tsx | xargs grep -l "[ก-๙]"
```
### Prevention
หลีกเลี่ยงการใช้ `<>` shorthand ใน component ที่มี Thai text — ใช้ `<div>` wrapper หรือ `<Fragment>` แทนเสมอ
---
## ConsentLogs: Default Export Required
Payload CMS บางเวอร์ชัน require ว่า collection config ที่สร้างเองต้องใช้ **default export** ไม่ใช่ named export
```ts
// ✅ ถูกต้อง
const ConsentLogs: CollectionConfig = { ... }
export default ConsentLogs
// ❌ ผิด — named export จะทำให้ Payload มองไม่เห็น collection
export const ConsentLogs = { ... }
```
ถ้า collection ไม่ปรากฏใน Payload admin → ตรวจสอบว่าใช้ `export default` ไม่ใช่ `export const`
---
## Payload Access Functions: Must Be Separate File
Payload CMS ไม่รู้จัก `access` property ที่เป็น inline function ใน collection config — ต้องแยกออกมาเป็นไฟล์
**ถูกต้อง:** `src/collections/access.ts`
```ts
import type { Access } from 'payload'
export const admins: Access = () => true
export const anyone: Access = () => true
```
**แล้ว import ใน collection:**
```ts
import { admins } from './access'
const MyCollection: CollectionConfig = {
access: { create: admins },
}
```
**ผิด:** inline function ใน collection config จะถูก strip หรือไม่ทำงาน
---
## Dev Mode: IP Access + allowedDevOrigins
เมื่อรัน dev server แล้วเข้าผ่าน IP address (เช่น `110.164.146.185:3000`) จะมี warning:
```
Access to server at IP from the development server is blocked by CORS policy.
allowedDevOrigins
```
### Fix: เพิ่ม allowedDevOrigins ใน next.config.ts
```ts
const nextConfig: NextConfig = {
allowedDevOrigins: ['110.164.146.185', '110.164.146.185:3000'],
}
```
### Docker: อย่าลืม Restart + Clear Cache หลังแก้ไข
```bash
docker exec <container> rm -rf /home/node/app/.next
docker restart <container>
# รอ warm up 10-40 วินาที แล้วค่อยเทสต์
```
---
## SWC Cache: Stale Cache หลังแก้ไข Error
ถ้าแก้ไข syntax error แล้ว dev server ยังแสดง error เดิม → SWC cache ค้าง
**วิธีแก้:**
```bash
# ลบ .next cache
rm -rf .next
# ถ้าใช้ Docker
docker exec <container> rm -rf /home/node/app/.next
docker restart <container>
```
**สาเหตุ:** Next.js 15 SWC compiler cache ระดับ binary ค้างอยู่ใน `.next/cache/swc`
---
## sitemap.xml Route (Next.js App Router)
`MetadataRoute.Sitemap` as a **default export function** fails with 500/timeout in Next.js App Router. The correct pattern:
```ts
// ✅ ถูกต้อง — ใช้ GET handler + new Response()
export async function GET(): Promise<Response> {
const pages = [/* ... */]
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${pages.map(p => ` <url><loc>${p.url}</loc>...</url>`).join('\n')}
</urlset>`
return new Response(xml, {
headers: { 'Content-Type': 'application/xml' },
})
}
// ❌ ผิด — MetadataRoute.Sitemap as default export
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// ...returns array — causes 500 in some Next.js versions
}
```
Payload first request ช้ามาก (7-35s) ทำให้ sitemap timeout — ใช้ fallback static data:
```ts
const STATIC_PAGES = [
{ url: 'https://example.com/', priority: 1.0, changefreq: 'weekly' },
// ...
]
export async function GET(): Promise<Response> {
let pages: string[] = []
try {
const payload = await getPayload({ config })
const { docs } = await payload.find({ collection: 'pages', limit: 100 })
pages = docs.map(d => d.slug as string)
} catch {
// Payload unavailable — use static fallback
}
// ...build XML
}
```
---
## Critical: `devBundleServerPackages: false` + `.next` Cache Clear = Total Failure
**Symptom:** หลังลบ `.next` cache แล้ว restart dev server — ทุกหน้ารวม `/` เป็น **500 error** พร้อม:
```
Error: Failed to load external module payload-e448a27c99c096d3
Cannot find package 'payload-e448a27c99c096d3'
```
**Root Cause:** `withPayload(nextConfig, { devBundleServerPackages: false })` บอก Payload ว่าไม่ต้อง bundle Payload packages ลงใน `.next` แต่ Turbopack ยังอ้างถึง bundled chunk names เดิมจาก cache ที่ถูกลบไปแล้ว
**Fix:** ลบ `{ devBundleServerPackages: false }` ออก — ใช้แค่ `withPayload(nextConfig)`
```ts
// ✅ ถูกต้อง
export default withPayload(nextConfig)
// ❌ ลบออก — ทำให้ล้มเหลวหลัง clear .next cache
export default withPayload(nextConfig, { devBundleServerPackages: false })
```
**Prevention:** ถ้าต้อง clear `.next` cache เพราะ cache มีปัญหา ให้ลบ `devBundleServerPackages: false` ก่อน restart dev server
---
## robots.txt Route (Next.js App Router)
`MetadataRoute.Robots` as default export function causes `TypeError: NextResponse.text is not a function` error. Must use explicit GET:
```ts
// ✅ ถูกต้อง
export async function GET() {
return new Response('User-agent: *\nAllow: /\nDisallow: /admin\n', {
headers: { 'Content-Type': 'text/plain' },
})
}
// ❌ ผิด — MetadataRoute.Robots default export
export default function robots(): Promise<MetadataRoute.Robots> {
return Promise.resolve({ rules: { userAgent: '*', allow: '/' } })
}
```
---
## robots.txt Route (Next.js App Router)
**Two patterns that cause 500:**
1. `MetadataRoute.Robots` as default export — บาง version ทำให้ `TypeError: NextResponse.text is not a function`
2. **Cached file conflict** — ถ้ามี file `app/robots.txt` (ไม่ใช่ route.ts) หรือ cached file ใน `.next/dev/server/app/` อยู่ จะทำให้ route.ts handler ถูก ignore แล้ว return empty response
```ts
// ✅ ถูกต้อง
import { NextResponse } from 'next/server'
export async function GET() {
return NextResponse.text(
`User-agent: *
Allow: /
Disallow: /admin
Disallow: /api/
Sitemap: https://www.example.com/sitemap.xml
`,
{ headers: { 'Content-Type': 'text/plain; charset=utf-8' } }
)
}
```
**ถ้า robots.txt เป็น 500 หรือว่างเปล่า:** ตรวจสอบว่าไม่มี `robots.txt` file ตรง (แทน route.ts) และลบ `.next` cache:
```bash
rm -rf .next
```
---
## sitemap.xml: Array Return = 500 Error
**Symptom:** `GET /sitemap.xml` returns 500 — log บอกว่าได้ `Array` แทน `Response`
**Root Cause:** Route handler ส่ง array ไปแทน Response object (เช่น `return [...pages, ...posts]`)
```ts
// ❌ ผิด — array ไม่ใช่ Response
export async function GET() {
const pages = await getPages()
return pages // ← 500 error
}
// ✅ ถูกต้อง
export async function GET() {
const pages = await getPages()
const xml = buildSitemapXml(pages)
return new Response(xml, {
headers: { 'Content-Type': 'application/xml; charset=utf-8' },
})
}
```
---
## `/sitemap` Page Conflicts with `/sitemap.xml` Route
ถ้ามีทั้ง `app/sitemap/page.tsx` และ `app/sitemap.xml/route.ts` — Next.js จะ route ไปที่ page.tsx ก่อน ทำให้ `/sitemap.xml` เป็น **404**
**Fix:** ลบ `app/sitemap/` directory ถ้ามี sitemap.xml route:
```bash
rm -rf app/sitemap/
```
ตรวจสอบ: `ls app/` อย่างน้อยต้องมีไฟล์ `.xml` ไม่ใช่ directory ที่ชื่อเดียวกัน
---
## Bulk Insert Posts ใน MongoDB (Direct via mongosh)
เมื่อ Payload REST API (`POST /api/posts`) ตอบ `500: Something went wrong` เวลา insert richText/Lexical field โดยตรง สามารถใช้ **direct MongoDB insert** แทนได้
### วิธีทำ
```bash
# เขียน script เป็นไฟล์ .cjs (CommonJS)
# รันโดยตรงจาก host (ไม่ต้องเข้า container)
node seed-mongo.cjs
```
### หา MongoDB URL
```bash
grep MONGODB_URL .env
# ถ้าใช้ Docker: mongodb://localhost:27017/portal-mini-store
# ถ้าใช้ Atlas: mongodb+srv://user:pass@cluster.mongodb.net/dbname
```
### Payload SDK Seed Fails ด้วย spawn Error
ถ้า seed script ที่ใช้ Payload SDK (`getPayload()`) ขึ้น error เช่น `spawn is not defined` หรือ `node not found` — นั่นคือ Payload SDK ภายในมีการ `spawn('node')` ซึ่งล้มเหลวในบาง environment
**วิธีแก้: ใช้ MongoDB driver โดยตรง (CommonJS)**
```js
// seed-mongo.cjs — CommonJS เท่านั้น (require, not import)
const { MongoClient } = require('mongodb')
async function main() {
const client = new MongoClient(process.env.MONGODB_URL)
await client.connect()
const db = client.db()
// insert posts
const posts = [/* ... */]
for (const post of posts) {
const result = await db.collection('posts').insertOne({
...post,
createdAt: new Date(),
updatedAt: new Date(),
})
console.log('Inserted:', post.title, result.insertedId)
}
await client.close()
}
main().catch(console.error)
```
### Lexical Content Format ขั้นต่ำ
```js
content: {
root: {
type: 'root',
children: [
{
type: 'paragraph',
children: [{ type: 'text', text: 'your excerpt or content here' }]
}
]
}
}
```
### หา Mongo Container Name
```bash
docker ps --format '{{.Names}}' # ดู container names
# ถ้าใช้ docker-compose จะเป็น <project>-mongo หรือ <project>-db
```
### ตรวจสอบว่า Posts ถูก Insert แล้วผ่าน Payload API
```bash
docker exec <app-container> node -e "
fetch('http://localhost:3000/api/posts?limit=15')
.then(r => r.json())
.then(d => { console.log('Total:', d.totalDocs); d.docs.forEach(p => console.log(' -', p.title)); })
"
```
### ข้อควรระวัง
- Insert ตรงๆ ผ่าน MongoDB จะ bypass Payload access control
- ถ้ามี auth token ต้องใช้ Payload API แทน
- richText field ต้องเป็น Lexical JSON format (ดูด้านบน)

View File

@@ -1,337 +0,0 @@
#!/usr/bin/env bash
#===============================================================================
# migrate-to-payload.sh - Migrate Astro content to Payload CMS with Lexical
#
# Usage: ./migrate-to-payload.sh [source-path] [target-path]
#
# This script migrates content from Astro MDX/Markdown to Payload CMS Lexical.
# - Converts .md/.mdx files to Payload CMS Lexical JSON format
# - Creates Payload collection entries
# - Preserves frontmatter as collection fields
#
# Requirements:
# - node.js 20+
# - npm
#
#===============================================================================
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
SOURCE_PATH="${1:-}"
TARGET_PATH="${2:-.}"
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
log_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
print_usage() {
cat << EOF
Usage: $(basename "$0") [source-path] [target-path]
Migrate Astro content to Payload CMS with Lexical
Arguments:
source-path Path to Astro project with content
target-path Path to Next.js + Payload CMS project
Examples:
$(basename "$0") /path/to/astro-site /path/to/payload-site
EOF
}
detect_content_type() {
log_info "Detecting content structure..."
cd "$SOURCE_PATH"
if [ -d "src/content" ]; then
CONTENT_DIR="src/content"
elif [ -d "content" ]; then
CONTENT_DIR="content"
elif [ -d "src/pages" ]; then
CONTENT_DIR="src/pages"
else
log_error "No content directory found"
exit 1
fi
log_success "Content directory: $CONTENT_DIR"
}
backup_content() {
log_info "Backing up content..."
BACKUP_DIR="/tmp/migration-backup-$(date +%s)"
mkdir -p "$BACKUP_DIR"
if [ -d "$SOURCE_PATH/$CONTENT_DIR" ]; then
cp -r "$SOURCE_PATH/$CONTENT_DIR" "$BACKUP_DIR/"
fi
log_success "Backup at: $BACKUP_DIR"
}
analyze_content() {
log_info "Analyzing content..."
cd "$SOURCE_PATH"
local md_count=$(find "$CONTENT_DIR" -type f \( -name "*.md" -o -name "*.mdx" \) 2>/dev/null | wc -l)
local astro_count=$(find . -type f -name "*.astro" 2>/dev/null | grep -v node_modules | wc -l)
echo ""
echo " Content files: $md_count"
echo " Astro components: $astro_count"
echo ""
find "$CONTENT_DIR" -type f \( -name "*.md" -o -name "*.mdx" \) 2>/dev/null | head -20
}
create_lexical_content() {
log_info "Converting MDX to Payload CMS Lexical format..."
cd "$SOURCE_PATH"
local output_dir="$TARGET_PATH/src/content-migration"
mkdir -p "$output_dir"
find "$CONTENT_DIR" -type f \( -name "*.md" -o -name "*.mdx" \) 2>/dev/null | while read -r file; do
local relative_path="${file#$SOURCE_PATH/$CONTENT_DIR/}"
local filename=$(basename "$file" .mdx .md | sed 's/\.mdx$//' | sed 's/\.md$//')
local slug=$(echo "$filename" | tr '[:upper:]' '[:lower:]' | tr ' ' '-')
local frontmatter=""
local content=""
if grep -q "^---" "$file" 2>/dev/null; then
frontmatter=$(sed -n '/^---/,/^---/p' "$file" | head -n -1 | tail -n +2)
content=$(awk '/^---/{found=1; next} found' "$file")
else
content=$(cat "$file")
fi
local title=$(echo "$frontmatter" | grep -i "^title:" | cut -d':' -f2- | tr -d ' "' | head -1)
local date=$(echo "$frontmatter" | grep -i "^date:" | cut -d':' -f2- | tr -d ' "' | head -1)
local description=$(echo "$frontmatter" | grep -i "^description:" | cut -d':' -f2- | tr -d ' "' | head -1)
local author=$(echo "$frontmatter" | grep -i "^author:" | cut -d':' -f2- | tr -d ' "' | head -1)
local image=$(echo "$frontmatter" | grep -i "^image:" | cut -d':' -f2- | tr -d ' "' | head -1)
local tags=$(echo "$frontmatter" | grep -i "^tags:" | cut -d':' -f2- | tr -d '[]"' | head -1)
title=${title:-$filename}
date=${date:-$(date +%Y-%m-%d)}
cat > "$output_dir/${slug}.json" << JSONEOF
{
"title": "$title",
"slug": "$slug",
"createdAt": "$date",
"updatedAt": "$(date +%Y-%m-%d)",
"meta": {
"title": "$title",
"description": "$description"
},
"author": "$author",
"heroImage": "$image",
"tags": ["$tags"],
"content": {
"root": {
"type": "root",
"format": "",
"indent": 0,
"version": 1,
"children": [
{
"type": "paragraph",
"version": 1,
"children": [
{
"type": "text",
"version": 1,
"text": "$content",
"mode": "tokenized",
"style": ""
}
]
}
]
}
}
}
JSONEOF
echo " Converted: $filename$slug.json"
done
log_success "Conversion complete: $output_dir/"
}
create_payload_import_script() {
log_info "Creating Payload import script..."
local output_dir="$TARGET_PATH/scripts"
mkdir -p "$output_dir"
cat > "$output_dir/import-content.ts" << 'TSEOF'
import { payload } from '../src/lib/payload'
import { promises as fs } from 'fs'
import path from 'path'
async function importContent() {
const contentDir = path.join(process.cwd(), 'src/content-migration')
try {
const files = await fs.readdir(contentDir)
const jsonFiles = files.filter(f => f.endsWith('.json'))
for (const file of jsonFiles) {
const filePath = path.join(contentDir, file)
const content = JSON.parse(await fs.readFile(filePath, 'utf-8'))
await payload.create({
collection: 'posts',
data: {
title: content.title,
slug: content.slug,
createdAt: content.createdAt,
updatedAt: content.updatedAt,
meta: content.meta,
author: content.author,
heroImage: content.heroImage,
tags: content.tags,
content: content.content,
_status: 'published',
},
})
console.log(`Imported: ${content.title}`)
}
console.log(`\nSuccessfully imported ${jsonFiles.length} posts`)
} catch (error) {
console.error('Import failed:', error)
process.exit(1)
}
}
importContent()
TSEOF
log_success "Created: $output_dir/import-content.ts"
}
create_migration_report() {
log_info "Creating migration report..."
cd "$SOURCE_PATH"
local page_count=$(find "$CONTENT_DIR" -type f \( -name "*.md" -o -name "*.mdx" \) 2>/dev/null | wc -l)
cat > "$TARGET_PATH/MIGRATION_REPORT.md" << EOF
# Migration Report: Astro → Payload CMS
## Source
- **Type:** Astro
- **Path:** $SOURCE_PATH
- **Backup:** $BACKUP_DIR
- **Date:** $(date)
## Statistics
- **Total Posts:** $page_count
## Content Migration
Content has been converted to Payload CMS Lexical JSON format in:
\`\`\`
src/content-migration/
\`\`\`
## Next Steps
1. **Review converted content:**
\`\`\`bash
ls src/content-migration/
\`\`\`
2. **Configure Payload collection:**
Make sure you have a 'posts' collection in \`src/collections/Posts.ts\`
3. **Import content to Payload:**
\`\`\`bash
npx tsx scripts/import-content.ts
\`\`\`
4. **Verify in admin:**
- Go to http://localhost:3002/admin
- Navigate to Posts collection
- Verify content and rich text editor (Lexical)
## Notes
- MDX/Markdown content is converted to Lexical JSON format
- Frontmatter fields (title, date, description) are mapped to collection fields
- Complex MDX components need manual conversion in Payload admin
- Images need to be re-uploaded to Payload Media
EOF
log_success "Migration report: $TARGET_PATH/MIGRATION_REPORT.md"
}
main() {
echo "=============================================="
echo " Astro → Payload CMS Migration Tool"
echo " Convert MDX/MD to Payload CMS with Lexical"
echo "=============================================="
echo ""
if [ "$1" == "-h" ] || [ "$1" == "--help" ]; then
print_usage
exit 0
fi
if [ -z "$SOURCE_PATH" ]; then
print_usage
echo ""
log_error "Please specify source path"
exit 1
fi
if [ ! -d "$SOURCE_PATH" ]; then
log_error "Source path not found: $SOURCE_PATH"
exit 1
fi
if [ ! -d "$TARGET_PATH" ]; then
log_error "Target path not found: $TARGET_PATH"
exit 1
fi
detect_content_type
backup_content
analyze_content
create_lexical_content
create_payload_import_script
create_migration_report
echo ""
echo "=============================================="
log_success "Migration preparation complete!"
echo "=============================================="
echo ""
echo "Next steps:"
echo " 1. cd $TARGET_PATH"
echo " 2. Review converted content in src/content-migration/"
echo " 3. Run: npm run dev"
echo " 4. Import: npx tsx scripts/import-content.ts"
echo " 5. Verify in Payload admin (http://localhost:3002/admin)"
echo ""
}
main "$@"

View File

@@ -0,0 +1,327 @@
#!/usr/bin/env bash
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
BACKEND_PATH="${1:-./tina-backend}"
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
print_usage() {
cat << EOF
Usage: $(basename "$0") [target-path]
Install Tina CMS Backend (self-hosted)
Arguments:
target-path Path where Tina backend will be installed (default: ./tina-backend)
Examples:
$(basename "$0") /opt/tina-backend
This script installs a self-hosted Tina CMS backend with:
- Auth.js authentication
- SQLite database adapter
- Git provider for content
- Next.js API routes
Requirements:
- Node.js 18+
- npm or yarn
- git
EOF
}
main() {
echo "=============================================="
echo " Tina CMS Backend Installer (Self-Hosted)"
echo "=============================================="
echo ""
if [ "$1" == "-h" ] || [ "$1" == "--help" ]; then
print_usage
exit 0
fi
if [ -d "$BACKEND_PATH" ]; then
log_error "Directory already exists: $BACKEND_PATH"
exit 1
fi
log_info "Creating Tina backend at: $BACKEND_PATH"
mkdir -p "$BACKEND_PATH"
cd "$BACKEND_PATH"
log_info "Creating package.json..."
cat > package.json << 'PKGEOF'
{
"name": "tina-backend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "tinacms dev",
"build": "tinacms build",
"start": "tinacms start"
},
"dependencies": {
"@auth/core": "^0.34.0",
"@auth/drizzle-adapter": "^1.4.0",
"@libsql/client": "^0.14.0",
"@tinacms/auth": "^2.0.0",
"@tinacms/database": "^2.0.0",
"@tinacms/git-provider": "^2.0.0",
"@tinacms/graphql": "^2.0.0",
"@tinacms/mssql": "^2.0.0",
"@tinacms/server": "^2.0.0",
"drizzle-orm": "^0.38.0",
"next": "^14.0.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"tinacms": "^2.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.0.0"
}
}
PKGEOF
log_info "Creating TypeScript config..."
cat > tsconfig.json << 'TSEOF'
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx"
},
"include": ["src/**/*", "tina/**/*"],
"exclude": ["node_modules"]
}
TSEOF
mkdir -p src/app/api/auth/\[...nextauth\]
mkdir -p src/app/api/tina/\[\[...tina\]\]
mkdir -p tina
log_info "Creating Auth.js configuration..."
cat > src/auth.config.ts << 'AUTHEOF'
import { AuthConfig } from "@auth/core/types";
const authConfig: AuthConfig = {
secret: process.env.NEXTAUTH_SECRET || "your-secret-change-in-production",
providers: [
{
id: "github",
name: "GitHub",
type: "oauth",
clientId: process.env.GITHUB_ID || "",
clientSecret: process.env.GITHUB_SECRET || "",
},
],
callbacks: {
async session({ session, token }) {
if (session.user && token.sub) {
session.user.email = token.email as string;
}
return session;
},
},
pages: {
signIn: "/auth/signin",
},
};
export default authConfig;
AUTHEOF
log_info "Creating NextAuth API route..."
cat > 'src/app/api/auth/[...nextauth]/route.ts' << 'NEXTAUTHEOF'
import { NextRequest, NextResponse } from "next/server";
import { AuthHandler } from "@auth/core";
import authConfig from "../../../auth.config";
const authHandler = (req: NextRequest) =>
AuthHandler({
...authConfig,
req: req as any,
resolve(): Promise<any> {
throw new Error("Function not implemented.");
},
secret: authConfig.secret!,
trustHost: true,
});
export { authHandler as GET, authHandler as POST };
NEXTAUTHEOF
log_info "Creating Tina API route..."
cat > 'src/app/api/tina/[[...tina]]/route.ts' << 'TINAEOF'
import { TinaNodeBackend } from "@tinacms/server";
import authConfig from "../../../../auth.config";
import { branchName } from "./branch";
const tinaBackend = TinaNodeBackend({
authConfig: authConfig as any,
branch: branchName,
});
export { tinaBackend as GET, tinaBackend as POST };
TINAEOF
log_info "Creating Tina branch configuration..."
cat > src/app/api/tina/branch.ts << 'BRANCHEMAP'
export const branchName = process.env.TINA_BRANCH || "main";
BRANCHEMAP
log_info "Creating database schema..."
mkdir -p src/lib
cat > src/lib/schema.ts << 'DBEOF'
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
export const users = sqliteTable("users", {
id: text("id").primaryKey(),
name: text("name"),
email: text("email").unique(),
emailVerified: integer("email_verified", { mode: "boolean" }),
image: text("image"),
createdAt: integer("created_at", { mode: "timestamp" }),
updatedAt: integer("updated_at", { mode: "timestamp" }),
});
export const accounts = sqliteTable("accounts", {
id: text("id").primaryKey(),
userId: text("user_id")
.references(() => users.id)
.notNull(),
type: text("type").notNull(),
provider: text("provider").notNull(),
providerAccountId: text("provider_account_id").notNull(),
refresh_token: text("refresh_token"),
access_token: text("access_token"),
expires_at: integer("expires_at"),
token_type: text("token_type"),
scope: text("scope"),
id_token: text("id_token"),
session_state: text("session_state"),
});
export const sessions = sqliteTable("sessions", {
id: text("id").primaryKey(),
sessionToken: text("session_token").unique(),
userId: text("user_id")
.references(() => users.id)
.notNull(),
expires: integer("expires", { mode: "timestamp" }).notNull(),
});
export const verificationTokens = sqliteTable("verification_tokens", {
identifier: text("identifier").notNull(),
token: text("token").notNull(),
expires: integer("expires", { mode: "timestamp" }).notNull(),
});
DBEOF
log_info "Creating database client..."
cat > src/lib/db.ts << 'DBCEOF'
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import * as schema from "./schema";
const client = createClient({
url: process.env.DATABASE_URL || "file:local.db",
});
export const db = drizzle(client, { schema });
DBCEOF
log_info "Creating environment template..."
cat > .env.example << 'ENVEOF'
NEXTAUTH_SECRET=generate-a-random-secret-here
NEXTAUTH_URL=http://localhost:3000
GITHUB_ID=your-github-oauth-app-client-id
GITHUB_SECRET=your-github-oauth-app-client-secret
DATABASE_URL=file:local.db
TINA_BRANCH=main
ENVEOF
log_info "Creating README..."
cat > README.md << 'READMEEOF'
# Tina CMS Backend (Self-Hosted)
Self-hosted Tina CMS backend with Auth.js authentication and SQLite database.
## Setup
1. Install dependencies:
```bash
npm install
```
2. Configure environment:
```bash
cp .env.example .env
# Edit .env with your settings
```
3. Set up GitHub OAuth App:
- Go to https://github.com/settings/developers
- Create a new OAuth App
- Set callback URL to: `http://your-domain.com/api/auth/callback/github`
4. Start development:
```bash
npm run dev
```
## Environment Variables
| Variable | Description |
|----------|-------------|
| NEXTAUTH_SECRET | Random secret for NextAuth |
| NEXTAUTH_URL | Your site URL |
| GITHUB_ID | GitHub OAuth Client ID |
| GITHUB_SECRET | GitHub OAuth Client Secret |
| DATABASE_URL | SQLite database path |
| TINA_BRANCH | Git branch for content |
## Connecting Frontend
In your Astro frontend's `tina/config.ts`:
```ts
import { defineConfig } from "tinacms";
export default defineConfig({
apiUrl: "https://your-tina-backend.com",
contentApiUrl: "https://your-tina-backend.com",
});
```
READMEEOF
log_success "Tina backend created at: $BACKEND_PATH"
echo ""
echo "Next steps:"
echo " 1. cd $BACKEND_PATH"
echo " 2. npm install"
echo " 3. cp .env.example .env"
echo " 4. Configure GitHub OAuth App"
echo " 5. npm run dev"
echo ""
}
main "$@"

View File

@@ -0,0 +1,443 @@
#!/usr/bin/env bash
#===============================================================================
# migrate-tina.sh - Migrate existing websites to Astro + Tina CMS
#
# Usage: ./migrate-tina.sh [source-path] [target-path]
#
# This script migrates websites to Astro + Tina CMS:
# - Converts content to Tina CMS format
# - Sets up Astro DB for consent logging
# - Adds PDPA-compliant consent system
# - Preserves content and structure
#
# Requirements:
# - node.js 20+
# - npm
# - git
#
#===============================================================================
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
SOURCE_PATH="${1:-}"
TARGET_PATH="${2:-.}"
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
log_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
print_usage() {
cat << EOF
Usage: $(basename "$0") [source-path] [target-path]
Migrate existing website to Astro + Tina CMS
Arguments:
source-path Path to existing website project
target-path Path for the migrated Astro + Tina project
Examples:
$(basename "$0") /path/to/existing-site /path/to/migrated-site
Features:
- Detects source website technology (Astro, Next.js, etc.)
- Converts content to Tina CMS format
- Sets up Astro DB for consent logging (PDPA compliant)
- Adds cookie consent banner with Thai law compliance
- Preserves SEO metadata and content structure
EOF
}
detect_source_type() {
log_info "Detecting source website type..."
cd "$SOURCE_PATH"
if [ -f "astro.config.mjs" ] || [ -f "astro.config.ts" ]; then
SOURCE_TYPE="astro"
log_success "Detected: Astro"
elif [ -f "next.config.js" ] || [ -f "next.config.mjs" ] || [ -f "package.json" ] && grep -q "next" package.json 2>/dev/null; then
SOURCE_TYPE="nextjs"
log_success "Detected: Next.js"
elif [ -f "package.json" ] && grep -q "remix" package.json 2>/dev/null; then
SOURCE_TYPE="remix"
log_success "Detected: Remix"
elif [ -d "src/content" ] || [ -d "content/posts" ]; then
SOURCE_TYPE="generic"
log_success "Detected: Generic static site"
else
log_warning "Could not detect source type, assuming generic"
SOURCE_TYPE="generic"
fi
}
analyze_source_content() {
log_info "Analyzing source content..."
cd "$SOURCE_PATH"
local md_count=$(find . -type f \( -name "*.md" -o -name "*.mdx" \) 2>/dev/null | grep -v node_modules | wc -l)
local astro_count=$(find . -type f -name "*.astro" 2>/dev/null | grep -v node_modules | wc -l)
local pages_count=$(find . -type f \( -name "*.tsx" -o -name "*.jsx" \) 2>/dev/null | grep -v node_modules | grep -E "pages/|app/" | wc -l)
echo ""
echo " Analysis Results:"
echo " ─────────────────"
echo " Markdown/MDX files: $md_count"
echo " Astro components: $astro_count"
echo " Pages (tsx/jsx): $pages_count"
echo ""
# List sample content files
if [ $md_count -gt 0 ]; then
echo " Sample content files:"
find . -type f \( -name "*.md" -o -name "*.mdx" \) 2>/dev/null | grep -v node_modules | head -5 | while read -r f; do
echo " - $f"
done
echo ""
fi
}
copy_template() {
log_info "Copying Astro+Tina template..."
local template_dir="$(dirname "$(dirname "$(readlink -f "$0")")")/templates/astro-tina-starter"
if [ ! -d "$template_dir" ]; then
log_error "Template not found: $template_dir"
exit 1
fi
cp -r "$template_dir"/* "$TARGET_PATH/"
cp -r "$template_dir"/.* "$TARGET_PATH/" 2>/dev/null || true
log_success "Template copied to: $TARGET_PATH"
}
migrate_content() {
log_info "Migrating content to Tina format..."
cd "$SOURCE_PATH"
# Detect content directory
local content_dir=""
if [ -d "src/content" ]; then
content_dir="src/content"
elif [ -d "content" ]; then
content_dir="content"
elif [ -d "content/posts" ]; then
content_dir="content/posts"
fi
if [ -z "$content_dir" ]; then
log_warning "No content directory found, creating default structure"
mkdir -p "$TARGET_PATH/src/content"
return
fi
# Create Tina content directory
mkdir -p "$TARGET_PATH/src/content"
# Copy markdown/mdx files
find "$content_dir" -type f \( -name "*.md" -o -name "*.mdx" \) 2>/dev/null | while read -r file; do
local relative_path="${file#$SOURCE_PATH/$content_dir/}"
local target_file="$TARGET_PATH/src/content/$relative_path"
mkdir -p "$(dirname "$target_file")"
cp "$file" "$target_file"
echo " Migrated: $relative_path"
done
log_success "Content migration complete"
}
add_consent_system() {
log_info "Adding PDPA-compliant consent system..."
local consent_template="$(dirname "$(dirname "$(readlink -f "$0")")")/templates/consent"
if [ ! -d "$consent_template" ]; then
log_warning "Consent template not found, skipping"
return
fi
# Copy consent files
cp -r "$consent_template"/* "$TARGET_PATH/src/components/consent/" 2>/dev/null || true
log_success "Consent system added"
}
create_tina_schema() {
log_info "Creating Tina CMS schema..."
cd "$TARGET_PATH"
# Ensure .tina directory exists
mkdir -p .tina
# Create or update schema
cat > .tina/schema.ts << 'EOF'
import { defineSchema, config } from 'tinacms'
// Your content collections
const schema = defineSchema({
collections: [
{
name: 'post',
label: 'Posts',
path: 'src/content/posts',
fields: [
{
type: 'string',
name: 'title',
label: 'Title',
required: true,
},
{
type: 'string',
name: 'slug',
label: 'Slug',
required: true,
},
{
type: 'datetime',
name: 'date',
label: 'Date',
},
{
type: 'string',
name: 'author',
label: 'Author',
},
{
type: 'string',
name: 'image',
label: 'Featured Image',
},
{
type: 'string',
name: 'description',
label: 'Description',
},
{
type: 'rich-text',
name: 'body',
label: 'Body',
isBody: true,
},
],
},
{
name: 'page',
label: 'Pages',
path: 'src/content/pages',
fields: [
{
type: 'string',
name: 'title',
label: 'Title',
required: true,
},
{
type: 'string',
name: 'slug',
label: 'Slug',
required: true,
},
{
type: 'rich-text',
name: 'body',
label: 'Body',
isBody: true,
},
],
},
],
})
export default config({
schema,
// Other config options
})
EOF
log_success "Tina schema created"
}
create_migration_report() {
log_info "Creating migration report..."
cd "$SOURCE_PATH"
local md_count=$(find . -type f \( -name "*.md" -o -name "*.mdx" \) 2>/dev/null | grep -v node_modules | wc -l)
cat > "$TARGET_PATH/MIGRATION_REPORT.md" << EOF
# Migration Report: → Astro + Tina CMS
## Source
- **Original Type:** $SOURCE_TYPE
- **Path:** $SOURCE_PATH
- **Date:** $(date)
## Statistics
- **Content Files Migrated:** $md_count
## What's Included
### ✅ Astro 6.1.7
Modern static site framework with excellent performance.
### ✅ Tina CMS
Self-hosted Git-based CMS for visual content editing.
### ✅ Tailwind CSS 4.x
Latest Tailwind with @tailwindcss/vite plugin.
### ✅ Astro DB
Built-in database for consent logging and dynamic content.
### ✅ PDPA Consent System
Thai Personal Data Protection Act compliant cookie consent:
- Cookie banner with Accept/Reject/Preferences
- Consent logging in Astro DB
- API endpoint for consent management
### ✅ Nano Stores
Lightweight client-side state management.
## Project Structure
\`\`\`
$TARGET_PATH/
├── src/
│ ├── components/
│ │ └── consent/ # PDPA consent system
│ ├── content/
│ │ ├── posts/ # Blog posts (Tina managed)
│ │ └── pages/ # Static pages (Tina managed)
│ ├── layouts/
│ │ └── Layout.astro
│ ├── pages/
│ │ └── index.astro
│ └── styles/
│ └── global.css
├── .tina/
│ └── schema.ts # Tina content schema
├── db/
│ └── config.ts # Astro DB config
├── Dockerfile
└── AGENTS.md # AI agent instructions
\`\`\`
## Next Steps
1. **Install dependencies:**
\`\`\`bash
cd $TARGET_PATH
npm install
\`\`\`
2. **Set up environment:**
\`\`\`bash
cp .env.example .env
# Edit .env with your settings
\`\`\`
3. **Start development:**
\`\`\`bash
npm run dev
\`\`\`
4. **Access Tina Admin:**
- Visit \`http://localhost:4321/admin\` (when in dev mode)
- Or \`http://localhost:4321/___tina\` for direct access
5. **Configure Tina Backend** (for production):
\`\`\`bash
./scripts/install-tina-backend.sh
\`\`\`
## Tina CMS Setup
For production, you'll need to set up the Tina backend:
\`\`\`bash
./scripts/install-tina-backend.sh
\`\`\`
This will install:
- Auth.js for authentication
- Database adapter for content storage
- Git provider for content management
## PDPA Compliance
The consent system logs:
- User consent choices (accept/reject)
- Cookie categories (analytics, marketing, functional)
- Timestamp and user agent
- IP address (for compliance auditing)
Logs are stored in Astro DB and can be exported for compliance reporting.
EOF
log_success "Migration report: $TARGET_PATH/MIGRATION_REPORT.md"
}
main() {
echo "=============================================="
echo " Website → Astro + Tina CMS Migration Tool"
echo "=============================================="
echo ""
if [ "$1" == "-h" ] || [ "$1" == "--help" ]; then
print_usage
exit 0
fi
if [ -z "$SOURCE_PATH" ]; then
print_usage
echo ""
log_error "Please specify source path"
exit 1
fi
if [ ! -d "$SOURCE_PATH" ]; then
log_error "Source path not found: $SOURCE_PATH"
exit 1
fi
if [ ! -d "$TARGET_PATH" ]; then
mkdir -p "$TARGET_PATH"
fi
detect_source_type
analyze_source_content
copy_template
migrate_content
add_consent_system
create_tina_schema
create_migration_report
echo ""
echo "=============================================="
log_success "Migration complete!"
echo "=============================================="
echo ""
echo "Next steps:"
echo " 1. cd $TARGET_PATH"
echo " 2. npm install"
echo " 3. npm run dev"
echo " 4. See MIGRATION_REPORT.md for details"
echo ""
}
main "$@"

View File

@@ -1,119 +1,79 @@
#!/usr/bin/env bash
#===============================================================================
# new-project.sh - สร้าง Next.js + Payload CMS project ใหม่จาก Template
#
# Usage: ./new-project.sh [project-name] [project-path]
#
# สร้าง Next.js + Payload CMS project ใหม่โดย:
# 1. คัดลอก nextjs-payload-starter template
# 2. ติดตั้ง dependencies
# 3. ตั้งค่า environment
#
# Requirements:
# - git
# - node.js 20+
# - npm
#
#===============================================================================
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
NC='\033[0m'
# Default values
PROJECT_NAME="${1:-}"
PROJECT_PATH="${2:-.}"
# Get skill directory
SKILL_DIR="$(dirname "$(dirname "$(readlink -f "$0")")")"
TEMPLATE_DIR="$SKILL_DIR/templates/nextjs-payload-starter"
TEMPLATE_DIR="$SKILL_DIR/templates/astro-tina-starter"
#-------------------------------------------------------------------------------
# Helper functions
#-------------------------------------------------------------------------------
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
log_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
log_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
print_usage() {
cat << EOF
Usage: $(basename "$0") [project-name] [project-path]
สร้าง Next.js + Payload CMS project ใหม่จาก Template
Create new Astro + Tina CMS project from template
Arguments:
project-name ชื่อ project (optional)
project-path ที่อยู่ project (default: current directory)
project-name Project name (optional)
project-path Project location (default: current directory)
Examples:
$(basename "$0") my-website
$(basename "$0") my-website /path/to/projects/
Creates:
- Astro 6.1.7 framework
- Tailwind CSS 4.x
- Tina CMS (self-hosted)
- Astro DB for consent logging
- PDPA-compliant consent system
EOF
}
#-------------------------------------------------------------------------------
# Pre-flight checks
#-------------------------------------------------------------------------------
check_requirements() {
log_info "ตรวจสอบความต้องการของระบบ..."
log_info "Checking requirements..."
# Check git
if ! command -v git &> /dev/null; then
log_error "git ไม่พบ กรุณาติดตั้ง git ก่อน"
log_error "git not found"
exit 1
fi
# Check node
if ! command -v node &> /dev/null; then
log_error "node.js ไม่พบ กรุณาติดตั้ง node.js ก่อน"
log_error "node.js not found"
exit 1
fi
NODE_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
if [ "$NODE_VERSION" -lt 20 ]; then
log_error "node.js version ต้อง >= 20 (ตอนนี้: $(node -v))"
log_error "node.js >= 20 required (current: $(node -v))"
exit 1
fi
# Check npm
if ! command -v npm &> /dev/null; then
log_error "npm ไม่พบ กรุณาติดตั้ง npm ก่อน"
log_error "npm not found"
exit 1
fi
# Check template exists
if [ ! -d "$TEMPLATE_DIR" ]; then
log_error "ไม่พบ Next.js Payload Starter Template: $TEMPLATE_DIR"
log_error "Template not found: $TEMPLATE_DIR"
exit 1
fi
log_success "ความต้องการของระบบผ่าน (git, node $(node -v), npm)"
log_success "Requirements OK (git, node $(node -v), npm)"
}
#-------------------------------------------------------------------------------
# Create project directory
#-------------------------------------------------------------------------------
setup_directory() {
local actual_project_path="$PROJECT_PATH"
@@ -121,13 +81,11 @@ setup_directory() {
actual_project_path="$PROJECT_PATH/$PROJECT_NAME"
fi
# Create directory
mkdir -p "$actual_project_path"
# Check if directory is empty
if [ "$(ls -A "$actual_project_path" | wc -l)" -gt 0 ]; then
log_warning "Directory ไม่ว่าง: $actual_project_path"
read -p "ดำเนินต่อ? (y/n): " -n 1 -r
log_warning "Directory not empty: $actual_project_path"
read -p "Continue? (y/n): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
@@ -138,32 +96,37 @@ setup_directory() {
log_info "Project path: $PROJECT_PATH"
}
#-------------------------------------------------------------------------------
# Copy template
#-------------------------------------------------------------------------------
copy_template() {
log_info "คัดลอก Next.js Payload Starter Template..."
log_info "Copying Astro+Tina template..."
# Copy all template files
cp -r "$TEMPLATE_DIR/"* "$PROJECT_PATH/"
cp -r "$TEMPLATE_DIR/src/collections/access" "$PROJECT_PATH/src/collections/" 2>/dev/null || true
cp -r "$TEMPLATE_DIR"/.* "$PROJECT_PATH/" 2>/dev/null || true
# Copy consent API if exists
if [ -d "$SKILL_DIR/templates/consent/api" ]; then
mkdir -p "$PROJECT_PATH/src/pages/api"
cp "$SKILL_DIR/templates/consent/api/"* "$PROJECT_PATH/src/pages/api/" 2>/dev/null || true
fi
log_success "คัดลอก template เสร็จสมบูรณ์"
log_success "Template copied"
}
#-------------------------------------------------------------------------------
# Copy legal templates
#-------------------------------------------------------------------------------
copy_consent_system() {
log_info "Adding PDPA consent system..."
local consent_template="$SKILL_DIR/templates/consent"
if [ -d "$consent_template" ]; then
mkdir -p "$PROJECT_PATH/src/components/consent"
cp "$consent_template/ConsentBanner.astro" "$PROJECT_PATH/src/components/consent/" 2>/dev/null || true
cp "$consent_template/stores/"* "$PROJECT_PATH/src/stores/" 2>/dev/null || true
mkdir -p "$PROJECT_PATH/src/pages/api"
cp "$consent_template/api/consent.ts" "$PROJECT_PATH/src/pages/api/" 2>/dev/null || true
mkdir -p "$PROJECT_PATH/db"
cp "$consent_template/db/config.ts" "$PROJECT_PATH/db/" 2>/dev/null || true
fi
log_success "Consent system added"
}
copy_legal_templates() {
log_info "คัดลอก PDPA templates..."
log_info "Copying PDPA legal templates..."
mkdir -p "$PROJECT_PATH/src/content/pages"
@@ -175,168 +138,92 @@ copy_legal_templates() {
cp "$SKILL_DIR/templates/terms-of-service.md" "$PROJECT_PATH/src/content/pages/"
fi
log_success "คัดลอก PDPA templates เสร็จสมบูรณ์"
log_success "Legal templates copied"
}
#-------------------------------------------------------------------------------
# Install dependencies
#-------------------------------------------------------------------------------
install_dependencies() {
log_info "ติดตั้ง dependencies..."
log_info "Installing dependencies..."
cd "$PROJECT_PATH"
npm install
log_success "ติดตั้ง dependencies เสร็จสมบูรณ์"
log_success "Dependencies installed"
}
#-------------------------------------------------------------------------------
# Setup environment
#-------------------------------------------------------------------------------
setup_environment() {
log_info "ตั้งค่า environment..."
log_info "Setting up environment..."
cd "$PROJECT_PATH"
if [ ! -f ".env" ]; then
if [ -f ".env.example" ]; then
cp .env.example .env
log_success "สร้าง .env จาก .env.example"
log_warning "กรุณาแก้ไข .env และใส่ DATABASE_URL ที่ถูกต้อง"
log_success "Created .env from .env.example"
else
cat > .env << 'EOF'
# Payload CMS
PAYLOAD_SECRET=change-this-secret-key-at-least-32-characters
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
# Server
SERVER_URL=http://localhost:4321
NODE_ENV=development
PUBLIC_SITE_URL=http://localhost:4321
TINA_TOKEN=your-tina-token
EOF
log_success "สร้าง .env เริ่มต้น"
log_warning "กรุณาแก้ไข .env และใส่ DATABASE_URL ที่ถูกต้อง"
log_success "Created default .env"
fi
else
log_info ".env มีอยู่แล้ว"
fi
}
#-------------------------------------------------------------------------------
# Create AI_RULES.md
#-------------------------------------------------------------------------------
create_ai_rules() {
log_info "สร้าง AI_RULES.md..."
cd "$PROJECT_PATH"
cat > AI_RULES.md << 'EOF'
# AI Rules
## Tech Stack Overview
- **Frontend:** Next.js App Router + TypeScript
- **Backend/CMS:** Payload CMS 3.0
- **Database:** MongoDB (via mongooseAdapter)
- **Styling:** Tailwind CSS v4
- **Authentication:** Payload built-in auth with role-based access
- **Image Handling:** Payload Media collection
## File Organization
- **Collections:** Define Payload collections in `src/collections/`
- **Pages:** Next.js App Router in `src/app/`
- **Components:** Reusable components in `src/components/`
- **Styles:** Global styles in `src/app/globals.css`
## Never Modify These Files
- `src/payload-types.ts` - Auto-generated by Payload
- `src/migrations/` - Database migration files
## Thai-First
- ใช้ Kanit หรือ Noto Sans Thai fonts
- Thai typography CSS
- Thai structured data (LocalBusiness, Organization)
- ภาษาไทยเป็นหลักใน content
EOF
log_success "สร้าง AI_RULES.md เสร็จสมบูรณ์"
}
#-------------------------------------------------------------------------------
# Initialize git
#-------------------------------------------------------------------------------
init_git() {
log_info "เริ่มต้น git..."
log_info "Initializing git..."
cd "$PROJECT_PATH"
if [ ! -d ".git" ]; then
git init
git add .
git commit -m "Initial commit: Next.js + Payload CMS starter"
log_success "เริ่มต้น git เสร็จสมบูรณ์"
else
log_info "git repo มีอยู่แล้ว"
git commit -m "Initial commit: Astro + Tina CMS starter"
log_success "Git initialized"
fi
}
#-------------------------------------------------------------------------------
# Show project structure
#-------------------------------------------------------------------------------
show_structure() {
log_info "โครงสร้าง project:"
log_info "Project structure:"
cd "$PROJECT_PATH"
echo ""
find . -type f \( -name "*.ts" -o -name "*.tsx" -o -name "*.astro" -o -name "*.mjs" -o -name "*.css" -o -name "*.md" -o -name "package.json" \) 2>/dev/null | grep -v node_modules | sort | head -30
}
#-------------------------------------------------------------------------------
# Main
#-------------------------------------------------------------------------------
main() {
echo "=============================================="
echo " Next.js + Payload CMS Project Creator"
echo " Using Next.js Payload Starter"
echo " Astro + Tina CMS Project Creator"
echo "=============================================="
echo ""
# Parse arguments
if [ "$1" == "-h" ] || [ "$1" == "--help" ]; then
print_usage
exit 0
fi
# Run steps
check_requirements
setup_directory
copy_template
copy_consent_system
copy_legal_templates
install_dependencies
setup_environment
create_ai_rules
init_git
show_structure
echo ""
echo "=============================================="
log_success "สร้าง Next.js + Payload CMS project เสร็จสมบูรณ์!"
log_success "Project created successfully!"
echo "=============================================="
echo ""
echo "ขั้นตอนถัดไป:"
echo "Next steps:"
echo " 1. cd $PROJECT_PATH"
echo " 2. แก้ไข .env (MONGODB_URL, PAYLOAD_SECRET)"
echo " 3. npm install"
echo " 4. npm run dev"
echo " 5. เปิด http://localhost:3002/admin สำหรับ Payload admin"
echo " 2. npm run dev"
echo " 3. Open http://localhost:4321"
echo ""
echo "For Tina CMS admin:"
echo " - npm run dev"
echo " - Visit http://localhost:4321/admin"
echo ""
}
main "$@"
main "$@"

View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'tinacms';
import { schema } from './schema';
export default defineConfig({
schema,
ui: {
navigation: {
'content/posts': { label: 'Posts' },
'content/pages': { label: 'Pages' },
},
},
media: {
tina: {
publicFolder: 'public',
mediaRoot: 'uploads',
},
},
});

View File

@@ -0,0 +1,91 @@
import { defineSchema } from 'tinacms'
export const schema = defineSchema({
collections: [
{
name: 'post',
label: 'Posts',
path: 'src/content/posts',
format: 'mdx',
fields: [
{
type: 'string',
name: 'title',
label: 'Title',
required: true,
},
{
type: 'string',
name: 'description',
label: 'Description',
},
{
type: 'datetime',
name: 'publishedAt',
label: 'Published At',
},
{
type: 'string',
name: 'category',
label: 'Category',
options: ['news', 'blog', 'tutorial'],
},
{
type: 'rich-text',
name: 'body',
label: 'Body',
isBody: true,
},
],
},
{
name: 'page',
label: 'Pages',
path: 'src/content/pages',
format: 'mdx',
fields: [
{
type: 'string',
name: 'title',
label: 'Title',
required: true,
},
{
type: 'string',
name: 'description',
label: 'Description',
},
{
type: 'rich-text',
name: 'body',
label: 'Body',
isBody: true,
},
],
},
{
name: 'settings',
label: 'Settings',
path: 'src/content/settings',
format: 'json',
fields: [
{
type: 'string',
name: 'siteName',
label: 'Site Name',
},
{
type: 'string',
name: 'siteDescription',
label: 'Site Description',
},
{
type: 'string',
name: 'language',
label: 'Language',
options: ['th', 'en', 'th-en'],
},
],
},
],
})

View File

@@ -0,0 +1,198 @@
# Astro Tina Starter - Agent Knowledge Base
**Generated:** 2026-04-17
**Version:** 1.0.0
**Type:** Astro 6 + Tina CMS Starter Template
---
## OVERVIEW
Starter template for building websites with Astro 6, Tina CMS, and Tailwind CSS 4.x.
### Tech Stack
| Component | Technology | Version |
|-----------|------------|---------|
| Framework | Astro | 6.1.7 |
| CMS | Tina CMS | 2.x |
| Styling | Tailwind CSS | 4.x |
| Database | Astro DB | 0.14.x |
| State | Nano Stores | 0.11.x |
### Key Features
- Self-hosted Tina CMS with schema-based content
- Tailwind CSS 4.x using `@tailwindcss/vite` plugin
- Astro DB for consent logging (PDPA compliant)
- Thai language support with Noto Sans Thai
- Docker-ready deployment
---
## PROJECT STRUCTURE
```
astro-tina-starter/
├── .tina/
│ ├── config.ts # Tina CMS configuration
│ └── schema.ts # Content schema definitions
├── db/
│ ├── config.ts # Astro DB schema
│ └── seed.ts # Database seed script
├── src/
│ ├── styles/
│ │ └── global.css # Tailwind v4 styles + @theme
│ ├── layouts/
│ │ └── Layout.astro
│ ├── pages/
│ │ └── index.astro
│ ├── components/
│ │ └── Header.astro
│ └── content/
│ ├── config.ts # Astro content collections
│ ├── posts/ # Blog posts (MDX)
│ ├── pages/ # Static pages (MDX)
│ └── settings/ # Site settings (JSON)
├── public/
│ └── favicon.svg
├── Dockerfile
├── astro.config.mjs
├── tsconfig.json
└── package.json
```
---
## IMPORTANT CONVENTIONS
### Tailwind CSS 4.x Setup
**CRITICAL:** This template uses `@tailwindcss/vite` plugin, NOT `@astrojs/tailwind`.
```javascript
// astro.config.mjs
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
vite: {
plugins: [tailwindcss()],
},
})
```
```css
/* src/styles/global.css */
@import "tailwindcss";
@theme {
--color-primary: #1a1a1a;
--color-accent: #3b82f6;
}
```
### Tina CMS Content
Tina CMS manages content in `src/content/`:
- `posts/` - Blog posts (MDX format)
- `pages/` - Static pages (MDX format)
- `settings/` - Site settings (JSON format)
Schema defined in `.tina/schema.ts`.
### Astro DB Schema
Consent log table for PDPA compliance in `db/config.ts`.
---
## CREDENTIALS
No external API credentials required for this template.
### Optional Environment Variables
| Variable | Description |
|----------|-------------|
| `TINA_TOKEN` | Tina CMS production authentication |
| `TINA_CLIENT_ID` | Tina CMS client ID |
| `DATABASE_URL` | Custom database connection (optional) |
---
## COMMANDS
```bash
# Install dependencies
npm install
# Development
npm run dev # Full dev (Tina + Astro)
npm run dev:astro # Astro only
npm run dev:tina # Tina CMS only
# Build
npm run build # Production build
npm run preview # Preview production build
# Database
npm run db:push # Push schema to database
npm run db:seed # Seed database
```
---
## PDPA COMPLIANCE
Template includes consent logging via Astro DB:
```typescript
// db/config.ts
export const ConsentLog = defineTable({
columns: {
action: text(),
purpose: text(),
analytics: boolean(),
marketing: boolean(),
functional: boolean(),
userAgent: text(),
ip: text(),
timestamp: text(),
},
})
```
---
## ANTI-PATTERNS
- **NEVER** use `@astrojs/tailwind` (deprecated)
- **ALWAYS** use `@tailwindcss/vite` for Tailwind v4
- **NEVER** commit environment files (.env)
---
## DEPLOYMENT
### Docker
```bash
docker build -t astro-tina-starter .
docker run -p 8080:80 astro-tina-starter
```
### Manual
```bash
npm install
npm run build
# Serve dist/ folder with any static server
```
---
## NOTES
- Tina CMS admin: http://localhost:4321/admin
- Astro default port: 4321
- Tina dev server: 3001

View File

@@ -0,0 +1,19 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine AS runner
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,104 @@
# Astro Tina Starter
Astro 6.1.7 + Tina CMS starter template with Tailwind CSS 4.x
## Tech Stack
- **Framework:** Astro 6.1.7
- **CMS:** Tina CMS (self-hosted)
- **Styling:** Tailwind CSS 4.x with `@tailwindcss/vite`
- **Database:** Astro DB (LibSQL)
- **State:** Nano Stores + React
- **Language:** TypeScript
## Features
- Self-hosted Tina CMS with schema-based content
- Tailwind CSS 4.x using `@tailwindcss/vite` plugin
- Astro DB for consent logging (PDPA compliant)
- Nano Stores for client-side state management
- Thai language support foundation
- Docker-ready deployment
## Quick Start
```bash
# Install dependencies
npm install
# Start development
npm run dev
# Build for production
npm run build
```
## Tina CMS Access
During development, access Tina CMS at:
- http://localhost:4321/admin
For production, you'll need a TINA_TOKEN environment variable.
## Project Structure
```
astro-tina-starter/
├── .tina/
│ ├── config.ts # Tina CMS configuration
│ └── schema.ts # Content schema definitions
├── db/
│ ├── config.ts # Astro DB schema (consent logs)
│ └── seed.ts # Database seed script
├── src/
│ ├── styles/
│ │ └── global.css # Tailwind v4 styles
│ ├── layouts/
│ │ └── Layout.astro
│ ├── pages/
│ │ └── index.astro
│ ├── components/
│ │ └── Header.astro
│ └── content/
│ └── config.ts # Tina content collections
├── Dockerfile
└── package.json
```
## Tailwind CSS 4.x
This template uses Tailwind CSS 4.x with the `@tailwindcss/vite` plugin.
The configuration is done via CSS `@theme` block in `src/styles/global.css`.
```css
@import "tailwindcss";
@theme {
--color-primary: #1a1a1a;
--color-accent: #3b82f6;
}
```
## Astro DB
The template includes a consent-log table for PDPA compliance:
```ts
// db/config.ts
export const ConsentLog = defineTable({
columns: {
action: text(),
purpose: text(),
analytics: boolean(),
marketing: boolean(),
functional: boolean(),
userAgent: text(),
ip: text(),
timestamp: text(),
},
})
```
## License
MIT

View File

@@ -0,0 +1,37 @@
import { defineConfig } from 'astro/config'
import tailwindcss from '@tailwindcss/vite'
import tina from 'tinacms'
import { fileURLToPath } from 'url'
import path from 'path'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
export default defineConfig({
integrations: [
tina({
enabled: !!process.env.TINA_TOKEN,
sidebar: {
partials: [],
},
}),
],
vite: {
plugins: [tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@components': path.resolve(__dirname, './src/components'),
'@layouts': path.resolve(__dirname, './src/layouts'),
'@styles': path.resolve(__dirname, './src/styles'),
'@content': path.resolve(__dirname, './src/content'),
},
},
},
output: 'static',
build: {
assets: '_assets',
},
server: {
port: 4321,
},
})

View File

@@ -0,0 +1,22 @@
import { defineDb, defineTable, column } from 'astro:db';
const ConsentLog = defineTable({
columns: {
id: column.number({ primaryKey: true }),
action: column.text(),
purpose: column.text(),
analytics: column.boolean({ default: false }),
marketing: column.boolean({ default: false }),
functional: column.boolean({ default: false }),
userAgent: column.text({ optional: true }),
ip: column.text({ optional: true }),
timestamp: column.date(),
sessionId: column.text({ optional: true }),
},
});
export default defineDb({
tables: {
ConsentLog,
},
});

View File

@@ -0,0 +1,7 @@
import { db } from 'astro:db'
import { sql } from 'astro/db'
export default async function seed() {
// Seed default settings if needed
console.log('Database seeded successfully')
}

View File

@@ -0,0 +1,38 @@
{
"name": "astro-tina-starter",
"type": "module",
"version": "1.0.0",
"description": "Astro 6 + Tina CMS starter template with Tailwind CSS 4.x",
"scripts": {
"dev": "tinacms dev --port 3001 & astro dev",
"dev:astro": "astro dev",
"dev:tina": "tinacms dev --port 3001",
"build": "tinacms build && astro build",
"preview": "astro preview",
"astro": "astro",
"db:push": "astro db push",
"db:seed": "astro db seed"
},
"dependencies": {
"@astrojs/check": "^0.9.4",
"@astrojs/db": "^0.14.3",
"@nanostores/react": "^0.7.3",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0",
"astro": "^6.1.7",
"nanostores": "^0.11.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwindcss": "^4.0.0",
"tina": "^2.1.4",
"tinacms": "^2.2.4",
"typescript": "^5.6.3"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1"
},
"engines": {
"node": ">=20.0.0"
}
}

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#3b82f6"/>
<stop offset="100%" style="stop-color:#1d4ed8"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="6" fill="url(#grad)"/>
<text x="16" y="22" font-family="Arial, sans-serif" font-size="16" font-weight="bold" fill="white" text-anchor="middle">A</text>
</svg>

After

Width:  |  Height:  |  Size: 473 B

View File

@@ -0,0 +1,27 @@
---
interface Props {
siteName?: string
}
const { siteName = "Astro Tina Starter" } = Astro.props
---
<header class="sticky top-0 z-50 bg-white/80 backdrop-blur-md border-b border-primary-200">
<nav class="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
<a href="/" class="font-bold text-xl text-primary-900 hover:text-accent-600 transition-colors">
{siteName}
</a>
<div class="flex items-center gap-6">
<a href="/" class="text-primary-600 hover:text-primary-900 transition-colors">
Home
</a>
<a href="/blog" class="text-primary-600 hover:text-primary-900 transition-colors">
Blog
</a>
<a href="/about" class="text-primary-600 hover:text-primary-900 transition-colors">
About
</a>
</div>
</nav>
</header>

View File

@@ -0,0 +1,34 @@
import { defineCollection, z } from "astro:content"
const postCollection = defineCollection({
type: "content",
schema: z.object({
title: z.string(),
description: z.string().optional(),
publishedAt: z.date().optional(),
category: z.enum(["news", "blog", "tutorial"]).optional(),
}),
})
const pageCollection = defineCollection({
type: "content",
schema: z.object({
title: z.string(),
description: z.string().optional(),
}),
})
const settingsCollection = defineCollection({
type: "data",
schema: z.object({
siteName: z.string(),
siteDescription: z.string(),
language: z.enum(["th", "en", "th-en"]).default("th"),
}),
})
export const collections = {
posts: postCollection,
pages: pageCollection,
settings: settingsCollection,
}

View File

@@ -0,0 +1,17 @@
---
title: Welcome to Astro Tina Starter
description: A modern starter template with Astro 6, Tina CMS, and Thai language support.
publishedAt: 2026-04-17
category: blog
---
Welcome to our new blog built with Astro and Tina CMS!
## Features
- **Tina CMS** - Self-hosted content management
- **Tailwind CSS v4** - Latest styling with @tailwindcss/vite
- **Astro DB** - Built-in database support
- **Thai Support** - Ready for Thai language content
Stay tuned for more updates!

View File

@@ -0,0 +1,5 @@
{
"siteName": "Astro Tina Starter",
"siteDescription": "Astro 6 + Tina CMS starter template with Thai language support",
"language": "th"
}

View File

@@ -0,0 +1,27 @@
---
import "@/styles/global.css"
interface Props {
title?: string
description?: string
}
const {
title = "Astro Tina Starter",
description = "Astro 6 + Tina CMS starter template",
} = Astro.props
---
<!doctype html>
<html lang="th">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title}</title>
</head>
<body class="bg-primary-50 text-primary-900 min-h-screen">
<slot />
</body>
</html>

View File

@@ -0,0 +1,47 @@
---
import Layout from "@/layouts/Layout.astro"
---
<Layout>
<main>
<section class="px-6 py-24 max-w-4xl mx-auto">
<h1 class="text-4xl md:text-5xl font-bold tracking-tight mb-6">
Welcome to Astro Tina Starter
</h1>
<p class="text-lg text-primary-600 mb-8 max-w-2xl">
A modern starter template with Astro 6, Tina CMS, Tailwind CSS 4.x,
and Thai language support.
</p>
<div class="grid gap-6 md:grid-cols-2">
<div class="p-6 bg-white rounded-xl border border-primary-200">
<h2 class="text-xl font-semibold mb-3">Tina CMS</h2>
<p class="text-primary-600">
Self-hosted content management with schema-based editing.
</p>
</div>
<div class="p-6 bg-white rounded-xl border border-primary-200">
<h2 class="text-xl font-semibold mb-3">Tailwind v4</h2>
<p class="text-primary-600">
Latest Tailwind CSS with @tailwindcss/vite plugin.
</p>
</div>
<div class="p-6 bg-white rounded-xl border border-primary-200">
<h2 class="text-xl font-semibold mb-3">Astro DB</h2>
<p class="text-primary-600">
Built-in database for consent logging and more.
</p>
</div>
<div class="p-6 bg-white rounded-xl border border-primary-200">
<h2 class="text-xl font-semibold mb-3">Thai Support</h2>
<p class="text-primary-600">
Ready for Thai language content with Noto Sans Thai.
</p>
</div>
</div>
</section>
</main>
</Layout>

View File

@@ -0,0 +1,57 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@theme {
--font-sans: "Inter", "Noto Sans Thai", system-ui, sans-serif;
--font-serif: "Merriweather", Georgia, serif;
--color-primary-50: #f8fafc;
--color-primary-100: #f1f5f9;
--color-primary-200: #e2e8f0;
--color-primary-300: #cbd5e1;
--color-primary-400: #94a3b8;
--color-primary-500: #64748b;
--color-primary-600: #475569;
--color-primary-700: #334155;
--color-primary-800: #1e293b;
--color-primary-900: #0f172a;
--color-primary-950: #020617;
--color-accent-50: #eff6ff;
--color-accent-100: #dbeafe;
--color-accent-200: #bfdbfe;
--color-accent-300: #93c5fd;
--color-accent-400: #60a5fa;
--color-accent-500: #3b82f6;
--color-accent-600: #2563eb;
--color-accent-700: #1d4ed8;
--color-accent-800: #1e40af;
--color-accent-900: #1e3a8a;
--color-success-500: #22c55e;
--color-warning-500: #f59e0b;
--color-error-500: #ef4444;
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-2xl: 1.5rem;
--radius-full: 9999px;
}
html {
scroll-behavior: smooth;
}
body {
font-family: var(--font-sans);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
::selection {
background-color: var(--color-accent-200);
color: var(--color-primary-900);
}

View File

@@ -0,0 +1,17 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@components/*": ["./src/components/*"],
"@layouts/*": ["./src/layouts/*"],
"@styles/*": ["./src/styles/*"],
"@content/*": ["./src/content/*"]
},
"jsx": "react-jsx",
"jsxImportSource": "react"
},
"include": ["src/**/*", ".tina/**/*", "db/**/*"],
"exclude": ["node_modules", "dist", ".astro"]
}

View File

@@ -0,0 +1,447 @@
---
/**
* PDPA Consent Banner Component for Astro + Tina
* Replaces cookie-banner.tsx from Next.js+Payload
*
* Usage: Import and add <ConsentBanner /> to your layout
*/
interface Props {
/** Optional: Custom privacy policy URL */
privacyPolicyUrl?: string;
}
const { privacyPolicyUrl = "/privacy-policy" } = Astro.props;
---
<div
id="pdpa-consent-banner"
class="consent-banner"
role="dialog"
aria-label="Cookie Consent Banner"
aria-hidden="true"
>
<div class="consent-banner__content">
<!-- Main Banner -->
<div id="consent-main" class="consent-banner__main">
<h3 class="consent-banner__title">
🍪 การยินยอมตาม พ.ร.บ.คุ้มครองข้อมูลส่วนบุคคล
</h3>
<p class="consent-banner__text">
เราใช้คุกกี้เพื่อปรับปรุงประสบการณ์การใช้งานเว็บไซต์ของคุณ การเข้าชมเว็บไซต์ต่อถือว่าคุณยินยอมให้เราใช้คุกกี้{' '}
<a href={privacyPolicyUrl} class="consent-banner__link">เรียนรู้เพิ่มเติม</a>
</p>
<div class="consent-banner__buttons">
<button
id="consent-accept-all"
class="consent-btn consent-btn--accept"
type="button"
>
ยอมรับทั้งหมด
</button>
<button
id="consent-reject-all"
class="consent-btn consent-btn--reject"
type="button"
>
ปฏิเสธทั้งหมด
</button>
<button
id="consent-show-preferences"
class="consent-btn consent-btn--preferences"
type="button"
>
ตั้งค่าคุกกี้
</button>
</div>
</div>
<!-- Preferences Panel -->
<div id="consent-preferences" class="consent-banner__preferences" style="display: none;">
<h3 class="consent-banner__title">ตั้งค่าคุกกี้</h3>
<p class="consent-banner__text" style="margin-bottom: 1rem; color: #555; font-size: 0.875rem;">
จัดการการตั้งค่าคุกกี้ของคุณด้านล่าง
</p>
<div class="consent-banner__options">
<!-- Functional Cookies -->
<div class="consent-option consent-option--disabled">
<div class="consent-option__header">
<div>
<h4 class="consent-option__title">คุกกี้ที่จำเป็น</h4>
<p class="consent-option__desc">
จำเป็นสำหรับการทำงานของเว็บไซต์ ไม่สามารปิดได้
</p>
</div>
<span class="consent-option__badge">เปิดอยู่เสมอ</span>
</div>
</div>
<!-- Analytics Cookies -->
<div class="consent-option">
<div class="consent-option__header">
<div>
<h4 class="consent-option__title">คุกกี้วิเคราะห์</h4>
<p class="consent-option__desc">
ช่วยเราเข้าใจว่าผู้เยี่ยมชมใช้งานเว็บไซต์ของเราอย่างไร
</p>
</div>
<label class="consent-checkbox">
<input
type="checkbox"
id="consent-analytics"
name="analytics"
class="consent-checkbox__input"
/>
</label>
</div>
</div>
<!-- Marketing Cookies -->
<div class="consent-option">
<div class="consent-option__header">
<div>
<h4 class="consent-option__title">คุกกี้การตลาด</h4>
<p class="consent-option__desc">
ใช้ติดตามผู้เยี่ยมชมข้ามเว็บไซต์เพื่อการโฆษณา
</p>
</div>
<label class="consent-checkbox">
<input
type="checkbox"
id="consent-marketing"
name="marketing"
class="consent-checkbox__input"
/>
</label>
</div>
</div>
</div>
<div class="consent-banner__buttons">
<button
id="consent-save-preferences"
class="consent-btn consent-btn--save"
type="button"
>
บันทึกการตั้งค่า
</button>
<button
id="consent-back"
class="consent-btn consent-btn--back"
type="button"
>
กลับ
</button>
</div>
</div>
</div>
</div>
<style>
.consent-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: #ffffff;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.15);
padding: 1.5rem;
z-index: 9999;
border-top: 1px solid #e5e5e5;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.consent-banner__content {
max-width: 1200px;
margin: 0 auto;
}
.consent-banner__title {
margin: 0 0 0.75rem 0;
font-size: 1.125rem;
font-weight: 600;
color: #1a1a1a;
}
.consent-banner__text {
margin: 0 0 1rem 0;
color: #555;
font-size: 0.9375rem;
line-height: 1.5;
}
.consent-banner__link {
color: #0066cc;
text-decoration: underline;
}
.consent-banner__link:hover {
color: #004499;
}
.consent-banner__buttons {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.consent-btn {
padding: 0.625rem 1.25rem;
border-radius: 6px;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.consent-btn--accept {
background-color: #22c55e;
color: white;
border: none;
}
.consent-btn--accept:hover {
background-color: #16a34a;
}
.consent-btn--reject {
background-color: #f5f5f5;
color: #333;
border: 1px solid #ddd;
}
.consent-btn--reject:hover {
background-color: #e5e5e5;
}
.consent-btn--preferences {
background-color: transparent;
color: #0066cc;
border: 1px solid #0066cc;
}
.consent-btn--preferences:hover {
background-color: #f0f9ff;
}
.consent-btn--save {
background-color: #0066cc;
color: white;
border: none;
}
.consent-btn--save:hover {
background-color: #004499;
}
.consent-btn--back {
background-color: transparent;
color: #666;
border: none;
}
.consent-btn--back:hover {
color: #333;
}
/* Preferences Panel */
.consent-banner__options {
margin-bottom: 1rem;
}
.consent-option {
padding: 1rem;
background-color: #fff;
border-radius: 8px;
margin-bottom: 0.75rem;
border: 1px solid #e5e5e5;
}
.consent-option--disabled {
background-color: #f9f9f9;
opacity: 0.7;
}
.consent-option__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.consent-option__title {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: #1a1a1a;
}
.consent-option__desc {
margin: 0.25rem 0 0 0;
font-size: 0.8125rem;
color: #666;
}
.consent-option__badge {
padding: 0.25rem 0.75rem;
background-color: #e5e5e5;
color: #666;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
}
.consent-checkbox__input {
width: 18px;
height: 18px;
cursor: pointer;
}
/* Hide initially via JS */
.consent-banner[hidden] {
display: none;
}
</style>
<script>
import { consentStore, type ConsentState } from './stores/consent';
// DOM Elements
const banner = document.getElementById('pdpa-consent-banner');
const mainPanel = document.getElementById('consent-main');
const prefsPanel = document.getElementById('consent-preferences');
const analyticsCheckbox = document.getElementById('consent-analytics') as HTMLInputElement;
const marketingCheckbox = document.getElementById('consent-marketing') as HTMLInputElement;
// Button handlers
const acceptAllBtn = document.getElementById('consent-accept-all');
const rejectAllBtn = document.getElementById('consent-reject-all');
const showPrefsBtn = document.getElementById('consent-show-preferences');
const savePrefsBtn = document.getElementById('consent-save-preferences');
const backBtn = document.getElementById('consent-back');
// Default consent state
const defaultConsent: ConsentState = {
analytics: false,
marketing: false,
functional: false,
hasConsented: false,
};
const STORAGE_KEY = 'pdpa_consent';
// Save consent to localStorage and server
async function saveConsent(newConsent: ConsentState) {
// Save to localStorage
localStorage.setItem(STORAGE_KEY, JSON.stringify(newConsent));
// Update nanostore
consentStore.set(newConsent);
// Hide banner
if (banner) {
banner.setAttribute('hidden', 'true');
}
// Log to server
try {
await fetch('/api/consent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: newConsent.hasConsented ? 'accept' : 'reject',
purpose: 'all',
analytics: newConsent.analytics,
marketing: newConsent.marketing,
functional: newConsent.functional,
}),
});
} catch (error) {
console.error('Failed to log consent:', error);
}
}
// Accept all cookies
acceptAllBtn?.addEventListener('click', () => {
saveConsent({
analytics: true,
marketing: true,
functional: true,
hasConsented: true,
timestamp: new Date().toISOString(),
});
});
// Reject all cookies
rejectAllBtn?.addEventListener('click', () => {
saveConsent({
analytics: false,
marketing: false,
functional: false,
hasConsented: true,
timestamp: new Date().toISOString(),
});
});
// Show preferences panel
showPrefsBtn?.addEventListener('click', () => {
if (mainPanel && prefsPanel) {
mainPanel.style.display = 'none';
prefsPanel.style.display = 'block';
}
});
// Save custom preferences
savePrefsBtn?.addEventListener('click', () => {
saveConsent({
analytics: analyticsCheckbox?.checked ?? false,
marketing: marketingCheckbox?.checked ?? false,
functional: true, // Always on
hasConsented: true,
timestamp: new Date().toISOString(),
});
});
// Back to main panel
backBtn?.addEventListener('click', () => {
if (mainPanel && prefsPanel) {
prefsPanel.style.display = 'none';
mainPanel.style.display = 'block';
}
});
// Check for existing consent on load
function initBanner() {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
const parsed = JSON.parse(stored);
// Already consented - hide banner
if (banner) {
banner.setAttribute('hidden', 'true');
}
// Sync with nanostore
consentStore.set(parsed);
} catch {
// No valid consent - show banner
if (banner) {
banner.removeAttribute('hidden');
}
}
} else {
// No consent yet - show banner
if (banner) {
banner.removeAttribute('hidden');
}
}
}
// Initialize on page load
initBanner();
</script>

View File

@@ -1,61 +1,70 @@
# PDPA Consent Logging Template
Template สำหรับเพิ่ม PDPA consent logging ใน Next.js + Payload CMS (MongoDB)
Template สำหรับเพิ่ม PDPA consent logging ใน Astro + Tina (Astro DB)
## Files
```
consent/
├── collections/
│ └── ConsentLogs.ts # Payload collection สำหรับ consent logs
├── ConsentBanner.astro # Consent banner component
├── api/
│ └── route.ts # API endpoint สำหรับบันทึก consent
├── cookie-banner.tsx # CookieBanner component
└── README.md
│ └── consent.ts # API endpoints (GET, POST, DELETE)
├── db/
│ └── config.ts # Astro DB schema (defineTable)
├── stores/
│ └── consent.ts # Nano Stores for client state
└── README.md # This file
```
## วิธีใช้
## วิธีใช้ (Astro)
### 1. เพิ่ม ConsentLogs Collection
### 1. เพิ่ม Astro DB Schema
Copy `collections/ConsentLogs.ts` ไปที่ `src/collections/` ของ project
Copy `db/config.ts` ไปที่ `src/db/config.ts`:
```ts
// src/db/config.ts
import { defineTable, column } from 'astro:db';
export const ConsentLog = defineTable({
columns: {
id: column.number({ primaryKey: true }),
action: column.text(),
purpose: column.text(),
analytics: column.boolean({ default: false }),
marketing: column.boolean({ default: false }),
functional: column.boolean({ default: false }),
userAgent: column.text({ optional: true }),
ip: column.text({ optional: true }),
timestamp: column.date(),
sessionId: column.text({ optional: true }),
},
});
```
### 2. สร้าง API Endpoint
Copy `api/route.ts` ไปที่ `src/app/api/consent/route.ts`
Copy `api/consent.ts` ไปที่ `src/pages/api/consent.ts`
### 3. เพิ่ม CookieBanner Component
### 3. เพิ่ม ConsentBanner Component
Copy `cookie-banner.tsx` ไปที่ `src/components/`
Copy `ConsentBanner.astro` ไปที่ `src/components/consent/ConsentBanner.astro`
### 4. เพิ่มใน Layout
เพิ่ม `<CookieBanner />` ใน `src/app/(frontend)/layout.tsx`:
เพิ่ม `<ConsentBanner />` ใน `src/layouts/Layout.astro`:
```tsx
import { CookieBanner } from '@/components/cookie-banner'
```astro
---
import ConsentBanner from '../components/consent/ConsentBanner.astro';
---
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<CookieBanner />
</body>
</html>
)
}
```
### 5. เพิ่ม Collection ใน payload.config.ts
```ts
import ConsentLogs from './collections/ConsentLogs'
export default buildConfig({
collections: [Users, Media, Snacks, Orders, ConsentLogs],
// ...
})
<html lang="th">
<body>
<slot />
<ConsentBanner />
</body>
</html>
```
## API
@@ -80,7 +89,7 @@ export default buildConfig({
{
"success": true,
"doc": {
"id": "...",
"id": 1,
"action": "accept",
"purpose": "all",
"analytics": true,
@@ -93,7 +102,46 @@ export default buildConfig({
}
```
### GET /api/consent
ดึง consent logs
```bash
curl "http://localhost:4321/api/consent"
```
### DELETE /api/consent
Right to be forgotten (ลบข้อมูลตาม พ.ร.บ.)
```bash
curl -X DELETE "http://localhost:4321/api/consent?sessionId=xxx"
```
## Nano Stores Usage
```ts
import { consentStore, hasAnalyticsConsent, hasMarketingConsent } from './stores/consent';
// Subscribe to changes
consentStore.subscribe((state) => {
console.log('Consent changed:', state);
});
// Check consent
if (hasAnalyticsConsent()) {
// Load analytics
}
```
## UX
- **ยอมรับทั้งหมด** - เปิดทุกคุกกี้
- **ปฏิเสธทั้งหมด** - ปิดทุกคุกกี้ (ยกเว้น functional)
- **ตั้งค่าคุกกี้** - แผงปรับแต่งเอง
## ⚠️ Pitfalls สำคัญ
1. **ใช้ `mongooseAdapter` ไม่ใช่ `mongodbAdapter`**
2. **ConsentLogs ต้องใช้ `export default`** ไม่ใช่ named export
1. **Astro DB ต้องรันบน server-side** - ใช้ `APIRoute` import
2. **Nano Stores รันบน client-side** - ใช้ `<script>` tag ใน Astro
3. **import ถูกต้อง** - ใช้ `import { db } from 'astro:db'` ไม่ใช่ `defineDb`

View File

@@ -0,0 +1,120 @@
import type { APIRoute } from 'astro';
import { db, eq } from 'astro:db';
import { ConsentLog } from '../db/config';
export const POST: APIRoute = async ({ request, clientAddress }) => {
try {
const body = await request.json();
const {
action = 'accept',
purpose = 'all',
analytics = false,
marketing = false,
functional = false,
} = body;
const ip = clientAddress || 'unknown';
const userAgent = request.headers.get('user-agent') || 'unknown';
const doc = await db.insert(ConsentLog).values({
action,
purpose,
analytics,
marketing,
functional,
ip,
userAgent,
timestamp: new Date(),
});
return new Response(JSON.stringify({
success: true,
doc,
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('Consent API error:', error);
return new Response(JSON.stringify({
success: false,
error: 'Failed to log consent',
}), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
};
export const GET: APIRoute = async ({ request }) => {
try {
const url = new URL(request.url);
const sessionId = url.searchParams.get('sessionId');
let docs;
if (sessionId) {
docs = await db.select().from(ConsentLog).where(
eq(ConsentLog.sessionId, sessionId)
);
} else {
docs = await db.select().from(ConsentLog);
}
return new Response(JSON.stringify({
success: true,
docs,
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('Consent GET error:', error);
return new Response(JSON.stringify({
success: false,
error: 'Failed to retrieve consent logs',
}), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
};
export const DELETE: APIRoute = async ({ request }) => {
try {
const url = new URL(request.url);
const sessionId = url.searchParams.get('sessionId');
if (!sessionId) {
return new Response(JSON.stringify({
success: false,
error: 'sessionId is required',
}), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const deleted = await db.delete(ConsentLog).where(
eq(ConsentLog.sessionId, sessionId)
);
return new Response(JSON.stringify({
success: true,
deleted,
message: 'All consent records for this session have been deleted',
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('Right to be forgotten error:', error);
return new Response(JSON.stringify({
success: false,
error: 'Failed to delete consent records',
}), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
};

View File

@@ -1,39 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@/payload.config'
/**
* DELETE /api/consent - Right to be forgotten (GDPR/PDPA)
*
* Deletes all consent records for a given session or user
*/
export async function DELETE(request: NextRequest) {
try {
const payloadConfig = await config
const payload = await getPayload({ config: payloadConfig })
const { searchParams } = new URL(request.url)
const sessionId = searchParams.get('sessionId')
if (!sessionId) {
return NextResponse.json({ error: 'sessionId is required' }, { status: 400 })
}
// Find and delete all consent logs for this session
const result = await payload.delete({
collection: 'consent-logs',
where: {
sessionId: { equals: sessionId },
},
})
return NextResponse.json({
success: true,
deleted: result.deletedDocs?.length || 0,
message: 'All consent records for this session have been deleted'
})
} catch (error) {
console.error('Right to be forgotten error:', error)
return NextResponse.json({ error: 'Failed to delete consent records' }, { status: 500 })
}
}

View File

@@ -1,188 +0,0 @@
import { CollectionConfig, Field } from 'payload'
// Consent Log Collection - เก็บ log การยินยอมของ users
export const ConsentLog: CollectionConfig = {
slug: 'consent-logs',
admin: {
useAsTitle: 'sessionId',
defaultColumns: ['sessionId', 'consentType', 'granted', 'createdAt'],
description: 'บันทึกการยินยอมของผู้ใช้ตาม PDPA',
},
access: {
// ทุกคนสามารถสร้าง log ได้ (public)
create: () => true,
// แต่ดูได้เฉพาะ admin
read: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin'
},
// แก้ไขได้เฉพาะ admin
update: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin'
},
// ลบได้เฉพาะ admin
delete: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin'
},
},
fields: [
{
name: 'sessionId',
type: 'text',
required: true,
admin: {
description: 'Session ID ของผู้ใช้',
},
},
{
name: 'consentType',
type: 'select',
required: true,
options: [
{ label: 'Essential', value: 'essential' },
{ label: 'Analytics', value: 'analytics' },
{ label: 'Marketing', value: 'marketing' },
{ label: 'Functional', value: 'functional' },
{ label: 'All Accepted', value: 'accept_all' },
{ label: 'All Rejected', value: 'reject_all' },
],
},
{
name: 'granted',
type: 'checkbox',
required: true,
defaultValue: false,
admin: {
description: 'ยินยอมหรือไม่',
},
},
{
name: 'ipAddress',
type: 'text',
admin: {
description: 'IP Address ของผู้ใช้',
readOnly: true,
},
},
{
name: 'userAgent',
type: 'text',
admin: {
description: 'Browser User Agent',
readOnly: true,
},
},
{
name: 'metadata',
type: 'json',
admin: {
description: 'ข้อมูลเพิ่มเติม',
},
},
{
name: 'createdAt',
type: 'date',
required: true,
admin: {
description: 'วันที่และเวลาที่ยินยอม',
readOnly: true,
},
},
],
hooks: {
beforeChange: [
({ data }) => {
// เพิ่ม timestamp อัตโนมัติ
if (!data.createdAt) {
data.createdAt = new Date().toISOString()
}
return data
},
],
},
}
// Consent Settings Collection - เก็บ settings ของ consent banner
export const ConsentSettings: CollectionConfig = {
slug: 'consent-settings',
admin: {
useAsTitle: 'title',
description: 'ตั้งค่า Cookie Consent Banner',
},
access: {
read: () => true, // Public read
create: ({ req: { user } }) => !!user && user.role === 'admin',
update: ({ req: { user } }) => !!user && user.role === 'admin',
delete: ({ req: { user } }) => !!user && user.role === 'admin',
},
fields: [
{
name: 'title',
type: 'text',
required: true,
defaultValue: 'นโยบายคุกกี้',
},
{
name: 'description',
type: 'textarea',
required: true,
defaultValue: 'เราใช้คุกกี้เพื่อปรับปรุงประสบการณ์การใช้งานเว็บไซต์ของคุณ คุณสามารถเลือกได้ว่าจะอนุญาตคุกกี้ประเภทใด',
},
{
name: 'position',
type: 'select',
defaultValue: 'bottom',
options: [
{ label: 'ด้านล่าง (Bottom)', value: 'bottom' },
{ label: 'ด้านบน (Top)', value: 'top' },
],
},
{
name: 'theme',
type: 'select',
defaultValue: 'light',
options: [
{ label: 'Light Mode', value: 'light' },
{ label: 'Dark Mode', value: 'dark' },
],
},
{
name: 'essentialCookies',
type: 'json',
admin: {
description: 'รายชื่อ essential cookies ที่จำเป็นต้องมี',
},
},
{
name: 'analyticsCookies',
type: 'json',
admin: {
description: 'รายชื่อ analytics cookies',
},
},
{
name: 'marketingCookies',
type: 'json',
admin: {
description: 'รายชื่อ marketing cookies',
},
},
{
name: 'functionalCookies',
type: 'json',
admin: {
description: 'รายชื่อ functional cookies',
},
},
{
name: 'isActive',
type: 'checkbox',
defaultValue: true,
admin: {
description: 'แสดง consent banner หรือไม่',
},
},
],
}

View File

@@ -1,122 +0,0 @@
import type { CollectionConfig } from 'payload'
export interface ConsentLogData {
action: 'accept' | 'reject' | 'update'
purpose: 'analytics' | 'marketing' | 'functional' | 'all'
userAgent?: string
ip?: string
timestamp: string
previousConsent?: Record<string, boolean>
newConsent?: Record<string, boolean>
}
const ConsentLogs: CollectionConfig = {
slug: 'consent-logs',
admin: {
useAsTitle: 'timestamp',
defaultColumns: ['timestamp', 'action', 'purpose', 'ip'],
description: 'Log of all consent actions for PDPA compliance',
},
access: {
create: () => true, // Allow anyone to create consent logs (public endpoint)
read: () => true, // Allow reading for compliance purposes
update: () => false, // Consent logs should not be modified
delete: () => false, // Consent logs should not be deleted
},
fields: [
{
name: 'action',
type: 'select',
required: true,
options: [
{ label: 'Accept', value: 'accept' },
{ label: 'Reject', value: 'reject' },
{ label: 'Update', value: 'update' },
],
admin: {
description: 'The type of consent action',
},
},
{
name: 'purpose',
type: 'select',
required: true,
options: [
{ label: 'Analytics', value: 'analytics' },
{ label: 'Marketing', value: 'marketing' },
{ label: 'Functional', value: 'functional' },
{ label: 'All', value: 'all' },
],
admin: {
description: 'The purpose of the consent',
},
},
{
name: 'analytics',
type: 'checkbox',
defaultValue: false,
admin: {
description: 'Consent for analytics cookies',
},
},
{
name: 'marketing',
type: 'checkbox',
defaultValue: false,
admin: {
description: 'Consent for marketing cookies',
},
},
{
name: 'functional',
type: 'checkbox',
defaultValue: false,
admin: {
description: 'Consent for functional cookies',
},
},
{
name: 'userAgent',
type: 'text',
admin: {
readOnly: true,
description: 'Browser user agent string',
},
},
{
name: 'ip',
type: 'text',
admin: {
readOnly: true,
description: 'IP address of the user',
},
},
{
name: 'timestamp',
type: 'date',
required: true,
admin: {
readOnly: true,
description: 'When the consent was given',
},
},
{
name: 'previousConsent',
type: 'json',
admin: {
readOnly: true,
description: 'Previous consent state (for updates)',
},
},
{
name: 'newConsent',
type: 'json',
admin: {
readOnly: true,
description: 'New consent state',
},
},
],
}
export default ConsentLogs

View File

@@ -1,316 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
interface ConsentState {
analytics: boolean
marketing: boolean
functional: boolean
hasConsented: boolean
timestamp?: string
}
const defaultConsent: ConsentState = {
analytics: false,
marketing: false,
functional: false,
hasConsented: false,
}
const STORAGE_KEY = 'pdpa_consent'
export function CookieBanner() {
const [consent, setConsent] = useState<ConsentState>(defaultConsent)
const [showBanner, setShowBanner] = useState(false)
const [showPreferences, setShowPreferences] = useState(false)
// Load consent from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
try {
const parsed = JSON.parse(stored)
setConsent(parsed)
setShowBanner(false)
} catch {
setShowBanner(true)
}
} else {
setShowBanner(true)
}
}, [])
// Save consent to localStorage
const saveConsent = async (newConsent: ConsentState) => {
// Save to localStorage
localStorage.setItem(STORAGE_KEY, JSON.stringify(newConsent))
setConsent(newConsent)
setShowBanner(false)
setShowPreferences(false)
// Log to server
try {
await fetch('/api/consent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: newConsent.hasConsented ? 'accept' : 'reject',
purpose: 'all',
...newConsent,
}),
})
} catch (error) {
console.error('Failed to log consent:', error)
}
}
// Accept all cookies
const acceptAll = () => {
saveConsent({
analytics: true,
marketing: true,
functional: true,
hasConsented: true,
timestamp: new Date().toISOString(),
})
}
// Reject all cookies (only functional)
const rejectAll = () => {
saveConsent({
analytics: false,
marketing: false,
functional: false,
hasConsented: true,
timestamp: new Date().toISOString(),
})
}
// Save custom preferences
const savePreferences = () => {
saveConsent({
...consent,
hasConsented: true,
timestamp: new Date().toISOString(),
})
}
// Update individual preference
const updatePreference = (key: keyof Pick<ConsentState, 'analytics' | 'marketing' | 'functional'>, value: boolean) => {
setConsent(prev => ({ ...prev, [key]: value }))
}
// If no banner to show, return null
if (!showBanner) return null
return (
<div
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
backgroundColor: '#ffffff',
boxShadow: '0 -4px 20px rgba(0, 0, 0, 0.15)',
padding: '1.5rem',
zIndex: 9999,
borderTop: '1px solid #e5e5e5',
}}
role="dialog"
aria-label="Cookie Consent Banner"
>
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
{!showPreferences ? (
// Main banner
<div>
<h3 style={{ margin: '0 0 0.75rem 0', fontSize: '1.125rem', fontWeight: 600 }}>
🍪 PDPA Cookie Consent
</h3>
<p style={{ margin: '0 0 1rem 0', color: '#555', fontSize: '0.9375rem', lineHeight: 1.5 }}>
We use cookies to enhance your experience. By continuing to visit this site, you agree to our use of cookies.{' '}
<a href="/privacy-policy" style={{ color: '#0066cc' }}>
Learn more
</a>
</p>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button
onClick={acceptAll}
style={{
padding: '0.625rem 1.25rem',
backgroundColor: '#22c55e',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '0.9375rem',
fontWeight: 500,
cursor: 'pointer',
}}
>
Accept All Cookies
</button>
<button
onClick={rejectAll}
style={{
padding: '0.625rem 1.25rem',
backgroundColor: '#f5f5f5',
color: '#333',
border: '1px solid #ddd',
borderRadius: '6px',
fontSize: '0.9375rem',
fontWeight: 500,
cursor: 'pointer',
}}
>
Reject All
</button>
<button
onClick={() => setShowPreferences(true)}
style={{
padding: '0.625rem 1.25rem',
backgroundColor: 'transparent',
color: '#0066cc',
border: '1px solid #0066cc',
borderRadius: '6px',
fontSize: '0.9375rem',
fontWeight: 500,
cursor: 'pointer',
}}
>
Cookie Preferences
</button>
</div>
</div>
) : (
// Preferences panel
<div>
<h3 style={{ margin: '0 0 0.75rem 0', fontSize: '1.125rem', fontWeight: 600 }}>
Cookie Preferences
</h3>
<p style={{ margin: '0 0 1rem 0', color: '#555', fontSize: '0.875rem' }}>
Manage your cookie preferences below.
</p>
<div style={{ marginBottom: '1rem' }}>
{/* Functional Cookies */}
<div style={{
padding: '1rem',
backgroundColor: '#f9f9f9',
borderRadius: '8px',
marginBottom: '0.75rem',
border: '1px solid #e5e5e5'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.5rem' }}>
<div>
<h4 style={{ margin: 0, fontSize: '0.9375rem', fontWeight: 600 }}>Functional Cookies</h4>
<p style={{ margin: '0.25rem 0 0 0', fontSize: '0.8125rem', color: '#666' }}>
Essential for the website to function properly. Cannot be disabled.
</p>
</div>
<div style={{
padding: '0.25rem 0.75rem',
backgroundColor: '#e5e5e5',
color: '#666',
borderRadius: '4px',
fontSize: '0.75rem',
fontWeight: 500,
}}>
Always Active
</div>
</div>
</div>
{/* Analytics Cookies */}
<div style={{
padding: '1rem',
backgroundColor: '#fff',
borderRadius: '8px',
marginBottom: '0.75rem',
border: '1px solid #e5e5e5'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h4 style={{ margin: 0, fontSize: '0.9375rem', fontWeight: 600 }}>Analytics Cookies</h4>
<p style={{ margin: '0.25rem 0 0 0', fontSize: '0.8125rem', color: '#666' }}>
Help us understand how visitors interact with our website.
</p>
</div>
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
<input
type="checkbox"
checked={consent.analytics}
onChange={(e) => updatePreference('analytics', e.target.checked)}
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
/>
</label>
</div>
</div>
{/* Marketing Cookies */}
<div style={{
padding: '1rem',
backgroundColor: '#fff',
borderRadius: '8px',
marginBottom: '0.75rem',
border: '1px solid #e5e5e5'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h4 style={{ margin: 0, fontSize: '0.9375rem', fontWeight: 600 }}>Marketing Cookies</h4>
<p style={{ margin: '0.25rem 0 0 0', fontSize: '0.8125rem', color: '#666' }}>
Used to track visitors across websites for advertising purposes.
</p>
</div>
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
<input
type="checkbox"
checked={consent.marketing}
onChange={(e) => updatePreference('marketing', e.target.checked)}
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
/>
</label>
</div>
</div>
</div>
<div style={{ display: 'flex', gap: '0.75rem' }}>
<button
onClick={savePreferences}
style={{
padding: '0.625rem 1.25rem',
backgroundColor: '#0066cc',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '0.9375rem',
fontWeight: 500,
cursor: 'pointer',
}}
>
Save Preferences
</button>
<button
onClick={() => setShowPreferences(false)}
style={{
padding: '0.625rem 1.25rem',
backgroundColor: 'transparent',
color: '#666',
border: 'none',
borderRadius: '6px',
fontSize: '0.9375rem',
cursor: 'pointer',
}}
>
Back
</button>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,38 @@
import { defineDb, defineTable, column } from 'astro:db';
const ConsentLog = defineTable({
columns: {
id: column.number({ primaryKey: true }),
action: column.text(),
purpose: column.text(),
analytics: column.boolean({ default: false }),
marketing: column.boolean({ default: false }),
functional: column.boolean({ default: false }),
userAgent: column.text({ optional: true }),
ip: column.text({ optional: true }),
timestamp: column.date(),
sessionId: column.text({ optional: true }),
},
});
export type ConsentAction = 'accept' | 'reject' | 'update';
export type ConsentPurpose = 'analytics' | 'marketing' | 'functional' | 'all';
export interface ConsentRow {
id: number;
action: ConsentAction;
purpose: ConsentPurpose;
analytics: boolean;
marketing: boolean;
functional: boolean;
userAgent?: string;
ip?: string;
timestamp: Date;
sessionId?: string;
}
export default defineDb({
tables: {
ConsentLog,
},
});

View File

@@ -0,0 +1,75 @@
import { map } from 'nanostores';
export interface ConsentState {
analytics: boolean;
marketing: boolean;
functional: boolean;
hasConsented: boolean;
timestamp?: string;
}
export interface ConsentLogData extends ConsentState {
ip?: string;
userAgent?: string;
}
export const defaultConsent: ConsentState = {
analytics: false,
marketing: false,
functional: false,
hasConsented: false,
};
export const consentStore = map<ConsentState>(defaultConsent);
export const STORAGE_KEY = 'pdpa_consent';
export function loadConsent(): ConsentState {
if (typeof localStorage === 'undefined') {
return defaultConsent;
}
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
const parsed = JSON.parse(stored) as ConsentState;
consentStore.set(parsed);
return parsed;
} catch {
return defaultConsent;
}
}
return defaultConsent;
}
export function saveConsentLocally(state: ConsentState): void {
if (typeof localStorage === 'undefined') return;
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
consentStore.set(state);
}
export function hasAnalyticsConsent(): boolean {
const state = consentStore.get();
return state.hasConsented && state.analytics;
}
export function hasMarketingConsent(): boolean {
const state = consentStore.get();
return state.hasConsented && state.marketing;
}
export function hasFunctionalConsent(): boolean {
const state = consentStore.get();
return state.hasConsented;
}
export function resetConsent(): void {
if (typeof localStorage === 'undefined') return;
localStorage.removeItem(STORAGE_KEY);
consentStore.set(defaultConsent);
}
export function hasConsented(): boolean {
return consentStore.get().hasConsented;
}

View File

@@ -1,9 +0,0 @@
node_modules
.next
out
dist
build
*.log
.env*.local
.DS_Store
*.pem

View File

@@ -1,5 +0,0 @@
# Payload CMS
PAYLOAD_SECRET=your-secret-key-here-change-in-production
# Database (PostgreSQL) - database name must match POSTGRES_DB in docker-compose
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/payload

View File

@@ -1,39 +0,0 @@
# Multi-stage Dockerfile for Next.js + Payload CMS with PostgreSQL
# Requires `output: 'standalone'` in next.config.ts
FROM node:22-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate && pnpm install --frozen-lockfile
FROM deps AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
RUN mkdir .next
RUN chown nextjs:nodejs .next
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View File

@@ -1,40 +0,0 @@
version: '3'
services:
payload:
image: node:22-alpine
ports:
- '3000:3000'
volumes:
- .:/home/node/app
- node_modules:/home/node/app/node_modules
working_dir: /home/node/app/
command: sh -c "corepack enable && corepack prepare pnpm@9.0.0 --activate && pnpm install && pnpm dev"
depends_on:
- postgres
env_file:
- .env
networks:
- payload-network
postgres:
restart: always
image: postgres:16-alpine
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- '5432:5432'
environment:
POSTGRES_USER: payload
POSTGRES_PASSWORD: payloadpass
POSTGRES_DB: payload
networks:
- payload-network
networks:
payload-network:
driver: bridge
volumes:
pgdata:
node_modules:

View File

@@ -1,31 +0,0 @@
import { withPayload } from '@payloadcms/next/withPayload'
import type { NextConfig } from 'next'
import path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(__filename)
const nextConfig: NextConfig = {
images: {
localPatterns: [
{
pathname: '/api/media/file/**',
},
],
},
output: 'standalone',
webpack: (webpackConfig) => {
webpackConfig.resolve.extensionAlias = {
'.cjs': ['.cts', '.cjs'],
'.js': ['.ts', '.tsx', '.js', '.jsx'],
'.mjs': ['.mts', '.mjs'],
}
return webpackConfig
},
turbopack: {
root: path.resolve(dirname),
},
}
export default withPayload(nextConfig, { devBundleServerPackages: false })

View File

@@ -1,45 +0,0 @@
{
"name": "nextjs-payload-starter",
"version": "1.0.0",
"description": "Next.js + Payload CMS starter template with PostgreSQL",
"private": true,
"type": "module",
"scripts": {
"dev": "cross-env NODE_OPTIONS=--no-deprecation next dev",
"devsafe": "rm -rf .next && cross-env NODE_OPTIONS=--no-deprecation next dev",
"build": "cross-env NODE_OPTIONS=--no-deprecation next build",
"start": "cross-env NODE_OPTIONS=--no-deprecation next start",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap",
"generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types",
"lint": "cross-env NODE_OPTIONS=--no-deprecation next lint",
"docker:dev": "docker compose up -d",
"docker:dev:logs": "docker compose logs -f",
"docker:down": "docker compose down"
},
"dependencies": {
"@payloadcms/next": "^3.82.1",
"@payloadcms/richtext-lexical": "^3.82.1",
"@payloadcms/ui": "^3.82.1",
"@payloadcms/db-postgres": "^3.82.1",
"cross-env": "^7.0.3",
"graphql": "^16.8.1",
"next": "^16.2.3",
"payload": "^3.82.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"sharp": "^0.34.2"
},
"devDependencies": {
"@types/node": "^22.19.9",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"eslint": "^9.16.0",
"eslint-config-next": "^16.2.3",
"prettier": "^3.4.2",
"typescript": "^5.7.3"
},
"engines": {
"node": "^18.20.2 || >=20.9.0"
}
}

View File

@@ -1,41 +0,0 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
line-height: 1.6;
color: #333;
background-color: #fafafa;
}
a {
color: #0070f3;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
h1, h2, h3, h4, h5, h6 {
line-height: 1.2;
}
main {
min-height: calc(100vh - 200px);
}
header {
background: white;
border-bottom: 1px solid #eee;
}
footer {
background: #f5f5f5;
padding: 2rem;
text-align: center;
color: #666;
}

View File

@@ -1,22 +0,0 @@
import type { Metadata } from 'next'
import './globals.css'
export const metadata: Metadata = {
title: {
default: 'Next.js + Payload CMS',
template: '%s | Next.js + Payload CMS',
},
description: 'A website built with Next.js and Payload CMS',
}
export default function FrontendLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="th">
<body>{children}</body>
</html>
)
}

View File

@@ -1,84 +0,0 @@
import { getPayload } from 'payload'
import Link from 'next/link'
import config from '@payload-config'
export const dynamic = 'force-dynamic'
export default async function HomePage() {
let posts: any[] = []
try {
const payload = await getPayload({ config })
const { docs } = await payload.find({
collection: 'posts',
limit: 10,
sort: '-createdAt',
})
posts = docs
} catch (e) {
// Table might not exist yet - that's OK for initial setup
console.warn('Could not fetch posts:', e)
}
return (
<main style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
<header style={{ marginBottom: '3rem', borderBottom: '1px solid #eee', paddingBottom: '1rem' }}>
<h1 style={{ fontSize: '2.5rem', marginBottom: '0.5rem' }}>
Next.js + Payload CMS
</h1>
<p style={{ color: '#666' }}>
Welcome to your new website. Edit <code>src/app/(frontend)/page.tsx</code> to customize.
</p>
</header>
<section>
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem' }}>Recent Posts</h2>
{posts.length === 0 ? (
<p style={{ color: '#888' }}>
No posts yet. Go to{' '}
<Link href="/admin" style={{ color: '#0070f3' }}>
Admin Panel
</Link>{' '}
to create your first post.
</p>
) : (
<ul style={{ listStyle: 'none', padding: 0 }}>
{posts.map((post) => (
<li
key={post.id}
style={{
padding: '1rem',
marginBottom: '1rem',
border: '1px solid #eee',
borderRadius: '8px',
}}
>
<Link
href={`/posts/${post.slug || post.id}`}
style={{ textDecoration: 'none', color: 'inherit' }}
>
<h3 style={{ margin: 0, marginBottom: '0.5rem' }}>{post.title as string}</h3>
{post.createdAt && (
<p style={{ margin: 0, fontSize: '0.875rem', color: '#888' }}>
{new Date(post.createdAt).toLocaleDateString('th-TH', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</p>
)}
</Link>
</li>
))}
</ul>
)}
</section>
<footer style={{ marginTop: '4rem', paddingTop: '2rem', borderTop: '1px solid #eee' }}>
<Link href="/admin" style={{ color: '#0070f3' }}>
Go to Admin Panel
</Link>
</footer>
</main>
)
}

View File

@@ -1,23 +0,0 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
import type { Metadata } from 'next'
import config from '@payload-config'
import { RootPage, generatePageMetadata } from '@payloadcms/next/views'
import { importMap } from '../importMap.js'
type Args = {
params: Promise<{
segments: string[]
}>
searchParams: Promise<{
[key: string]: string | string[]
}>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const Page = ({ params, searchParams }: Args) =>
RootPage({ config, params, searchParams, importMap })
export default Page

View File

@@ -1,2 +0,0 @@
/* THIS FILE IS GENERATED BY PAYLOAD - RUN `pnpm generate:importmap` AFTER CHANGING COLLECTIONS */
export const importMap = {}

View File

@@ -1,18 +0,0 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
import config from '@payload-config'
import '@payloadcms/next/css'
import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} from '@payloadcms/next/routes'
export const GET = REST_GET(config)
export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config)
export const PUT = REST_PUT(config)
export const OPTIONS = REST_OPTIONS(config)

View File

@@ -1,6 +0,0 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* Run `pnpm generate:importmap` to regenerate */
import config from '@payload-config'
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
export const GET = GRAPHQL_PLAYGROUND_GET(config)

View File

@@ -1,6 +0,0 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
import config from '@payload-config'
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes'
export const POST = GRAPHQL_POST(config)
export const OPTIONS = REST_OPTIONS(config)

View File

@@ -1 +0,0 @@
/* Custom styles for Payload admin - add your overrides here */

View File

@@ -1,30 +0,0 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
import config from '@payload-config'
import '@payloadcms/next/css'
import type { ServerFunctionClient } from 'payload'
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
import React from 'react'
import { importMap } from './admin/importMap.js'
import './custom.scss'
type Args = {
children: React.ReactNode
}
const serverFunction: ServerFunctionClient = async function (args) {
'use server'
return handleServerFunctions({
...args,
config,
importMap,
})
}
const Layout = ({ children }: Args) => (
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
{children}
</RootLayout>
)
export default Layout

View File

@@ -1,16 +0,0 @@
import type { CollectionConfig } from 'payload'
export const Media: CollectionConfig = {
slug: 'media',
access: {
read: () => true,
},
fields: [
{
name: 'alt',
type: 'text',
required: true,
},
],
upload: true,
}

View File

@@ -1,70 +0,0 @@
import type { CollectionConfig } from 'payload'
export const Pages: CollectionConfig = {
slug: 'pages',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'slug', 'updatedAt'],
},
access: {
read: () => true,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'slug',
type: 'text',
required: true,
admin: {
description: 'URL-friendly version (e.g. "about-us")',
},
},
{
name: 'status',
type: 'select',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
defaultValue: 'draft',
admin: {
description: 'Control publication status',
},
},
{
name: 'content',
type: 'richText',
label: 'Page Content',
admin: {
description: 'Main page content — use the visual editor',
},
},
{
name: 'updatedAt',
type: 'date',
admin: {
readOnly: true,
date: {
pickerAppearance: 'dayAndTime',
},
},
},
],
hooks: {
beforeChange: [
({ data }) => {
if (data.title && !data.slug) {
data.slug = data.title
.toLowerCase()
.replace(/[^a-z0-9ก-๙]+/g, '-')
.replace(/(^-|-$)/g, '')
}
return data
},
],
},
}

View File

@@ -1,73 +0,0 @@
import type { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'slug', 'createdAt'],
},
access: {
read: () => true,
create: () => true,
update: () => true,
delete: () => true,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'slug',
type: 'text',
required: true,
admin: {
description: 'URL-friendly version of the title',
},
},
{
name: 'content',
type: 'richText',
},
{
name: 'featuredImage',
type: 'upload',
relationTo: 'media',
},
{
name: 'publishedAt',
type: 'date',
admin: {
date: {
pickerAppearance: 'dayAndTime',
},
},
},
{
name: 'status',
type: 'select',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
defaultValue: 'draft',
admin: {
description: 'Control publication status',
},
},
],
hooks: {
beforeChange: [
({ data }) => {
if (data.title && !data.slug) {
data.slug = data.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
}
return data
},
],
},
}

View File

@@ -1,12 +0,0 @@
import type { CollectionConfig } from 'payload'
export const Users: CollectionConfig = {
slug: 'users',
admin: {
useAsTitle: 'email',
},
auth: true,
fields: [
// Email added by default
],
}

View File

@@ -1 +0,0 @@
export { default as config } from './payload.config'

View File

@@ -1,41 +0,0 @@
import { postgresAdapter } from '@payloadcms/db-postgres'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import path from 'path'
import { buildConfig } from 'payload'
import { fileURLToPath } from 'url'
import sharp from 'sharp'
import { Users } from './collections/Users'
import { Media } from './collections/Media'
import { Posts } from './collections/Posts'
import { Pages } from './collections/Pages'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfig({
admin: {
user: Users.slug,
importMap: {
baseDir: path.resolve(dirname),
},
},
collections: [
Users,
Media,
Posts,
Pages,
],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || '',
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
db: postgresAdapter({
pool: {
connectionString: process.env.DATABASE_URL || '',
},
}),
sharp,
plugins: [],
})

View File

@@ -1,28 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"],
"@payload-config": ["./src/payload.config.ts"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}