Strapi
Next.js
CMS
Headless
TypeScript
API

Création d'un CMS sans tête personnalisé avec Strapi et Next.js

Création d'un CMS sans tête personnalisé avec Strapi et Next.js

En 2025, la demande de CMS headless continue de croître, les entreprises recherchant des solutions flexibles et évolutives pour gérer le contenu. Dans ce guide, nous allons créer un CMS sans tête complet en utilisant Strapi comme backend et Next.js comme frontend, intégrant les dernières pratiques et fonctionnalités des deux technologies.

Architecture du système

L'architecture de notre CMS headless sera basée sur des microservices, avec une séparation claire entre backend (Strapi) et frontend (Next.js). Cela permet une plus grande flexibilité, évolutivité et maintenabilité.

Composants principaux

  1. Back-end (Strapi) -API RESTful

    • Authentification et autorisation
    • Gestion de contenu
    • Téléchargement de médias
    • Plugins personnalisés
  2. Frontend (Next.js)

    • Composants du serveur
    • Rendu hybride
    • Cache et revalidation
    • Mode aperçu
    • SEO optimisé
  3. Infrastructures

    • PostgreSQL -Cache Redis
    • CDN
    • Orchestration des conteneurs

Implémentation du back-end

1. Configuration Strapi

// config/database.ts export default ({ env }) => ({ connection: { client: 'postgres', connection: { host: env('DATABASE_HOST'), port: env.int('DATABASE_PORT'), database: env('DATABASE_NAME'), user: env('DATABASE_USERNAME'), password: env('DATABASE_PASSWORD'), ssl: env.bool('DATABASE_SSL'), }, pool: { min: 0, max: 10, }, }, }); // config/plugins.ts export default { upload: { config: { provider: 'aws-s3', providerOptions: { accessKeyId: process.env.AWS_ACCESS_KEY_ID, secretAccessKey: process.env.AWS_ACCESS_SECRET, region: process.env.AWS_REGION, params: { Bucket: process.env.AWS_BUCKET, }, }, }, }, email: { config: { provider: 'sendgrid', providerOptions: { apiKey: process.env.SENDGRID_API_KEY, }, settings: { defaultFrom: 'no-reply@example.com', defaultReplyTo: 'contact@example.com', }, }, }, 'users-permissions': { config: { jwt: { expiresIn: '7d', }, }, }, };

2. Types de contenu

// api/article/content-types/article/schema.json { "kind": "collectionType", "collectionName": "articles", "info": { "singularName": "article", "pluralName": "articles", "displayName": "Article" }, "options": { "draftAndPublish": true, "versions": true }, "attributes": { "title": { "type": "string", "required": true, "unique": true }, "slug": { "type": "uid", "targetField": "title" }, "content": { "type": "richtext", "required": true }, "coverImage": { "type": "media", "multiple": false, "required": true }, "categories": { "type": "relation", "relation": "manyToMany", "target": "api::category.category", "inversedBy": "articles" }, "author": { "type": "relation", "relation": "manyToOne", "target": "plugin::users-permissions.user" }, "seo": { "type": "component", "component": "shared.seo", "required": true } } }

3. API personnalisée

// api/article/controllers/article.ts export default factories.createCoreController( 'api::article.article', ({ strapi }) => ({ async findBySlug(ctx) { const { slug } = ctx.params; const entity = await strapi.db.query('api::article.article').findOne({ where: { slug }, populate: ['coverImage', 'categories', 'author', 'seo'], }); if (!entity) { return ctx.notFound('Article not found'); } return this.transformResponse(entity); }, async findFeatured(ctx) { const entities = await strapi.db.query('api::article.article').findMany({ where: { featured: true, publishedAt: { $notNull: true }, }, limit: 5, orderBy: { publishedAt: 'desc' }, populate: ['coverImage', 'categories'], }); return this.transformResponse(entities); }, }) );

Implémentation du front-end

1. Configuration de Next.js

// app/layout.tsx import { Inter } from 'next/font/google'; import { Analytics } from '@vercel/analytics/react'; import { Providers } from './providers'; const inter = Inter({ subsets: ['latin'] }); export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( <html lang="en" suppressHydrationWarning> <body className={inter.className}> <Providers> {children} </Providers> <Analytics /> </body> </html> ); }

2. Client API

// lib/api/client.ts import qs from 'qs'; const API_URL = process.env.NEXT_PUBLIC_STRAPI_API_URL; async function fetchAPI( path: string, urlParamsObject = {}, options = {} ) { try { const mergedOptions = { next: { revalidate: 60 }, headers: { 'Content-Type': 'application/json', }, ...options, }; const queryString = qs.stringify(urlParamsObject, { encodeValuesOnly: true, }); const requestUrl = `${API_URL}/api${path}${queryString ? `?${queryString}` : ''}`; const response = await fetch(requestUrl, mergedOptions); if (!response.ok) { throw new Error(`API error: ${response.statusText}`); } const data = await response.json(); return data; } catch (error) { console.error(error); throw error; } } export async function getArticles(params = {}) { const data = await fetchAPI('/articles', { populate: ['coverImage', 'categories', 'author'], sort: ['publishedAt:desc'], ...params, }); return data; } export async function getArticle(slug: string) { const data = await fetchAPI(`/articles/${slug}`, { populate: ['coverImage', 'categories', 'author', 'seo'], }); return data; }

