Construire un site web axé sur le contenu avec Payload CMS 3 et Next.js

AI Bot
Par AI Bot ·

Chargement du lecteur de synthèse vocale...

Payload CMS 3 est le premier CMS headless qui vit à l'intérieur de votre application Next.js. Pas de serveur séparé, pas d'API externe à gérer — votre panneau d'administration, votre API et votre frontend fonctionnent tous comme une seule application Next.js unifiée. Dans ce tutoriel, vous allez construire un site web complet de A à Z.

Ce que vous allez construire

Un TechPulse Blog — une plateforme de contenu complète avec :

  • Panneau d'administration Payload CMS 3 intégré directement dans Next.js App Router
  • Collections pour les articles, catégories et médias
  • Éditeur de texte riche avec Lexical (éditeur par défaut de Payload)
  • Upload d'images avec optimisation automatique
  • Authentification des utilisateurs et contrôle d'accès basé sur les rôles
  • Workflow brouillon/publication
  • APIs REST et GraphQL (générées automatiquement)
  • Gestion des métadonnées SEO
  • Interface responsive avec Tailwind CSS
  • Déploiement en production avec PostgreSQL

Prérequis

Avant de commencer, assurez-vous d'avoir :

  • Node.js 20+ installé
  • npm ou pnpm comme gestionnaire de paquets
  • Des connaissances de base en React, TypeScript et Next.js
  • Un éditeur de code (VS Code recommandé)
  • Docker installé (pour PostgreSQL local) ou une URL de base de données cloud
  • Une compréhension de base des systèmes de gestion de contenu

Pourquoi Payload CMS 3 ?

Payload CMS 3 représente un changement fondamental dans le fonctionnement des CMS headless. Au lieu de fonctionner comme un service backend séparé, Payload 3 s'intègre directement dans votre application Next.js :

FonctionnalitéPayload CMS 3StrapiSanityContentful
ArchitectureDans Next.jsServeur séparéSaaS hébergéSaaS hébergé
Interface adminRoute Next.jsApp séparéeStudio (React)Dashboard cloud
Base de donnéesPostgres/MongoDBPostgres/MySQLHébergéeHébergée
Type-safetyTypeScript completPartielTypes GROQTypes SDK
Auto-hébergéOui (inclus)Oui (séparé)NonNon
APIREST + GraphQLREST + GraphQLGROQREST + GraphQL
CoûtGratuit et open-sourcePlan gratuitPlan gratuitPlan gratuit

L'avantage clé : une seule base de code, un seul déploiement, un seul modèle mental. Votre CMS n'est qu'une partie de votre application Next.js.

Étape 1 : Créer le projet

Payload fournit un CLI officiel create-payload-app qui génère un projet complet :

npx create-payload-app@latest techpulse

Quand on vous le demande, sélectionnez :

  • Nom du projet : techpulse
  • Base de données : PostgreSQL (recommandé pour la production)
  • Template : website (inclut les collections communes)

Pour utiliser un PostgreSQL local via Docker :

docker run -d \
  --name payload-postgres \
  -e POSTGRES_USER=payload \
  -e POSTGRES_PASSWORD=payload123 \
  -e POSTGRES_DB=techpulse \
  -p 5432:5432 \
  postgres:16-alpine

Configurez et lancez le serveur de développement :

cd techpulse
cp .env.example .env

Mettez à jour votre fichier .env :

DATABASE_URI=postgresql://payload:payload123@localhost:5432/techpulse
PAYLOAD_SECRET=your-super-secret-key-change-this-in-production
NEXT_PUBLIC_SITE_URL=http://localhost:3000
npm run dev

Visitez http://localhost:3000/admin pour créer votre premier utilisateur administrateur. Votre frontend sera à http://localhost:3000.

Étape 2 : Comprendre la structure du projet

Voici la structure clé d'un projet Payload 3 + Next.js :

