Files
opencode-skill/skills/website-creator/references/payload-nextjs-notes.md
Kunthawat Greethong ce8483e546 Remove Astro templates, fix sitemap template for Next.js
- Delete: CookieConsent.astro (old Astro component)
- Delete: consent.ts, right-to-be-forgotten.ts (Astro API routes)
- Update: route.ts is now proper Next.js route handler
- Update: sitemap-template.md - replace Astro pages structure with Next.js app/ structure
- Update: payload-nextjs-notes.md - fix MongoDB port reference
- Note: seo-multi-channel auto_publish.py is for Astro sites (kept as-is)
2026-04-17 11:03:10 +07:00

16 KiB

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:

# 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

# 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

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

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>:

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

# ดูว่าไฟล์มี 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

// ✅ ถูกต้อง
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

import type { Access } from 'payload'

export const admins: Access = () => true
export const anyone: Access = () => true

แล้ว import ใน collection:

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

const nextConfig: NextConfig = {
  allowedDevOrigins: ['110.164.146.185', '110.164.146.185:3000'],
}

Docker: อย่าลืม Restart + Clear Cache หลังแก้ไข

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 ค้าง

วิธีแก้:

# ลบ .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:

// ✅ ถูกต้อง — ใช้ 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:

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)

// ✅ ถูกต้อง
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:

// ✅ ถูกต้อง
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

// ✅ ถูกต้อง
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:

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])

// ❌ ผิด — 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:

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 แทนได้

วิธีทำ

# เขียน script เป็นไฟล์ .cjs (CommonJS)
# รันโดยตรงจาก host (ไม่ต้องเข้า container)
node seed-mongo.cjs

หา MongoDB URL

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)

// 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 ขั้นต่ำ

content: {
  root: {
    type: 'root',
    children: [
      {
        type: 'paragraph',
        children: [{ type: 'text', text: 'your excerpt or content here' }]
      }
    ]
  }
}

หา Mongo Container Name

docker ps --format '{{.Names}}'  # ดู container names
# ถ้าใช้ docker-compose จะเป็น <project>-mongo หรือ <project>-db

ตรวจสอบว่า Posts ถูก Insert แล้วผ่าน Payload API

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 (ดูด้านบน)