3. Composants

// components/ArticleCard.tsx import Image from 'next/image'; import Link from 'next/link'; import { format } from 'date-fns'; interface ArticleCardProps { article: { attributes: { title: string; slug: string; excerpt: string; publishedAt: string; coverImage: { data: { attributes: { url: string; alternativeText: string; }; }; }; }; }; } export function ArticleCard({ article }: ArticleCardProps) { const { attributes } = article; return ( <Link href={`/articles/${attributes.slug}`} className="group block" > <article className="space-y-4"> <div className="aspect-w-16 aspect-h-9 overflow-hidden rounded-lg"> <Image src={attributes.coverImage.data.attributes.url} alt={attributes.coverImage.data.attributes.alternativeText} fill className="object-cover transition group-hover:scale-105" /> </div> <div className="space-y-2"> <h3 className="text-xl font-semibold line-clamp-2"> {attributes.title} </h3> <p className="text-gray-600 line-clamp-3"> {attributes.excerpt} </p> <time dateTime={attributes.publishedAt} className="text-sm text-gray-500" > {format( new Date(attributes.publishedAt), 'dd MMM, yyyy' )} </time> </div> </article> </Link> ); }

4. Pages et itinéraires

// app/articles/page.tsx import { Suspense } from 'react'; import { getArticles } from '@/lib/api/client'; import { ArticleCard } from '@/components/ArticleCard'; import { Pagination } from '@/components/Pagination'; interface ArticlesPageProps { searchParams: { page?: string; category?: string; }; } export default async function ArticlesPage({ searchParams, }: ArticlesPageProps) { const page = Number(searchParams.page) || 1; const { data: articles, meta } = await getArticles({ pagination: { page, pageSize: 9, }, filters: searchParams.category ? { categories: { slug: searchParams.category, }, } : undefined, }); return ( <div className="space-y-8"> <h1 className="text-4xl font-bold">Articles</h1> <div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3"> {articles.map((article) => ( <ArticleCard key={article.id} article={article} /> ))} </div> <Pagination currentPage={page} totalPages={meta.pagination.pageCount} baseUrl="/articles" /> </div> ); }

Déploiement et infrastructure

1. Docker Composer

## docker-compose.yml version: '3' services: strapi: build: ./backend environment: DATABASE_CLIENT: postgres DATABASE_HOST: postgres DATABASE_NAME: strapi DATABASE_USERNAME: strapi DATABASE_PASSWORD: strapi NODE_ENV: production depends_on: - postgres ports: - '1337:1337' postgres: image: postgres:15 environment: POSTGRES_DB: strapi POSTGRES_USER: strapi POSTGRES_PASSWORD: strapi volumes: - postgres-data:/var/lib/postgresql/data redis: image: redis:7 ports: - '6379:6379' nextjs: build: ./frontend environment: NEXT_PUBLIC_STRAPI_API_URL: http://strapi:1337 ports: - '3000:3000' depends_on: - strapi volumes: postgres-data:

2. Actions GitHub

## .github/workflows/deploy.yml name: Deploy on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Setup Node.js uses: actions/setup-node@v3 with: node-version: '20' - name: Install dependencies run: | cd backend && yarn install cd ../frontend && yarn install - name: Build run: | cd backend && yarn build cd ../frontend && yarn build - name: Deploy env: DIGITALOCEAN_ACCESS_TOKEN: $ run: | doctl kubernetes cluster kubeconfig save cms-cluster kubectl apply -f k8s/

## meilleures pratiques

1. Performances

  • Implémenter la mise en cache sur plusieurs couches
  • Optimiser les actifs et les images
  • Utiliser le CDN
  • Implémenter le chargement paresseux
  • Suivre les indicateurs de performances

2. Sécurité

  • Configurer CORS de manière appropriée
  • Implémenter une limitation de débit
  • Utiliser des variables d'environnement
  • Désinfecter les entrées
  • Garder les dépendances à jour

3. Référencement

  • Implémenter des balises méta dynamiques
  • Générer un sitemap.xml
  • Utilisez des URL conviviales
  • Optimiser les performances
  • Implémenter schema.org

Conclusion

Construire un CMS sans tête avec Strapi et Next.js propose :

  1. Flexibilité : Architecture modulaire et extensible
  2. Performances : optimisation de la diffusion de contenu
  3. Sécurité : contrôle total sur la mise en œuvre
  4. Évolutivité : architecture distribuée
  5. Maintenabilité : code organisé et bien documenté

Prochaines étapes

  1. Implémenter des fonctionnalités avancées
  2. Ajouter une analyse de données
  3. Développez les intégrations
  4. Optimiser pour l'échelle
  5. Recueillir les commentaires des utilisateurs

Construisez-vous un CMS sans tête ? Partagez vos expériences et vos questions dans les commentaires ci-dessous !

A lire aussi