techpulse/
├── app/
│   ├── (frontend)/          # Routes de votre site public
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   └── posts/
│   │       └── [slug]/
│   │           └── page.tsx
│   ├── (payload)/           # Routes du panneau admin Payload
│   │   └── admin/
│   │       └── [[...segments]]/
│   │           └── page.tsx
│   ├── api/                 # API REST + GraphQL de Payload
│   │   └── [...slug]/
│   │       └── route.ts
│   └── layout.tsx           # Layout racine
├── collections/             # Configs des collections Payload
│   ├── Posts.ts
│   ├── Categories.ts
│   ├── Media.ts
│   └── Users.ts
├── globals/                 # Configs des globals Payload
│   └── SiteSettings.ts
├── payload.config.ts        # Configuration principale de Payload
├── payload-types.ts         # Types TypeScript auto-générés
└── tailwind.config.ts

Remarquez comment Payload utilise les groupes de routes Next.js : (payload) pour le panneau admin et (frontend) pour votre site public. Ils coexistent dans la même application Next.js.

Étape 3 : Configurer Payload

Le cœur de votre CMS est payload.config.ts. Configurons-le :

// payload.config.ts
import { buildConfig } from 'payload'
import { postgresAdapter } from '@payloadcms/db-postgres'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { Posts } from './collections/Posts'
import { Categories } from './collections/Categories'
import { Media } from './collections/Media'
import { Users } from './collections/Users'
import { SiteSettings } from './globals/SiteSettings'
import path from 'path'
import { fileURLToPath } from 'url'
import sharp from 'sharp'
 
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
 
export default buildConfig({
  admin: {
    user: Users.slug,
    meta: {
      titleSuffix: '- TechPulse Admin',
    },
  },
  collections: [Posts, Categories, Media, Users],
  globals: [SiteSettings],
  db: postgresAdapter({
    pool: {
      connectionString: process.env.DATABASE_URI || '',
    },
  }),
  editor: lexicalEditor(),
  sharp,
  typescript: {
    outputFile: path.resolve(dirname, 'payload-types.ts'),
  },
  secret: process.env.PAYLOAD_SECRET || '',
})

Ce fichier de configuration unique indique à Payload tout ce qu'il doit savoir sur votre CMS : quels types de contenu existent, quelle base de données utiliser, et comment le panneau admin doit se comporter.

Étape 4 : Définir les collections

Les collections sont les blocs de construction fondamentaux de Payload. Chaque collection définit un type de contenu avec ses champs, son contrôle d'accès et ses hooks.

Collection des articles

// collections/Posts.ts
import type { CollectionConfig } from 'payload'
import { lexicalEditor, HTMLConverterFeature } from '@payloadcms/richtext-lexical'
 
export const Posts: CollectionConfig = {
  slug: 'posts',
  admin: {
    useAsTitle: 'title',
    defaultColumns: ['title', 'category', 'status', 'publishedAt'],
    preview: (doc) => {
      if (doc?.slug) {
        return `${process.env.NEXT_PUBLIC_SITE_URL}/posts/${doc.slug}`
      }
      return null
    },
  },
  access: {
    read: ({ req }) => {
      if (req.user) return true
      return { status: { equals: 'published' } }
    },
    create: ({ req }) => !!req.user,
    update: ({ req }) => !!req.user,
    delete: ({ req }) => !!req.user,
  },
  versions: {
    drafts: {
      autosave: { interval: 30000 },
    },
  },
  fields: [
    {
      name: 'title',
      type: 'text',
      required: true,
      minLength: 10,
      maxLength: 120,
    },
    {
      name: 'slug',
      type: 'text',
      required: true,
      unique: true,
      admin: {
        position: 'sidebar',
        description: "Identifiant URL-friendly",
      },
      hooks: {
        beforeValidate: [
          ({ value, data }) => {
            if (!value && data?.title) {
              return data.title
                .toLowerCase()
                .replace(/[^a-z0-9]+/g, '-')
                .replace(/(^-|-$)/g, '')
            }
            return value
          },
        ],
      },
    },
    {
      name: 'excerpt',
      type: 'textarea',
      required: true,
      maxLength: 300,
    },
    {
      name: 'coverImage',
      type: 'upload',
      relationTo: 'media',
      required: true,
    },
    {
      name: 'content',
      type: 'richText',
      required: true,
      editor: lexicalEditor({
        features: ({ defaultFeatures }) => [
          ...defaultFeatures,
          HTMLConverterFeature({}),
        ],
      }),
    },
    {
      name: 'category',
      type: 'relationship',
      relationTo: 'categories',
      required: true,
      hasMany: false,
      admin: { position: 'sidebar' },
    },
    {
      name: 'tags',
      type: 'array',
      admin: { position: 'sidebar' },
      fields: [
        { name: 'tag', type: 'text', required: true },
      ],
    },
    {
      name: 'status',
      type: 'select',
      defaultValue: 'draft',
      options: [
        { label: 'Brouillon', value: 'draft' },
        { label: 'Publié', value: 'published' },
      ],
      admin: { position: 'sidebar' },
    },
    {
      name: 'publishedAt',
      type: 'date',
      admin: {
        position: 'sidebar',
        date: { pickerAppearance: 'dayAndTime' },
      },
    },
    {
      name: 'seo',
      type: 'group',
      fields: [
        { name: 'metaTitle', type: 'text', maxLength: 60 },
        { name: 'metaDescription', type: 'textarea', maxLength: 160 },
        { name: 'ogImage', type: 'upload', relationTo: 'media' },
      ],
    },
  ],
  hooks: {
    beforeChange: [
      ({ data }) => {
        if (data.status === 'published' && !data.publishedAt) {
          data.publishedAt = new Date().toISOString()
        }
        return data
      },
    ],
  },
}

