Strapi 5 et Next.js 15 : Créer une Application Full-Stack avec un CMS Headless

Strapi est né en France en 2015 et s'est imposé comme le CMS headless open-source le plus populaire au monde, avec plus de 60 000 étoiles sur GitHub. La version 5, sortie fin 2024, apporte une réécriture complète avec TypeScript natif, une nouvelle architecture de plugins, un système de contenu enrichi et des performances considérablement améliorées.
Combiné avec Next.js 15 et son App Router, vous disposez d'un duo redoutable pour construire des sites et applications web modernes : Strapi gère le contenu et expose une API REST ou GraphQL, tandis que Next.js consomme cette API pour rendre des pages ultra-rapides avec Server Components, Static Generation et Incremental Static Regeneration.
Dans ce tutoriel, vous allez construire un blog complet avec gestion des articles, catégories et auteurs — de l'installation jusqu'au déploiement en production.
Ce que vous allez apprendre
À la fin de ce tutoriel, vous saurez :
- Installer et configurer Strapi 5 avec TypeScript
- Créer des types de contenu (Article, Category, Author) avec relations
- Configurer les permissions API pour l'accès public
- Créer un client API typé pour consommer l'API REST de Strapi
- Afficher les articles avec des Server Components Next.js
- Implémenter les pages dynamiques avec rendu du rich text
- Filtrer les articles par catégorie
- Utiliser le Draft Mode de Next.js avec la prévisualisation Strapi
- Activer l'internationalisation avec le plugin i18n de Strapi
- Optimiser les images avec le composant Next.js Image
- Déployer en production sur Railway/Render et Vercel
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 20+ et npm/yarn installés
- Des connaissances de base en TypeScript
- Une familiarité avec Next.js et l'App Router (layouts, pages, Server Components)
- Un éditeur de code (VS Code recommandé avec l'extension Strapi)
- Un compte GitHub pour le déploiement
Pourquoi Strapi 5 ?
Le marché des CMS headless est riche. Voici comment Strapi se compare aux alternatives populaires :
| Critère | Strapi 5 | Payload CMS | Directus | Sanity | WordPress |
|---|---|---|---|---|---|
| Open-source | Oui (MIT) | Oui (MIT) | Oui (BSL) | Non | Oui (GPL) |
| Hébergement | Self-hosted / Cloud | Self-hosted | Self-hosted / Cloud | Cloud uniquement | Self-hosted / Cloud |
| Interface admin | Excellente | Bonne | Excellente | Bonne | Excellente |
| TypeScript | Natif (v5) | Natif | Oui | Oui | Non |
| API REST + GraphQL | Les deux | REST | Les deux | GraphQL | Via plugins |
| i18n natif | Plugin officiel | Oui | Oui | Oui | Via plugins |
| Courbe d'apprentissage | Douce | Modérée | Douce | Modérée | Douce |
| Prix cloud | Freemium | Gratuit | Freemium | Freemium | Variable |
Strapi excelle pour les équipes qui veulent une interface d'administration conviviale, une flexibilité totale sur l'hébergement, et une communauté massive (plus de 3 millions de développeurs).
Ce que vous allez construire
Un blog avec les fonctionnalités suivantes :
- Liste des articles avec pagination, images de couverture et extraits
- Page de détail avec rendu du rich text (blocs Strapi)
- Filtrage par catégorie avec navigation dédiée
- Pages auteur avec biographie et liste des articles
- Prévisualisation des brouillons pour les éditeurs
- Interface bilingue (français / arabe) avec le plugin i18n
Étape 1 : Créer le projet Strapi 5
Ouvrez un terminal et lancez la commande de création :
npx create-strapi@latest strapi-blogLe CLI vous posera plusieurs questions :
? What's the name of your project? strapi-blog
? Choose your installation type: Quickstart (Recommended)
? Choose your preferred language: TypeScript
? Choose your preferred package manager: npm
? Would you like to use the default database (SQLite)? Yes
? Start with an example structure & data? No
Pour la production, utilisez PostgreSQL ou MySQL. SQLite est parfait pour le développement local mais n'est pas adapté aux environnements multi-instances.
Une fois l'installation terminée, démarrez Strapi :
cd strapi-blog
npm run developStrapi démarre sur http://localhost:1337. La première fois, il vous demande de créer un compte administrateur. Remplissez le formulaire et connectez-vous à l'interface d'administration.
Structure du projet Strapi 5
strapi-blog/
├── config/
│ ├── database.ts # Configuration base de données
│ ├── middlewares.ts # Middlewares (CORS, etc.)
│ ├── plugins.ts # Configuration plugins
│ └── server.ts # Configuration serveur
├── src/
│ ├── api/ # Types de contenu (auto-générés)
│ ├── admin/ # Personnalisation admin
│ └── middlewares/ # Middlewares custom
├── public/
│ └── uploads/ # Fichiers médias uploadés
├── .env # Variables d'environnement
└── package.json
Le fichier .env contient les secrets importants :
HOST=0.0.0.0
PORT=1337
APP_KEYS=votre-clé-app-1,votre-clé-app-2
API_TOKEN_SALT=votre-salt
ADMIN_JWT_SECRET=votre-secret-jwt-admin
TRANSFER_TOKEN_SALT=votre-salt-transfer
JWT_SECRET=votre-secret-jwtÉtape 2 : Configurer les types de contenu
Dans l'interface admin Strapi, allez dans Content-Type Builder pour créer vos types de contenu.
Créer le type "Category"
Cliquez sur Create new collection type, nommez-le Category et ajoutez ces champs :
name— Text (requis, unique)slug— UID (basé surname, requis)description— Text (long text, optionnel)color— Text (pour la couleur d'affichage)
Cliquez sur Save et attendez que Strapi redémarre.
Créer le type "Author"
Créez un nouveau type Author avec ces champs :
name— Text (requis)bio— Rich Text (Blocks)avatar— Media (image unique)email— Email (optionnel)twitter— Text (handle Twitter, optionnel)
Créer le type "Article"
C'est le type principal. Créez Article avec ces champs :
title— Text (requis)slug— UID (basé surtitle, requis, unique)summary— Text (long text, requis)content— Rich Text (Blocks) — le contenu principalcover— Media (image unique)publishedAt— DateTime (géré automatiquement par Strapi)readingTime— Number (entier, optionnel)
Relations :
category— Relation Many-to-One avec Category (un article appartient à une catégorie)author— Relation Many-to-One avec Author (un article a un auteur)relatedArticles— Relation Many-to-Many avec Article (articles similaires)
Dans Strapi 5, les champs Rich Text (Blocks) génèrent un format JSON structuré appelé "Strapi Blocks". Ce format est beaucoup plus puissant que le Markdown et s'intègre parfaitement avec le renderer officiel @strapi/blocks-react-renderer.
Ajouter des données de test
Allez dans Content Manager et créez quelques entrées :
- Créez 2-3 catégories (ex: "Technologie", "Tutoriels", "Actualités")
- Créez 1-2 auteurs avec une photo de profil
- Créez 5-10 articles avec du contenu rich text, une image de couverture, et assignez une catégorie et un auteur
Étape 3 : Configurer les permissions API
Par défaut, l'API Strapi est protégée. Vous devez configurer les permissions pour le rôle Public.
Allez dans Settings puis Users & Permissions plugin puis Roles puis Public.
Pour chaque type de contenu (Article, Category, Author), activez :
find— pour lister les entréesfindOne— pour récupérer une entrée par son ID ou slug
N'activez jamais create, update ou delete pour le rôle Public en production ! Ces opérations doivent toujours être protégées par authentification.
Pour les médias (plugin Upload), activez find et findOne.
Cliquez sur Save pour enregistrer les permissions.
Créer un token API
Pour une sécurité renforcée, utilisez un API Token plutôt que l'accès public anonyme. Allez dans Settings puis API Tokens puis Create new API Token :
- Nom :
nextjs-frontend - Type :
Read-only - Durée : Unlimited (ou définissez une expiration)
Copiez le token — vous en aurez besoin pour Next.js.
Étape 4 : Créer le projet Next.js 15
Dans un nouveau dossier (en dehors de strapi-blog), créez le projet Next.js :
npx create-next-app@latest nextjs-blog \
--typescript \
--tailwind \
--eslint \
--app \
--src-dir \
--import-alias "@/*"
cd nextjs-blogInstallez les dépendances nécessaires :
npm install @strapi/blocks-react-renderer
npm install clsx lucide-react date-fnsCréez le fichier .env.local :
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337
STRAPI_API_TOKEN=votre-token-api-ici
DRAFT_MODE_SECRET=un-secret-aleatoire-pour-la-previewÉtape 5 : Créer le client API Strapi
Créez le dossier src/lib/ et ajoutez les fichiers suivants.
Types TypeScript
// src/lib/types.ts
export interface StrapiImage {
id: number;
documentId: string;
url: string;
alternativeText: string | null;
width: number;
height: number;
formats: {
thumbnail?: StrapiImageFormat;
small?: StrapiImageFormat;
medium?: StrapiImageFormat;
large?: StrapiImageFormat;
};
}
interface StrapiImageFormat {
url: string;
width: number;
height: number;
}
export interface Category {
id: number;
documentId: string;
name: string;
slug: string;
description: string | null;
color: string | null;
}
export interface Author {
id: number;
documentId: string;
name: string;
bio: BlocksContent | null;
avatar: StrapiImage | null;
email: string | null;
twitter: string | null;
}
export interface Article {
id: number;
documentId: string;
title: string;
slug: string;
summary: string;
content: BlocksContent;
cover: StrapiImage | null;
publishedAt: string;
readingTime: number | null;
category: Category | null;
author: Author | null;
}
// Type pour le contenu Strapi Blocks
export type BlocksContent = Array<{
type: string;
children?: BlocksContent;
text?: string;
bold?: boolean;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
code?: boolean;
url?: string;
level?: number;
format?: string;
image?: StrapiImage;
}>;
export interface StrapiResponse<T> {
data: T;
meta: {
pagination?: {
page: number;
pageSize: number;
pageCount: number;
total: number;
};
};
}
export interface StrapiListResponse<T> extends StrapiResponse<T[]> {}Client API
// src/lib/strapi.ts
import { Article, Author, Category, StrapiListResponse, StrapiResponse } from './types';
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL ?? 'http://localhost:1337';
const STRAPI_TOKEN = process.env.STRAPI_API_TOKEN;
interface FetchOptions {
filters?: Record<string, unknown>;
populate?: string | string[] | Record<string, unknown>;
sort?: string | string[];
pagination?: { page?: number; pageSize?: number };
locale?: string;
status?: 'published' | 'draft';
}
function buildQueryString(options: FetchOptions): string {
const params = new URLSearchParams();
if (options.populate) {
if (typeof options.populate === 'string') {
params.set('populate', options.populate);
} else if (Array.isArray(options.populate)) {
options.populate.forEach((p) => params.append('populate[]', p));
} else {
params.set('populate', JSON.stringify(options.populate));
}
}
if (options.filters) {
const encodeFilters = (obj: Record<string, unknown>, prefix = 'filters') => {
Object.entries(obj).forEach(([key, value]) => {
const fullKey = `${prefix}[${key}]`;
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
encodeFilters(value as Record<string, unknown>, fullKey);
} else {
params.set(fullKey, String(value));
}
});
};
encodeFilters(options.filters);
}
if (options.sort) {
const sorts = Array.isArray(options.sort) ? options.sort : [options.sort];
sorts.forEach((s) => params.append('sort[]', s));
}
if (options.pagination) {
if (options.pagination.page) params.set('pagination[page]', String(options.pagination.page));
if (options.pagination.pageSize) params.set('pagination[pageSize]', String(options.pagination.pageSize));
}
if (options.locale) {
params.set('locale', options.locale);
}
if (options.status) {
params.set('status', options.status);
}
const qs = params.toString();
return qs ? `?${qs}` : '';
}
async function strapiRequest<T>(
path: string,
options: FetchOptions = {},
fetchOptions: RequestInit = {}
): Promise<T> {
const queryString = buildQueryString(options);
const url = `${STRAPI_URL}/api${path}${queryString}`;
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (STRAPI_TOKEN) {
headers['Authorization'] = `Bearer ${STRAPI_TOKEN}`;
}
const response = await fetch(url, {
headers,
next: { revalidate: 60 }, // ISR : revalider toutes les 60 secondes
...fetchOptions,
});
if (!response.ok) {
throw new Error(`Strapi API error: ${response.status} ${response.statusText} for ${url}`);
}
return response.json() as Promise<T>;
}
// ──────────────── Articles ────────────────
const ARTICLE_POPULATE = {
cover: true,
category: true,
author: {
populate: { avatar: true },
},
};
export async function getArticles(
options: FetchOptions = {}
): Promise<StrapiListResponse<Article>> {
return strapiRequest<StrapiListResponse<Article>>('/articles', {
populate: ARTICLE_POPULATE,
sort: ['publishedAt:desc'],
pagination: { pageSize: 10 },
...options,
});
}
export async function getArticleBySlug(
slug: string,
options: FetchOptions = {}
): Promise<Article | null> {
const res = await strapiRequest<StrapiListResponse<Article>>('/articles', {
filters: { slug: { '$eq': slug } },
populate: {
...ARTICLE_POPULATE,
content: true,
relatedArticles: {
populate: { cover: true, category: true },
},
},
...options,
});
return res.data[0] ?? null;
}
export async function getArticlesByCategory(
categorySlug: string,
options: FetchOptions = {}
): Promise<StrapiListResponse<Article>> {
return getArticles({
filters: { category: { slug: { '$eq': categorySlug } } },
...options,
});
}
// ──────────────── Catégories ────────────────
export async function getCategories(): Promise<StrapiListResponse<Category>> {
return strapiRequest<StrapiListResponse<Category>>('/categories', {
sort: ['name:asc'],
});
}
export async function getCategoryBySlug(slug: string): Promise<Category | null> {
const res = await strapiRequest<StrapiListResponse<Category>>('/categories', {
filters: { slug: { '$eq': slug } },
});
return res.data[0] ?? null;
}
// ──────────────── Auteurs ────────────────
export async function getAuthorById(
documentId: string
): Promise<StrapiResponse<Author>> {
return strapiRequest<StrapiResponse<Author>>(`/authors/${documentId}`, {
populate: { avatar: true },
});
}
// ──────────────── Utilitaires ────────────────
export function getStrapiMediaUrl(url: string | null | undefined): string {
if (!url) return '/images/placeholder.webp';
if (url.startsWith('http')) return url;
return `${STRAPI_URL}${url}`;
}Dans Strapi 5, chaque entrée a un documentId (chaîne) en plus de l'id numérique classique. Utilisez documentId pour les opérations CRUD — c'est l'identifiant stable recommandé par Strapi 5.
Étape 6 : Afficher la liste des articles
Layout principal
// src/app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { NavBar } from '@/components/NavBar';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: {
template: '%s | Mon Blog',
default: 'Mon Blog — Propulsé par Strapi et Next.js',
},
description: 'Blog full-stack construit avec Strapi 5 et Next.js 15',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="fr">
<body className={inter.className}>
<NavBar />
<main className="max-w-6xl mx-auto px-4 py-8">{children}</main>
</body>
</html>
);
}Composant NavBar
// src/components/NavBar.tsx
import Link from 'next/link';
import { getCategories } from '@/lib/strapi';
export async function NavBar() {
const { data: categories } = await getCategories();
return (
<header className="border-b bg-white sticky top-0 z-10">
<div className="max-w-6xl mx-auto px-4 py-4 flex items-center gap-6">
<Link href="/" className="font-bold text-xl text-blue-600">
Mon Blog
</Link>
<nav className="flex items-center gap-4 text-sm">
<Link href="/articles" className="hover:text-blue-600 transition-colors">
Articles
</Link>
{categories.map((cat) => (
<Link
key={cat.id}
href={`/categories/${cat.slug}`}
className="hover:text-blue-600 transition-colors"
>
{cat.name}
</Link>
))}
</nav>
</div>
</header>
);
}Page d'accueil
// src/app/page.tsx
import { getArticles } from '@/lib/strapi';
import { ArticleCard } from '@/components/ArticleCard';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Accueil',
};
export default async function HomePage() {
const { data: articles, meta } = await getArticles({
pagination: { pageSize: 6 },
});
return (
<div>
<section className="mb-12 text-center">
<h1 className="text-4xl font-bold mb-4">Bienvenue sur Mon Blog</h1>
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
Découvrez nos derniers articles sur la technologie, les tutoriels et l'actualité tech.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-6">Derniers articles</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{articles.map((article) => (
<ArticleCard key={article.id} article={article} />
))}
</div>
</section>
</div>
);
}Composant ArticleCard
// src/components/ArticleCard.tsx
import Image from 'next/image';
import Link from 'next/link';
import { getStrapiMediaUrl } from '@/lib/strapi';
import type { Article } from '@/lib/types';
import { formatDate } from '@/lib/utils';
interface ArticleCardProps {
article: Article;
}
export function ArticleCard({ article }: ArticleCardProps) {
const coverUrl = getStrapiMediaUrl(
article.cover?.formats?.medium?.url ?? article.cover?.url
);
return (
<article className="rounded-xl border bg-white overflow-hidden shadow-sm hover:shadow-md transition-shadow">
<Link href={`/articles/${article.slug}`}>
<div className="relative aspect-video">
<Image
src={coverUrl}
alt={article.cover?.alternativeText ?? article.title}
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
</div>
</Link>
<div className="p-4">
{article.category && (
<Link
href={`/categories/${article.category.slug}`}
className="text-xs font-semibold uppercase tracking-wide text-blue-600 hover:underline"
>
{article.category.name}
</Link>
)}
<h3 className="mt-2 font-bold text-lg leading-snug">
<Link href={`/articles/${article.slug}`} className="hover:text-blue-600">
{article.title}
</Link>
</h3>
<p className="mt-2 text-sm text-gray-600 line-clamp-2">{article.summary}</p>
<div className="mt-4 flex items-center justify-between text-xs text-gray-500">
<span>{article.author?.name ?? 'Auteur inconnu'}</span>
<span>{formatDate(article.publishedAt)}</span>
</div>
</div>
</article>
);
}Utilitaires
// src/lib/utils.ts
import { format } from 'date-fns';
import { fr } from 'date-fns/locale';
import { clsx, type ClassValue } from 'clsx';
export function cn(...inputs: ClassValue[]) {
return clsx(inputs);
}
export function formatDate(dateString: string): string {
return format(new Date(dateString), 'd MMMM yyyy', { locale: fr });
}
export function calculateReadingTime(content: unknown[]): number {
const wordsPerMinute = 200;
const text = JSON.stringify(content);
const wordCount = text.split(/\s+/).length;
return Math.ceil(wordCount / wordsPerMinute);
}Étape 7 : Page de détail article
// src/app/articles/[slug]/page.tsx
import { notFound } from 'next/navigation';
import Image from 'next/image';
import { getArticleBySlug, getArticles, getStrapiMediaUrl } from '@/lib/strapi';
import { BlocksRenderer } from '@strapi/blocks-react-renderer';
import { ArticleCard } from '@/components/ArticleCard';
import { AuthorCard } from '@/components/AuthorCard';
import { formatDate } from '@/lib/utils';
import type { Metadata } from 'next';
interface ArticlePageProps {
params: Promise<{ slug: string }>;
}
// Génération des métadonnées dynamiques
export async function generateMetadata(
{ params }: ArticlePageProps
): Promise<Metadata> {
const { slug } = await params;
const article = await getArticleBySlug(slug);
if (!article) return { title: 'Article non trouvé' };
return {
title: article.title,
description: article.summary,
openGraph: {
title: article.title,
description: article.summary,
images: article.cover
? [{ url: getStrapiMediaUrl(article.cover.url) }]
: [],
},
};
}
// Pré-génération des routes statiques
export async function generateStaticParams() {
const { data: articles } = await getArticles({ pagination: { pageSize: 100 } });
return articles.map((a) => ({ slug: a.slug }));
}
export default async function ArticlePage({ params }: ArticlePageProps) {
const { slug } = await params;
const article = await getArticleBySlug(slug);
if (!article) notFound();
const coverUrl = getStrapiMediaUrl(article.cover?.url);
return (
<div className="max-w-3xl mx-auto">
{/* En-tête */}
<header className="mb-8">
{article.category && (
<span className="text-sm font-semibold text-blue-600 uppercase tracking-wide">
{article.category.name}
</span>
)}
<h1 className="mt-2 text-4xl font-bold leading-tight">{article.title}</h1>
<p className="mt-4 text-xl text-gray-600">{article.summary}</p>
<div className="mt-6 flex items-center gap-4 text-sm text-gray-500">
{article.author && (
<span className="font-medium text-gray-700">{article.author.name}</span>
)}
<span>·</span>
<time dateTime={article.publishedAt}>{formatDate(article.publishedAt)}</time>
{article.readingTime && (
<>
<span>·</span>
<span>{article.readingTime} min de lecture</span>
</>
)}
</div>
</header>
{/* Image de couverture */}
{article.cover && (
<div className="relative aspect-video mb-8 rounded-xl overflow-hidden">
<Image
src={coverUrl}
alt={article.cover.alternativeText ?? article.title}
fill
className="object-cover"
priority
sizes="(max-width: 768px) 100vw, 768px"
/>
</div>
)}
{/* Contenu Rich Text */}
<div className="prose prose-lg max-w-none">
<BlocksRenderer
content={article.content}
blocks={{
paragraph: ({ children }) => (
<p className="mb-4 leading-relaxed">{children}</p>
),
heading: ({ children, level }) => {
const Tag = `h${level}` as keyof JSX.IntrinsicElements;
const classes: Record<number, string> = {
1: 'text-3xl font-bold mt-8 mb-4',
2: 'text-2xl font-bold mt-8 mb-4',
3: 'text-xl font-semibold mt-6 mb-3',
4: 'text-lg font-semibold mt-4 mb-2',
};
return <Tag className={classes[level] ?? 'font-semibold mt-4 mb-2'}>{children}</Tag>;
},
code: ({ plainText }) => (
<pre className="bg-gray-900 text-gray-100 rounded-lg p-4 overflow-x-auto my-4">
<code>{plainText}</code>
</pre>
),
image: ({ image }) => (
<figure className="my-6">
<div className="relative aspect-video rounded-lg overflow-hidden">
<Image
src={getStrapiMediaUrl(image.url)}
alt={image.alternativeText ?? ''}
fill
className="object-cover"
sizes="768px"
/>
</div>
{image.alternativeText && (
<figcaption className="text-center text-sm text-gray-500 mt-2">
{image.alternativeText}
</figcaption>
)}
</figure>
),
quote: ({ children }) => (
<blockquote className="border-l-4 border-blue-500 pl-4 italic text-gray-600 my-4">
{children}
</blockquote>
),
list: ({ children, format }) => {
if (format === 'ordered') {
return <ol className="list-decimal list-inside space-y-2 my-4">{children}</ol>;
}
return <ul className="list-disc list-inside space-y-2 my-4">{children}</ul>;
},
'list-item': ({ children }) => <li>{children}</li>,
}}
modifiers={{
bold: ({ children }) => <strong className="font-semibold">{children}</strong>,
italic: ({ children }) => <em>{children}</em>,
code: ({ children }) => (
<code className="bg-gray-100 text-red-600 px-1 py-0.5 rounded text-sm font-mono">
{children}
</code>
),
underline: ({ children }) => <u>{children}</u>,
strikethrough: ({ children }) => <s>{children}</s>,
link: ({ children, url }) => (
<a href={url} className="text-blue-600 hover:underline" target="_blank" rel="noopener noreferrer">
{children}
</a>
),
}}
/>
</div>
{/* Auteur */}
{article.author && (
<div className="mt-12 pt-8 border-t">
<AuthorCard author={article.author} />
</div>
)}
</div>
);
}Composant AuthorCard
// src/components/AuthorCard.tsx
import Image from 'next/image';
import { getStrapiMediaUrl } from '@/lib/strapi';
import type { Author } from '@/lib/types';
interface AuthorCardProps {
author: Author;
}
export function AuthorCard({ author }: AuthorCardProps) {
const avatarUrl = getStrapiMediaUrl(author.avatar?.url);
return (
<div className="flex items-start gap-4 p-6 bg-gray-50 rounded-xl">
<div className="relative h-16 w-16 flex-shrink-0 rounded-full overflow-hidden">
<Image
src={avatarUrl}
alt={author.name}
fill
className="object-cover"
sizes="64px"
/>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-gray-500 mb-1">Écrit par</p>
<h3 className="font-bold text-lg">{author.name}</h3>
{author.twitter && (
<a
href={`https://twitter.com/${author.twitter}`}
className="text-sm text-blue-600 hover:underline"
target="_blank"
rel="noopener noreferrer"
>
@{author.twitter}
</a>
)}
</div>
</div>
);
}Étape 8 : Navigation par catégories
// src/app/categories/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { getArticlesByCategory, getCategories, getCategoryBySlug } from '@/lib/strapi';
import { ArticleCard } from '@/components/ArticleCard';
import type { Metadata } from 'next';
interface CategoryPageProps {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: CategoryPageProps): Promise<Metadata> {
const { slug } = await params;
const category = await getCategoryBySlug(slug);
if (!category) return { title: 'Catégorie non trouvée' };
return {
title: `${category.name} — Articles`,
description: category.description ?? `Articles dans la catégorie ${category.name}`,
};
}
export async function generateStaticParams() {
const { data: categories } = await getCategories();
return categories.map((c) => ({ slug: c.slug }));
}
export default async function CategoryPage({ params }: CategoryPageProps) {
const { slug } = await params;
const [category, { data: articles }] = await Promise.all([
getCategoryBySlug(slug),
getArticlesByCategory(slug),
]);
if (!category) notFound();
return (
<div>
<header className="mb-8">
<div
className="inline-block px-3 py-1 rounded-full text-white text-sm font-semibold mb-3"
style={{ backgroundColor: category.color ?? '#3b82f6' }}
>
Catégorie
</div>
<h1 className="text-3xl font-bold">{category.name}</h1>
{category.description && (
<p className="mt-2 text-gray-600">{category.description}</p>
)}
<p className="mt-1 text-sm text-gray-500">
{articles.length} article{articles.length !== 1 ? 's' : ''}
</p>
</header>
{articles.length === 0 ? (
<div className="text-center py-16 text-gray-500">
<p>Aucun article dans cette catégorie pour le moment.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{articles.map((article) => (
<ArticleCard key={article.id} article={article} />
))}
</div>
)}
</div>
);
}Étape 9 : Prévisualisation en mode brouillon
Strapi 5 dispose d'un système de Draft and Publish natif. Vous pouvez prévisualiser les brouillons dans Next.js grâce au Draft Mode.
Route handler pour activer le Draft Mode
// src/app/api/preview/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const secret = searchParams.get('secret');
const slug = searchParams.get('slug');
const type = searchParams.get('type') ?? 'articles';
if (secret !== process.env.DRAFT_MODE_SECRET) {
return new Response('Secret invalide', { status: 401 });
}
if (!slug) {
return new Response('Paramètre slug manquant', { status: 400 });
}
const draft = await draftMode();
draft.enable();
redirect(`/${type}/${slug}`);
}
// Route pour désactiver le Draft Mode
export async function DELETE() {
const draft = await draftMode();
draft.disable();
return new Response('Draft Mode désactivé', { status: 200 });
}Utiliser le Draft Mode dans les pages
// src/app/articles/[slug]/page.tsx (extrait mis à jour)
import { draftMode } from 'next/headers';
import { getArticleBySlug } from '@/lib/strapi';
export default async function ArticlePage({ params }: ArticlePageProps) {
const { slug } = await params;
const { isEnabled: isDraft } = await draftMode();
// En mode brouillon, on passe status: 'draft' pour récupérer les articles non publiés
const article = await getArticleBySlug(slug, {
status: isDraft ? 'draft' : 'published',
});
if (!article) notFound();
return (
<div>
{isDraft && (
<div className="bg-amber-100 border border-amber-400 text-amber-800 px-4 py-2 rounded mb-6 flex items-center justify-between">
<span>Mode prévisualisation actif — cet article est un brouillon</span>
<a
href="/api/preview"
className="text-sm underline"
onClick={async () => {
await fetch('/api/preview', { method: 'DELETE' });
}}
>
Quitter la prévisualisation
</a>
</div>
)}
{/* reste du contenu */}
</div>
);
}Dans Strapi 5, configurez l'URL de prévisualisation depuis Settings puis Content-Type Builder puis votre type de contenu puis Configure the view. L'URL ressemblera à : http://localhost:3000/api/preview?secret=VOTRE_SECRET&slug=SLUG_ARTICLE&type=articles
Étape 10 : Internationalisation avec le plugin i18n
Activer i18n dans Strapi
Allez dans Settings puis Internationalization et ajoutez les langues souhaitées (ex: fr-FR et ar).
Ensuite, dans Content-Type Builder, pour chaque type de contenu (Article, Category, Author), activez l'option Internationalization dans les paramètres avancés du type.
Voici la configuration du plugin dans Strapi :
// config/plugins.ts
export default () => ({
i18n: {
enabled: true,
config: {
defaultLocale: 'fr',
locales: ['fr', 'ar', 'en'],
},
},
});Fetching multilingue côté Next.js
Mettez à jour la structure de routes pour gérer les locales :
// src/app/[locale]/layout.tsx
import { getCategories } from '@/lib/strapi';
import type { Metadata } from 'next';
interface LocaleLayoutProps {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}
export default async function LocaleLayout({ children, params }: LocaleLayoutProps) {
const { locale } = await params;
return (
<html lang={locale} dir={locale === 'ar' ? 'rtl' : 'ltr'}>
<body>{children}</body>
</html>
);
}Mettez à jour le client Strapi pour passer la locale :
// src/lib/strapi.ts — extrait mis à jour
export async function getArticles(
options: FetchOptions = {}
): Promise<StrapiListResponse<Article>> {
return strapiRequest<StrapiListResponse<Article>>('/articles', {
populate: ARTICLE_POPULATE,
sort: ['publishedAt:desc'],
pagination: { pageSize: 10 },
locale: options.locale ?? 'fr', // Locale par défaut
...options,
});
}Créez un composant de sélection de langue :
// src/components/LocaleSwitcher.tsx
'use client';
import { usePathname, useRouter } from 'next/navigation';
const LOCALES = [
{ code: 'fr', label: 'Français', flag: '🇫🇷' },
{ code: 'ar', label: 'العربية', flag: '🇹🇳' },
{ code: 'en', label: 'English', flag: '🇬🇧' },
];
export function LocaleSwitcher({ currentLocale }: { currentLocale: string }) {
const pathname = usePathname();
const router = useRouter();
const switchLocale = (newLocale: string) => {
const newPath = pathname.replace(`/${currentLocale}`, `/${newLocale}`);
router.push(newPath);
};
return (
<div className="flex items-center gap-2">
{LOCALES.map((locale) => (
<button
key={locale.code}
onClick={() => switchLocale(locale.code)}
className={`text-sm px-2 py-1 rounded ${
currentLocale === locale.code
? 'bg-blue-100 text-blue-700 font-semibold'
: 'text-gray-600 hover:text-gray-900'
}`}
aria-label={`Passer en ${locale.label}`}
>
{locale.flag} {locale.code.toUpperCase()}
</button>
))}
</div>
);
}Étape 11 : Optimisation des images
Configurer le domaine Strapi dans Next.js
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: 'http',
hostname: 'localhost',
port: '1337',
pathname: '/uploads/**',
},
{
protocol: 'https',
hostname: 'votre-strapi-production.up.railway.app',
pathname: '/uploads/**',
},
// Cloudinary si vous utilisez le plugin Strapi Cloudinary
{
protocol: 'https',
hostname: 'res.cloudinary.com',
},
],
},
// Activer la compression d'images WebP/AVIF
experimental: {
optimizePackageImports: ['@strapi/blocks-react-renderer'],
},
};
export default nextConfig;Composant Image optimisé avec blur placeholder
// src/components/OptimizedImage.tsx
import Image from 'next/image';
import { getStrapiMediaUrl } from '@/lib/strapi';
import type { StrapiImage } from '@/lib/types';
interface OptimizedImageProps {
image: StrapiImage;
className?: string;
priority?: boolean;
sizes?: string;
}
// Génère un placeholder SVG Base64 pour l'effet blur
function generateBlurPlaceholder(width: number, height: number): string {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}"><filter id="b"><feGaussianBlur stdDeviation="20"/></filter><rect width="100%" height="100%" filter="url(#b)" fill="#e5e7eb"/></svg>`;
const base64 = Buffer.from(svg).toString('base64');
return `data:image/svg+xml;base64,${base64}`;
}
export function OptimizedImage({
image,
className,
priority = false,
sizes = '100vw',
}: OptimizedImageProps) {
const src = getStrapiMediaUrl(image.url);
const blurDataURL = generateBlurPlaceholder(image.width, image.height);
return (
<Image
src={src}
alt={image.alternativeText ?? ''}
width={image.width}
height={image.height}
className={className}
priority={priority}
sizes={sizes}
placeholder="blur"
blurDataURL={blurDataURL}
/>
);
}Configurer le plugin Strapi pour Cloudinary (optionnel)
Pour les projets en production, il est recommandé d'utiliser un service de stockage externe comme Cloudinary ou AWS S3 pour les médias Strapi :
# Dans le projet Strapi
npm install @strapi/provider-upload-cloudinary// config/plugins.ts (Strapi)
export default () => ({
upload: {
config: {
provider: 'cloudinary',
providerOptions: {
cloud_name: process.env.CLOUDINARY_NAME,
api_key: process.env.CLOUDINARY_KEY,
api_secret: process.env.CLOUDINARY_SECRET,
},
actionOptions: {
upload: {},
uploadStream: {},
delete: {},
},
},
},
});Étape 12 : Déploiement en production
Déployer Strapi sur Railway
Railway est la solution la plus simple pour héberger Strapi en production.
- Créez un compte sur Railway
- Cliquez sur New Project puis Deploy from GitHub repo
- Connectez votre repository Strapi
- Ajoutez un service PostgreSQL depuis le marketplace Railway
- Configurez les variables d'environnement :
# Variables Railway pour Strapi
NODE_ENV=production
DATABASE_CLIENT=postgres
DATABASE_URL=${{Postgres.DATABASE_URL}}
APP_KEYS=generez-une-cle-aleatoire-1,generez-une-cle-2
API_TOKEN_SALT=votre-salt-aleatoire
ADMIN_JWT_SECRET=votre-secret-admin
JWT_SECRET=votre-secret-jwt
TRANSFER_TOKEN_SALT=votre-transfer-saltRailway détecte automatiquement Node.js et lance npm run build && npm run start.
Configuration CORS pour la production
// config/middlewares.ts (Strapi)
export default [
'strapi::errors',
{
name: 'strapi::security',
config: {
contentSecurityPolicy: {
useDefaults: true,
directives: {
'connect-src': ["'self'", 'https:'],
'img-src': ["'self'", 'data:', 'blob:', 'https://res.cloudinary.com'],
'media-src': ["'self'", 'data:', 'blob:', 'https://res.cloudinary.com'],
upgradeInsecureRequests: null,
},
},
},
},
{
name: 'strapi::cors',
config: {
origin: [
'http://localhost:3000',
'https://votre-site.vercel.app',
'https://www.votre-domaine.com',
],
headers: ['*'],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'],
},
},
'strapi::poweredBy',
'strapi::logger',
'strapi::query',
'strapi::body',
'strapi::session',
'strapi::favicon',
'strapi::public',
];Déployer Next.js sur Vercel
# Installer Vercel CLI
npm install -g vercel
# Déployer depuis le dossier nextjs-blog
vercel
# Configurer les variables d'environnement sur Vercel
vercel env add NEXT_PUBLIC_STRAPI_URL
vercel env add STRAPI_API_TOKEN
vercel env add DRAFT_MODE_SECRETOu plus simplement, connectez votre repository GitHub directement depuis le dashboard Vercel. Vercel détecte Next.js automatiquement.
Activer ISR (Incremental Static Regeneration)
Pour des performances optimales en production, configurez ISR dans vos pages :
// src/app/articles/[slug]/page.tsx — extrait
// Option 1 : revalidation basée sur le temps
export const revalidate = 3600; // Revalider toutes les heures
// Option 2 : revalidation à la demande via webhook Strapi
// src/app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const secret = request.headers.get('x-revalidation-secret');
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
}
const body = await request.json();
const { model, entry } = body;
if (model === 'article' && entry?.slug) {
revalidatePath(`/articles/${entry.slug}`);
revalidatePath('/');
revalidateTag('articles');
}
if (model === 'category' && entry?.slug) {
revalidatePath(`/categories/${entry.slug}`);
revalidateTag('categories');
}
return NextResponse.json({ revalidated: true });
}Configurez le webhook dans Strapi : Settings puis Webhooks puis Create new webhook avec l'URL https://votre-site.vercel.app/api/revalidate et l'événement Entry.publish.
Dépannage
Erreur CORS au démarrage
Symptôme : Access to fetch at 'http://localhost:1337/api/...' from origin 'http://localhost:3000' has been blocked by CORS policy
Solution : Vérifiez la configuration CORS dans config/middlewares.ts de Strapi et assurez-vous que http://localhost:3000 est bien dans la liste origin.
Images qui ne s'affichent pas
Symptôme : Les images Strapi retournent une URL relative (ex: /uploads/image.jpg)
Solution : Utilisez toujours la fonction getStrapiMediaUrl() qui préfixe l'URL avec STRAPI_URL si elle ne commence pas par http. Vérifiez aussi que le domaine est configuré dans next.config.ts dans images.remotePatterns.
Erreur "Not found" sur les articles publiés
Symptôme : L'API retourne un tableau vide malgré des articles publiés dans l'admin
Solution : Vérifiez les permissions dans Settings puis Users & Permissions puis Roles puis Public. Les actions find et findOne doivent être cochées pour le type Article.
Populate incomplet — relations manquantes
Symptôme : article.category est null malgré une catégorie assignée dans l'admin
Solution : Par défaut, Strapi 5 ne retourne pas les relations imbriquées. Utilisez populate explicitement :
// Mauvais — ne retourne pas les relations
await strapiRequest('/articles');
// Correct — peuple les relations nécessaires
await strapiRequest('/articles', {
populate: { cover: true, category: true, author: { populate: { avatar: true } } },
});Contenu Rich Text qui ne s'affiche pas
Symptôme : Le champ content contient un tableau JSON mais rien ne s'affiche
Solution : Assurez-vous d'utiliser le composant BlocksRenderer de @strapi/blocks-react-renderer et que le champ a bien été créé avec le type Rich Text (Blocks) dans Strapi (et non Rich Text classique Markdown).
Slugs en double dans Strapi
Symptôme : Strapi refuse de sauvegarder un article avec un slug déjà utilisé
Solution : Le champ UID dans Strapi vérifie l'unicité automatiquement. Si vous renommez un article, régénérez le slug en cliquant sur le bouton de rechargement à côté du champ UID dans l'admin.
Prochaines étapes
Votre blog Strapi + Next.js est maintenant fonctionnel. Voici des améliorations à explorer :
Authentification utilisateur
Strapi 5 inclut un système d'authentification complet avec JWT. Vous pouvez permettre aux utilisateurs de créer un compte, se connecter et soumettre des commentaires :
// Connexion utilisateur
const response = await fetch(`${STRAPI_URL}/api/auth/local`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
identifier: 'user@example.com',
password: 'motdepasse',
}),
});
const { jwt, user } = await response.json();Recherche full-text
Strapi 5 supporte la recherche via le paramètre _q :
export async function searchArticles(query: string) {
return strapiRequest<StrapiListResponse<Article>>('/articles', {
filters: {
'$or': [
{ title: { '$containsi': query } },
{ summary: { '$containsi': query } },
],
},
populate: ARTICLE_POPULATE,
});
}Commentaires avec modération
Créez un type de contenu Comment dans Strapi avec les champs content, author, article (relation) et approved (boolean). Configurez les permissions pour que le rôle Authenticated puisse créer des commentaires, et l'admin les approuver.
Webhooks Strapi pour notifications
Configurez des webhooks pour envoyer des notifications Slack ou email quand un article est publié :
// config/bootstrap.ts (Strapi)
export default async () => {
// Hook appelé après la publication d'un article
strapi.db.lifecycles.subscribe({
models: ['api::article.article'],
async afterUpdate(event) {
if (event.result.publishedAt) {
await notifySlack(`Nouvel article publié : ${event.result.title}`);
}
},
});
};GraphQL à la place de REST
Installez le plugin GraphQL de Strapi pour une API plus flexible :
# Dans le projet Strapi
npm install @strapi/plugin-graphqlActivez-le dans config/plugins.ts et accédez au playground GraphQL sur http://localhost:1337/graphql.
Conclusion
Vous avez construit une application blog complète avec Strapi 5 comme CMS headless et Next.js 15 comme frontend. Cette architecture offre le meilleur des deux mondes : une interface d'administration intuitive pour les éditeurs de contenu, et des performances de rendu optimales grâce aux Server Components de Next.js.
Les points clés à retenir :
- Strapi 5 avec TypeScript natif simplifie la création et la gestion des types de contenu
- Le client API typé garantit la cohérence entre le backend et le frontend
- Les Server Components Next.js permettent de fetcher les données directement sans passer par le client
- Le Draft Mode de Next.js s'intègre parfaitement avec le système Draft/Publish de Strapi
- L'ISR (Incremental Static Regeneration) couplé aux webhooks Strapi offre des performances maximales avec du contenu toujours frais
Strapi étant open-source et self-hostable, vous gardez le contrôle total de vos données et de votre infrastructure — un avantage considérable pour les projets clients sensibles ou les applications à fort trafic.
Besoin d'aide pour intégrer Strapi dans votre projet ? L'équipe Noqta est là pour vous accompagner dans la conception et le développement de votre CMS headless sur mesure.
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 site web axé sur le contenu avec Payload CMS 3 et Next.js
Apprenez à construire un site web complet avec Payload CMS 3, qui fonctionne nativement dans Next.js App Router. Ce tutoriel couvre les collections, le rich text, les uploads, l'authentification et le déploiement en production.

Construire une API GraphQL typesafe avec Next.js App Router, Yoga et Pothos
Apprenez à construire une API GraphQL entièrement typesafe avec Next.js 15 App Router, GraphQL Yoga et le constructeur de schémas Pothos. Ce tutoriel pratique couvre la conception de schémas, les requêtes, les mutations, le middleware d'authentification et un client React avec urql.

Authentifier votre application Next.js 15 avec Auth.js v5 : Email, OAuth et contrôle des rôles
Apprenez à ajouter une authentification prête pour la production à votre application Next.js 15 avec Auth.js v5. Ce guide complet couvre Google OAuth, les identifiants email/mot de passe, les routes protégées, le middleware et le contrôle d'accès basé sur les rôles.