Collection des catégories

// collections/Categories.ts
import type { CollectionConfig } from 'payload'
 
export const Categories: CollectionConfig = {
  slug: 'categories',
  admin: { useAsTitle: 'name' },
  access: {
    read: () => true,
    create: ({ req }) => !!req.user,
    update: ({ req }) => !!req.user,
    delete: ({ req }) => !!req.user,
  },
  fields: [
    { name: 'name', type: 'text', required: true, unique: true },
    { name: 'slug', type: 'text', required: true, unique: true },
    { name: 'description', type: 'textarea' },
    { name: 'color', type: 'text' },
  ],
}

Collection des médias

// collections/Media.ts
import type { CollectionConfig } from 'payload'
 
export const Media: CollectionConfig = {
  slug: 'media',
  admin: { useAsTitle: 'alt' },
  access: {
    read: () => true,
    create: ({ req }) => !!req.user,
    update: ({ req }) => !!req.user,
    delete: ({ req }) => !!req.user,
  },
  upload: {
    staticDir: 'public/media',
    mimeTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'],
    imageSizes: [
      { name: 'thumbnail', width: 400, height: 300, position: 'centre' },
      { name: 'card', width: 768, height: 432, position: 'centre' },
      { name: 'hero', width: 1920, height: undefined, position: 'centre' },
    ],
  },
  fields: [
    { name: 'alt', type: 'text', required: true },
    { name: 'caption', type: 'text' },
  ],
}

Collection des utilisateurs

// collections/Users.ts
import type { CollectionConfig } from 'payload'
 
export const Users: CollectionConfig = {
  slug: 'users',
  auth: true,
  admin: { useAsTitle: 'email' },
  access: {
    read: () => true,
    create: ({ req }) => req.user?.role === 'admin',
    update: ({ req, id }) => {
      if (req.user?.role === 'admin') return true
      return req.user?.id === id
    },
    delete: ({ req }) => req.user?.role === 'admin',
  },
  fields: [
    { name: 'name', type: 'text', required: true },
    {
      name: 'role',
      type: 'select',
      defaultValue: 'editor',
      options: [
        { label: 'Administrateur', value: 'admin' },
        { label: 'Éditeur', value: 'editor' },
      ],
      access: { update: ({ req }) => req.user?.role === 'admin' },
      admin: { position: 'sidebar' },
    },
    { name: 'avatar', type: 'upload', relationTo: 'media' },
  ],
}

Étape 5 : Créer les paramètres globaux

Les globals sont des documents singleton — du contenu qui n'existe qu'une seule fois, comme les paramètres du site :

// globals/SiteSettings.ts
import type { GlobalConfig } from 'payload'
 
export const SiteSettings: GlobalConfig = {
  slug: 'site-settings',
  access: {
    read: () => true,
    update: ({ req }) => !!req.user,
  },
  fields: [
    { name: 'siteName', type: 'text', required: true, defaultValue: 'TechPulse' },
    { name: 'siteDescription', type: 'textarea', defaultValue: "Votre source quotidienne d'insights tech et de tutoriels." },
    { name: 'logo', type: 'upload', relationTo: 'media' },
    {
      name: 'socialLinks',
      type: 'group',
      fields: [
        { name: 'twitter', type: 'text' },
        { name: 'github', type: 'text' },
        { name: 'linkedin', type: 'text' },
      ],
    },
    {
      name: 'footer',
      type: 'group',
      fields: [
        { name: 'copyright', type: 'text', defaultValue: '© 2026 TechPulse. Tous droits réservés.' },
        {
          name: 'links',
          type: 'array',
          fields: [
            { name: 'label', type: 'text', required: true },
            { name: 'url', type: 'text', required: true },
          ],
        },
      ],
    },
  ],
}

Étape 6 : Construire le frontend

Construisons maintenant le site public. Payload fournit une fonction getPayload qui vous donne un accès direct aux données de votre CMS — pas besoin d'appels API puisque Payload fonctionne dans votre application Next.js.

Composant Layout

// app/(frontend)/layout.tsx
import type { Metadata } from 'next'
import { getPayload } from 'payload'
import config from '@payload-config'
import Link from 'next/link'
import './globals.css'
 
export async function generateMetadata(): Promise<Metadata> {
  const payload = await getPayload({ config })
  const settings = await payload.findGlobal({ slug: 'site-settings' })
 
  return {
    title: {
      default: settings.siteName,
      template: `%s | ${settings.siteName}`,
    },
    description: settings.siteDescription,
  }
}
 
export default async function FrontendLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const payload = await getPayload({ config })
  const settings = await payload.findGlobal({ slug: 'site-settings' })
  const categories = await payload.find({
    collection: 'categories',
    limit: 10,
    sort: 'name',
  })
 
  return (
    <div className="min-h-screen flex flex-col">
      <header className="border-b bg-white sticky top-0 z-50">
        <nav className="max-w-6xl mx-auto px-4 h-16 flex items-center justify-between">
          <Link href="/" className="text-xl font-bold text-gray-900">
            {settings.siteName}
          </Link>
          <div className="hidden md:flex items-center gap-6">
            {categories.docs.map((category) => (
              <Link
                key={category.id}
                href={`/category/${category.slug}`}
                className="text-sm text-gray-600 hover:text-gray-900 transition-colors"
              >
                {category.name}
              </Link>
            ))}
          </div>
        </nav>
      </header>
      <main className="flex-1">{children}</main>
      <footer className="border-t bg-gray-50 py-8">
        <div className="max-w-6xl mx-auto px-4 text-center text-sm text-gray-500">
          {settings.footer?.copyright}
        </div>
      </footer>
    </div>
  )
}

Page d'accueil — Liste des articles

// app/(frontend)/page.tsx
import { getPayload } from 'payload'
import config from '@payload-config'
import Link from 'next/link'
import Image from 'next/image'
import type { Post, Media, Category } from '@/payload-types'
 
export default async function HomePage() {
  const payload = await getPayload({ config })
 
  const posts = await payload.find({
    collection: 'posts',
    where: { status: { equals: 'published' } },
    sort: '-publishedAt',
    limit: 12,
    depth: 2,
  })
 
  const [featured, ...rest] = posts.docs
 
  return (
    <div className="max-w-6xl mx-auto px-4 py-12">
      {featured && <FeaturedPost post={featured} />}
      <section className="mt-16">
        <h2 className="text-2xl font-bold mb-8">Derniers articles</h2>
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
          {rest.map((post) => (
            <PostCard key={post.id} post={post} />
          ))}
        </div>
      </section>
    </div>
  )
}
 
function FeaturedPost({ post }: { post: Post }) {
  const coverImage = post.coverImage as Media
  const category = post.category as Category
 
  return (
    <Link href={`/posts/${post.slug}`} className="group block">
      <article className="grid md:grid-cols-2 gap-8 items-center">
        <div className="relative aspect-video rounded-xl overflow-hidden">
          {coverImage?.url && (
            <Image
              src={coverImage.url}
              alt={coverImage.alt}
              fill
              className="object-cover group-hover:scale-105 transition-transform duration-500"
              priority
            />
          )}
        </div>
        <div>
          {category && (
            <span
              className="inline-block px-3 py-1 text-xs font-medium rounded-full text-white mb-4"
              style={{ backgroundColor: category.color || '#3B82F6' }}
            >
              {category.name}
            </span>
          )}
          <h1 className="text-3xl font-bold mb-4 group-hover:text-blue-600 transition-colors">
            {post.title}
          </h1>
          <p className="text-gray-600 mb-4">{post.excerpt}</p>
        </div>
      </article>
    </Link>
  )
}
 
function PostCard({ post }: { post: Post }) {
  const coverImage = post.coverImage as Media
  const category = post.category as Category
 
  return (
    <Link href={`/posts/${post.slug}`} className="group block">
      <article>
        <div className="relative aspect-video rounded-lg overflow-hidden mb-4">
          {coverImage?.url && (
            <Image
              src={coverImage.url}
              alt={coverImage.alt}
              fill
              className="object-cover group-hover:scale-105 transition-transform duration-300"
            />
          )}
        </div>
        {category && (
          <span
            className="inline-block px-2 py-0.5 text-xs font-medium rounded-full text-white mb-2"
            style={{ backgroundColor: category.color || '#3B82F6' }}
          >
            {category.name}
          </span>
        )}
        <h3 className="font-semibold mb-2 group-hover:text-blue-600 transition-colors line-clamp-2">
          {post.title}
        </h3>
        <p className="text-sm text-gray-600 line-clamp-2">{post.excerpt}</p>
      </article>
    </Link>
  )
}

Page d'article individuel

// app/(frontend)/posts/[slug]/page.tsx
import { getPayload } from 'payload'
import config from '@payload-config'
import { notFound } from 'next/navigation'
import Image from 'next/image'
import type { Metadata } from 'next'
import type { Post, Media, Category } from '@/payload-types'
import { RichText } from '@payloadcms/richtext-lexical/react'
 
type Params = Promise<{ slug: string }>
 
export async function generateStaticParams() {
  const payload = await getPayload({ config })
  const posts = await payload.find({
    collection: 'posts',
    where: { status: { equals: 'published' } },
    limit: 100,
    select: { slug: true },
  })
 
  return posts.docs.map((post) => ({ slug: post.slug }))
}
 
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
  const { slug } = await params
  const payload = await getPayload({ config })
  const posts = await payload.find({
    collection: 'posts',
    where: { slug: { equals: slug } },
    limit: 1,
  })
 
  const post = posts.docs[0]
  if (!post) return { title: 'Article non trouvé' }
 
  const coverImage = post.coverImage as Media
 
  return {
    title: post.seo?.metaTitle || post.title,
    description: post.seo?.metaDescription || post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: coverImage?.url ? [{ url: coverImage.url }] : [],
    },
  }
}
 
export default async function PostPage({ params }: { params: Params }) {
  const { slug } = await params
  const payload = await getPayload({ config })
 
  const posts = await payload.find({
    collection: 'posts',
    where: {
      slug: { equals: slug },
      status: { equals: 'published' },
    },
    limit: 1,
    depth: 2,
  })
 
  const post = posts.docs[0]
  if (!post) notFound()
 
  const coverImage = post.coverImage as Media
  const category = post.category as Category
 
  return (
    <article className="max-w-3xl mx-auto px-4 py-12">
      <header className="mb-8">
        {category && (
          <span
            className="inline-block px-3 py-1 text-xs font-medium rounded-full text-white mb-4"
            style={{ backgroundColor: category.color || '#3B82F6' }}
          >
            {category.name}
          </span>
        )}
        <h1 className="text-4xl font-bold mb-4">{post.title}</h1>
        <p className="text-lg text-gray-600 mb-4">{post.excerpt}</p>
      </header>
 
      {coverImage?.url && (
        <div className="relative aspect-video rounded-xl overflow-hidden mb-12">
          <Image src={coverImage.url} alt={coverImage.alt} fill className="object-cover" priority />
        </div>
      )}
 
      <div className="prose prose-lg max-w-none">
        <RichText data={post.content} />
      </div>
 
      {post.tags && post.tags.length > 0 && (
        <div className="mt-12 pt-8 border-t flex flex-wrap gap-2">
          {post.tags.map((item, i) => (
            <span key={i} className="px-3 py-1 bg-gray-100 text-sm text-gray-600 rounded-full">
              #{item.tag}
            </span>
          ))}
        </div>
      )}
    </article>
  )
}

Étape 7 : Ajouter la route API Payload

Payload génère automatiquement des APIs REST et GraphQL. Vous avez besoin d'une route catch-all :

// app/api/[...slug]/route.ts
import { REST_DELETE, REST_GET, REST_PATCH, REST_POST } from '@payloadcms/next/routes'
import config from '@payload-config'
 
export const GET = REST_GET(config)
export const POST = REST_POST(config)
export const PATCH = REST_PATCH(config)
export const DELETE = REST_DELETE(config)

Cela vous donne des endpoints API automatiques :

  • GET /api/posts — Lister tous les articles
  • GET /api/posts/:id — Obtenir un article
  • POST /api/posts — Créer un article
  • PATCH /api/posts/:id — Mettre à jour un article
  • DELETE /api/posts/:id — Supprimer un article
  • POST /api/users/login — Authentification

Étape 8 : Peupler les données initiales

Créez un script de seed pour remplir votre CMS avec des données initiales :

// scripts/seed.ts
import { getPayload } from 'payload'
import config from '@payload-config'
 
async function seed() {
  const payload = await getPayload({ config })
 
  console.log('Peuplement de la base de données...')
 
  const categories = [
    { name: 'Ingénierie', slug: 'engineering', color: '#3B82F6', description: "Ingénierie et développement logiciel" },
    { name: 'IA & ML', slug: 'ai-ml', color: '#8B5CF6', description: "Intelligence artificielle et machine learning" },
    { name: 'DevOps', slug: 'devops', color: '#10B981', description: 'Infrastructure et opérations' },
    { name: 'Design', slug: 'design', color: '#F59E0B', description: "Design UI/UX et systèmes de design" },
  ]
 
  for (const category of categories) {
    await payload.create({ collection: 'categories', data: category })
    console.log(`Catégorie créée : ${category.name}`)
  }
 
  console.log('Peuplement terminé !')
  process.exit(0)
}
 
seed()

Exécutez-le :

npx tsx scripts/seed.ts

Étape 9 : Ajouter des patterns de contrôle d'accès

Le contrôle d'accès de Payload est l'une de ses fonctionnalités les plus puissantes :

// lib/access.ts
import type { Access } from 'payload'
 
// Seuls les admins peuvent effectuer cette action
export const isAdmin: Access = ({ req }) => {
  return req.user?.role === 'admin'
}
 
// Les admins voient tout, les autres uniquement leurs propres documents
export const isAdminOrSelf: Access = ({ req }) => {
  if (req.user?.role === 'admin') return true
  return { id: { equals: req.user?.id } }
}
 
// Le contenu publié est public, les brouillons nécessitent une authentification
export const publishedOrAuth: Access = ({ req }) => {
  if (req.user) return true
  return { status: { equals: 'published' } }
}

Étape 10 : Ajouter des hooks aux collections

Les hooks vous permettent d'exécuter de la logique à différents points du cycle de vie des documents :

hooks: {
  beforeChange: [
    ({ data }) => {
      if (data.status === 'published' && !data.publishedAt) {
        data.publishedAt = new Date().toISOString()
      }
      return data
    },
  ],
  afterChange: [
    async ({ doc, operation }) => {
      if (operation === 'create' && doc.status === 'published') {
        try {
          await fetch(`${process.env.NEXT_PUBLIC_SITE_URL}/api/revalidate`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              secret: process.env.REVALIDATION_SECRET,
              path: `/posts/${doc.slug}`,
            }),
          })
        } catch (error) {
          console.error('Échec de la revalidation :', error)
        }
      }
    },
  ],
}

Étape 11 : Configurer la revalidation à la demande

Créez une route API de revalidation pour que les mises à jour de contenu soient reflétées immédiatement :

// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'
 
export async function POST(request: NextRequest) {
  const body = await request.json()
 
  if (body.secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ message: 'Secret invalide' }, { status: 401 })
  }
 
  if (body.path) {
    revalidatePath(body.path)
    return NextResponse.json({ revalidated: true, path: body.path })
  }
 
  revalidatePath('/')
  return NextResponse.json({ revalidated: true, path: '/' })
}

Étape 12 : Déployer en production

Option A : Déployer sur Vercel

Payload CMS 3 fonctionne parfaitement avec Vercel. Vous aurez besoin d'une base de données PostgreSQL hébergée :

npm i -g vercel
vercel

Configurez vos variables d'environnement dans le dashboard Vercel :

DATABASE_URI=postgresql://...
PAYLOAD_SECRET=your-production-secret
NEXT_PUBLIC_SITE_URL=https://your-domain.com
REVALIDATION_SECRET=your-revalidation-secret

Option B : Déployer avec Docker

FROM node:20-alpine AS base
 
FROM base AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
 
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
 
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
 
EXPOSE 3000
CMD ["node", "server.js"]
docker compose up -d

Tester votre implémentation

  1. Panneau admin : Visitez /admin, créez un utilisateur et connectez-vous
  2. Créer du contenu : Ajoutez une catégorie, uploadez une image, créez un article
  3. Frontend : Visitez la page d'accueil pour voir vos articles
  4. API : Testez l'API REST à /api/posts
  5. Brouillon/Publication : Créez un brouillon et vérifiez qu'il n'apparaît qu'une fois publié
  6. Contrôle d'accès : Déconnectez-vous et vérifiez que les brouillons sont masqués

Dépannage

"Cannot find module '@payload-config'" Vérifiez que votre tsconfig.json contient l'alias de chemin :

{
  "compilerOptions": {
    "paths": {
      "@payload-config": ["./payload.config.ts"]
    }
  }
}

Erreurs de connexion à la base de données Vérifiez que votre DATABASE_URI est correct et que le serveur PostgreSQL fonctionne.

Images qui ne s'affichent pas Vérifiez que le répertoire public/media existe et a les permissions d'écriture.

Erreurs de types après modification du schéma Régénérez les types :

npx payload generate:types

Prochaines étapes

  • Ajouter la recherche avec le plugin de recherche intégré de Payload ou Algolia
  • Ajouter l'i18n avec la fonctionnalité de localisation de Payload
  • Configurer le mode preview pour que les éditeurs puissent prévisualiser les brouillons
  • Ajouter des composants personnalisés au panneau admin
  • Implémenter des webhooks pour notifier les services externes

Conclusion

Vous avez construit un site web complet axé sur le contenu avec Payload CMS 3 fonctionnant nativement dans Next.js. Cette architecture vous offre le meilleur des deux mondes : un panneau d'administration puissant et une API sans complexité de déploiement — tout fonctionne comme une seule application.

Points clés à retenir :

  • Payload CMS 3 vit dans Next.js — pas de backend séparé à gérer
  • Les collections définissent votre modèle de contenu avec des types TypeScript complets
  • Le contrôle d'accès est code-first — flexible et testable
  • Les hooks fournissent une automatisation du cycle de vie — pas besoin de workflows externes
  • Requêtes directes à la base de données via getPayload() — pas de surcharge REST côté serveur
  • APIs auto-générées pour les consommateurs externes qui ont besoin de REST ou GraphQL

La combinaison Payload + Next.js est l'un des stacks les plus productifs pour construire des applications riches en contenu en 2026.


Vous voulez lire plus de tutoriels? Découvrez notre dernier tutoriel sur Construire un Agent IA Autonome avec Agentic RAG et Next.js.

Discutez de votre projet avec nous

Nous sommes ici pour vous aider avec vos besoins en développement Web. Planifiez un appel pour discuter de votre projet et comment nous pouvons vous aider.

Trouvons les meilleures solutions pour vos besoins.

Articles connexes

Construire un Chatbot IA Local avec Ollama et Next.js : Guide Complet

Construisez un chatbot IA privé fonctionnant entièrement sur votre machine locale avec Ollama et Next.js. Ce tutoriel pratique couvre l'installation, le streaming des réponses, la sélection de modèles et le déploiement d'une interface de chat prête pour la production — le tout sans envoyer de données dans le cloud.

25 min read·

Construire un Agent IA Autonome avec Agentic RAG et Next.js

Apprenez a construire un agent IA qui decide de maniere autonome quand et comment recuperer des informations depuis des bases de donnees vectorielles. Un guide pratique complet avec Vercel AI SDK et Next.js, accompagne d'exemples executables.

30 min read·