Passer au contenu principal

Aujourd’hui je vous ai fait un tutoriel Strapi pour ajouter un CMS à votre site perso, votre CV en ligne.
C’est quoi Strapi ? Il fait partie de la grande tendance des CMS “Headless”. Cela permet de déployer un backoffice qui permet de gérer le contenu, comme sur WordPress, Drupal ou autre. Sauf qu’au lieu d’exposer un site web complet, il expose juste une API.
Et, celle-ci est consommée par votre site web. Récemment, j’ai fait un site web pour un projet cyclotouriste. Il fallait que mes copains cyclistes puissent ajouter des itinéraires vélo, des photos, des lieux pour donner envie aux gens de voyager. Et, j’avais vraiment pas envie de faire un WordPress. Alors, j’ai utilisé Strapi pour la première fois.

J’ai rencontré quelques dents dures. Et, j’ai eu aussi des “Ahah-moment”, des moments où tu te dis “ah mais c’est trop bien. Je vous propose aujourd’hui de passer de Zéro à Héro sans rester coincé dans les ornières tout en découvrant les beaux paysages.

On va partir d’un CV en ligne fictif complètement statique. Et, dans quelques minutes tout sera dynamisé et même plus encore. C’est parti ?

Leçon numéro 1 du tutoriel Strapi : commencer par le front.

Il y a deux solutions. Commencer par saisir le contenu côté backoffice pour ensuite faire le front. Ou l’inverse. Et honnêtement la première fois j’ai commencé par déployer le Strapi en suivant la doc. Et à construire des blocs dedans. Mais ensuite je n’arrivais pas à faire un front cohérent. Parce que mon front restait un patchwork de contenus.

Ma recommandation, c’est vraiment de commencer par le front. La maquette doit être bien et sembler être d’une seule pièce.

À ce niveau, c’est pas grave si le rédactionnel n’est pas parfait. Par contre, évitez les “Lorem Ipsum”. Si vous n’avez rien à mettre à cet endroit, c’est probablement que c’est pas utile.

Capture d’écran. On y voit un CV en site web très classique d’un super héro du quotidien.

Ici, j’ai donc généré un site web très simple pour le CV d’un journaliste du Daily Planet.

Vous pouvez trouver cette version sur Github avec le tag `v0-site-statique`. https://github.com/thedamfr/cv-strapi-tutorial/tree/v0-site-statique

Quelle infrastructure ?

On va ajouter maintenant un peu d’infrastructure pour accueillir le Strapi.

Le premier élément, c’est une base de données. On prendra un PostgreSQL récent. Et le front aura pour interdiction formelle de se connecter dessus.
Il faut toujours demander à Strapi son contenu, si on se connecte à la base de données, on le contourne. Et ça va nous embêter plus tard.

Le deuxième élément, c’est un Minio. Si vous avez l’habitude de stocker vos objets multimédias dans un S3, c’est la même chose, mais en open-source.
Et pour organiser tout ça, on va utiliser `docker-compose`. On pourra ainsi l’utiliser sur sa machine en local, et l’exposer facilement sur un VPS à distance. Et voilà à quoi ressemble mon fichier docker-compose. 

services:
 postgres:
   image: postgres:16
   environment:
     POSTGRES_DB: ${POSTGRES_DB:-strapi}
     POSTGRES_USER: ${POSTGRES_USER:-strapi}
     POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-strapi}
   ports:
     - "5432:5432"
   volumes:
     - postgres_data:/var/lib/postgresql/data
   restart: unless-stopped


 minio:
   image: minio/minio:latest
   command: server /data --console-address ":9001"
   environment:
     MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
     MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
   ports:
     - "9000:9000"
     - "9001:9001"
   volumes:
     - minio_data:/data
   restart: unless-stopped


 nextjs:
   image: node:20-alpine
   working_dir: /app
   command: sh -c "npm install --include=dev && npm run build && npm start"
   ports:
     - "3000:3000"
   volumes:
     - ./site-statique:/app
   environment:
     - NODE_ENV=production
   restart: unless-stopped


volumes:
 postgres_data:
 minio_data:

Vous pouvez retrouver cet état avec le tag `v1-infra-locale`.

Installer Strapi simplement.

Maintenant on va installer Strapi. Alors ça une seule ligne de commande. Mais il faut faire attention. Strapi peut se brancher sur un sqlite par défaut. On va donc bien le forcer à utiliser notre SGBD.

npx create-strapi-app@latest cms --quickstart --no-run 

Il faudra ensuite aller dans le `.env` pour configurer les éléments de la base de données. Vous pourrez remarquer que le wizard a déjà amorcé des sels de chiffrement etc…

# Server
HOST=0.0.0.0
PORT=1337

# Secrets
APP_KEYS=GLKsG/Yxcy4FI+T19oy8/w==,BkW6TccT8HL18d....
API_TOKEN_SALT=1PhzxBWfccXB9W4jtJKjiA==
ADMIN_JWT_SECRET=//8+tkod07AEm8BWvnO9iw==
TRANSFER_TOKEN_SALT=8f0TNdW63xy5BUexFmfd7Q==
ENCRYPTION_KEY=Jc/mweC4gLoOlX28Prvd1w==

# Database
DATABASE_CLIENT=postgres
DATABASE_HOST=postgres
DATABASE_PORT=5432
DATABASE_NAME=strapi
DATABASE_USERNAME=strapi
DATABASE_PASSWORD=strapi
DATABASE_SSL=false

Il faut vraiment indiquer des choses plus intelligentes que dans ce stub. Et, pensez à vous assurer que le gitignore va bien exclure ce fichier.

Et, bientôt, vous pourrez trouver votre backoffice sur https://localhost:1337.
Votre port 3000 était déjà sûrement occupé par votre serveur frontal. Et vous êtes Leet donc 1337.

écran d'accueil de Strapi. Vous pouvez saisir, prénom nom, email et mot de passe.Alors attention ! Si vous avez lancé votre Strapi dans Docker, avec un `npm run start` vous ne pourrez pas créer d’utilisateur. Le Strapi est relativement verrouillé, il ne permet pas de créer de nouveaux types de contenu, par exemple. Et, c’est plutôt bien, je vais vous expliquer pourquoi plus tard.
Quand on veut modifier le schéma des données, on utilise `npm run develop`et le serveur passe en mode full-édition.

Si votre docker-compose a un peu de mal, pensez à utiliser l’image `node:20`plutôt que la `node:20-alpine`. La version alpine est très minimaliste, et le serveur a besoin de 500 à 700mo de mémoire vive. J’ai mis un Dockerfile aussi sur mon projet pour que le build de Strapi soit géré et caché.
Vous pouvez retrouver cet état sur Github avec le tag : `v2-strapi` : https://github.com/thedamfr/cv-strapi-tutorial/tree/v2-strapi

J’ai aussi dû m’assurer que Colima (runtime Docker pour MacOS) avait plus de 2Go de RAM.

Enfin n’oubliez pas que vous ne pourrez pas faire de récupération de mot de passe sans serveur mail. Par exemple, si, comme moi, vous créez un accès admin à des copains pour qu’ils saisissent du contenu. Il faut leur envoyer le mot de passe via Whatsapp. Le serveur ne leur enverra pas d’e-mail.

Leçon numéro 2 : configurez Minio avec s3cmd.

On avance dans le tutoriel Strapi, et maintenant, on va maintenant brancher le bucket Minio avec Strapi. Et la première fois, j’ai bloqué un certain temps. J’avais beau le faire correctement, Strapi trouvait toujours une erreur. Il faisait la liste des buckets et trouvait un tableau vide.
Pourtant mes “policy” étaient bien, le connecteur aussi.

Le problème c’est que j’avais créé mon bucket avec les outils ligne de commande de Minio. On va donc s’assurer d’utiliser ceux de AWS S3 et en particulier `s3cmd` ou `aws-cli`.
Si vous déployez en production avec un stockage équivalent à S3 comme celui de Clever-Cloud ou de Scaleway, il faudra faire la même chose.

Avant de partir, on prépare la todolist : 

  • Créer le bucket `strapi-uploads`
  • Ajouter un connecteur dans le `plugin.ts`
  • Ajouter une Content Service Policy dans le `middleware.ts`

Vous pouvez retrouver la bucket policy sur Github avec le tag `v3-strapi-minio` : https://github.com/thedamfr/cv-strapi-tutorial/tree/v3-strapi-minio

Pour le `plugin.ts` voici ma configuration :

export default ({ env }) => {
 // En production Docker, utiliser le réseau interne 'minio:9000'
 // En développement local, utiliser 'localhost:9000'
 const isProduction = env('NODE_ENV') === 'production';


 return {
   upload: {
     config: {
       provider: 'aws-s3',
       providerOptions: {
         baseUrl: env('AWS_CDN_URL', `http://localhost:9000/${env('AWS_BUCKET', 'strapi-uploads')}`),
         s3Options: {
           credentials: {
             accessKeyId: env('AWS_ACCESS_KEY_ID', 'minioadmin'),
             secretAccessKey: env('AWS_SECRET_ACCESS_KEY', 'minioadmin'),
           },
           region: env('AWS_REGION', 'us-east-1'),
           endpoint: isProduction
             ? env('AWS_ENDPOINT', 'http://minio:9000')
             : env('AWS_ENDPOINT', 'http://localhost:9000'),
           forcePathStyle: true,
           tls: false,
           bucketEndpoint: false,
         },
         params: {
           Bucket: env('AWS_BUCKET', 'strapi-uploads'),
         },
       },
       sizeLimit: 10 * 1024 * 1024, // 10MB
       mimeTypes: [
         'image/jpeg',
         'image/png',
         'image/gif',
         'image/svg+xml',
         'image/webp',
         'application/pdf',
       ],
     },
   },
 };
};

Pour le `middleware.ts`, l’important est de modifier la Content Service Policy pour que le serveur Strapi accède au bucket, ce n’est pas sur son domaine.

export default [
 'strapi::logger',
 'strapi::errors',
 {
   name: 'strapi::security',
   config: {
     contentSecurityPolicy: {
       useDefaults: true,
       directives: {
         'connect-src': ["'self'", 'https:'],
         'img-src': [
           "'self'",
           'data:',
           'blob:',
           'https://market-assets.strapi.io',
           'http://localhost:9000', // MinIO en dev
           'http://minio:9000', // MinIO en prod Docker
         ],
         'media-src': [
           "'self'",
           'data:',
           'blob:',
           'http://localhost:9000',
           'http://minio:9000',
         ],
         upgradeInsecureRequests: null,
       },
     },
   },
 },
 'strapi::cors',
 'strapi::poweredBy',
 'strapi::query',
 'strapi::body',
 'strapi::session',
 'strapi::favicon',
 'strapi::public',
];

Sur le backoffice vous allez pouvoir aller dans la section “Média Library” pour uploader de nouvelles photos de CV.

Capture d'écran de la modale d’upload.

Si le bucket est bien configuré, vous pourrez les retrouver en librairie même après avoir redémarré le serveur. Sinon, vérifiez la Content Service Policy de Strapi ou la Policy du bucket.

On voit les 5 photos de profil en thumbnail dans StrapiÀ l’action : on va brancher la photo de CV sur le Strapi !

On commence à saisir le contenu et c’est assez excitant. Si vous êtes à ce niveau du tutoriel Strapi, le plus gros est passé.

Rappelez-vous, on préfère transformer en champ CMS le contenu d’une page en dur que l’inverse. Donc ici on va commencer par un bout, la photo de CV. Ça nous donnera l’occasion d’ajouter le service Next qui consomme Strapi et de voir comment le contenu va s’injecter.

On va d’abord créer la page, le CV. C’est un “Content-type” qui n’est pas une collection, c’est donc un “Single Type”. On va l’appeler “Resume”. 

Modale “resume”

Et on va lui donner un champ Avatar de type “Media Field”.

Modale “New Media Field”

Et, vous verrez dans vos fichiers, sur le répertoire `cms/src/api`, il y a tous vos content-types avec chacun un schema.json.

{
 "kind": "singleType",
 "collectionName": "resumes",
 "info": {
   "singularName": "resume",
   "pluralName": "resumes",
   "displayName": "Resume"
 },
 "options": {
   "draftAndPublish": true
 },
 "pluginOptions": {},
 "attributes": {
   "avatar": {
     "type": "media",
     "multiple": false,
     "allowedTypes": [
       "images",
       "files",
       "videos",
       "audios"
     ]
   }
 }
}


Ce fichier doit être ajouté au versionning correctement.
Parce que vous vous dites sûrement : “Attends mais je vais devoir saisir tous les champs une fois en dev, et une fois en prod ?”. Et bien non. Strapi en production va écraser son environnement pour qu’il s’adapte au code. C’est pas génial ?

Et, comme on a le front et le CMS sur le même repo, on peut avoir les évolutions de schéma dans le même commit.

Pour continuer, dans le répertoire de Next on va rajouter `strapi.ts` : 

const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || 'http://localhost:1337';


interface StrapiMedia {
 id: number;
 url: string;
 alternativeText?: string;
 width: number;
 height: number;
 formats?: {
   thumbnail?: { url: string };
   small?: { url: string };
   medium?: { url: string };
   large?: { url: string };
 };
}


interface StrapiResume {
 data: {
   id: number;
   documentId: string;
   avatar?: StrapiMedia;
   createdAt: string;
   updatedAt: string;
   publishedAt: string;
 };
}


/**
* Récupère les données du resume depuis Strapi
*/
export async function getResume(): Promise<StrapiResume | null> {
 try {
   const res = await fetch(`${STRAPI_URL}/api/resume?populate=avatar`, {
     next: { revalidate: 60 }, // Revalidate toutes les 60 secondes
   });


   if (!res.ok) {
     console.warn('Failed to fetch resume from Strapi:', res.statusText);
     return null;
   }


   const data = await res.json();
   console.log('Resume data from Strapi:', JSON.stringify(data, null, 2));
   return data;
 } catch (error) {
   console.error('Error fetching resume from Strapi:', error);
   return null;
 }
}


/**
* Extrait l'URL de l'avatar depuis les données du resume
*/
export function getAvatarUrl(resume: StrapiResume | null): string | null {
 if (!resume?.data?.avatar) {
   console.log('No avatar data found in resume');
   return null;
 }


 const avatarUrl = resume.data.avatar.url;
 console.log('Avatar URL from Strapi:', avatarUrl);
  // Si l'URL est relative, la préfixer avec l'URL Strapi
 if (avatarUrl.startsWith('/')) {
   const fullUrl = `${STRAPI_URL}${avatarUrl}`;
   console.log('Full avatar URL:', fullUrl);
   return fullUrl;
 }


 return avatarUrl;
}

Et dans le fichier `page.tsx` on va juste l’invoquer succinctement : 

// Récupérer les données du resume depuis Strapi
 const resume = await getResume();
 const avatarUrl = getAvatarUrl(resume);
  return (
   <div className="min-h-screen bg-gray-50">
     <main className="max-w-4xl mx-auto px-6 py-12">
       {/* Header */}
       <header className="mb-12 flex items-start gap-6">
         <Image
           src={avatarUrl || "/resume-pic-CK.png"}
           alt="Clark Kent"
           width={128}
           height={128}
           className="rounded-full shadow-lg object-cover flex-shrink-0"
           priority
         />

Et, là vous allez tomber sur l’erreur : “Failed to fetch resume from Strapi: Forbidden”

Ça tombe bien, on a un truc à apprendre ici.

Leçon n°3 : les permissions Strapi, c’est très strict.

Et, c’est bien. Strapi gère de manière très stricte qui a le droit de trouver du contenu, de le voir, et de le modifier.
Quand on crée un nouveau content-type, il faut toujours prendre le temps de l’exposer.

Suivez le chemin : Settings → Users & Permissions Plugin → Roles

Votre front a le rôle Public et il faut lui donner le droit de “find” sur “Resume”

Vue Permissions, on y voit la catégorie Resume avec les droits “delete”, “find” et “update”.

Vue Permissions, on y voit la catégorie Resume avec les droits “delete”, “find” et “update”.

Il faudra aussi configurer `next.config.ts` pour qu’il autorise le bucket (et le localhost).

Si vous avez un doute, tout le code est sur le tag `v4-avatar` : https://github.com/thedamfr/cv-strapi-tutorial/tree/v4-avatar

Tutoriel Strapi : la capture d’écran du CV en ligne, la photo a changé.

Capture d’écran du CV en ligne, la photo a changé.

Le système de permission est très largement extensible !
Imaginez que vous ayez plusieurs interfaces. Par exemple, en plus de la Prod, vous avez une Pré-prod avec un rôle différent.
Ou bien vous avez un portail pour vos clients B2B en plus du site vitrine. Vous pourriez lui donner un rôle spécifique et accès à des données spécifiques.
Sur un blog, si vous voulez ajouter une interface pour vos rédacteurs, vous pouvez créer un rôle et permettre aux rédacteurs de modifier certains contenus directement depuis le front avec le rôle “update”.

Ajoutons un champ texte !

On continue dans ce tutoriel Strapi, sauf que ce coup-ci, on va pas utiliser l’interface, on va utiliser le code.
Allez dans le schema.json est ajoutez :

"fullName": { "type": "string" }

Pas besoin de redémarrer le serveur, le champ est apparu sur votre interface et vous pouvez remplir.

tutoriel Strapi : la capture d’écran du Content Manager, il y a bien un nouveau champ appelé fullName.Maintenant dans le front, on va ajouter le fait de consommer ce champ sur l’API et de l’injecter dans le fichier `.tsx`

Dans le service, on va surtout remplacer le `populate=avatar` pour un populate=*. On ne veut surtout pas faire un appel par champ, tout peut-être récupéré en une fois

/**
* Récupère les données du resume depuis Strapi
*/
export async function fetchResumeFromStrapi(): Promise<StrapiResume | null> {
 try {
   const res = await fetch(`${STRAPI_URL}/api/resume?populate=*`, {
     next: { revalidate: 60 }, // Revalidate toutes les 60 secondes
   });


   if (!res.ok) {
     console.warn('Failed to fetch resume from Strapi:', res.statusText);
     return null;
   }


   return res.json();
 } catch (error) {
   console.error('Error fetching resume from Strapi:', error);
   return null;
 }
}

On ajoute la variable proprement dans le fichier `page.tsx` et le serveur Next va se rafraichir automatiquement.

tutoriel Strapi : la capture d’écran du site publique, le nom a bien changé.

Capture d’écran du site publique, le nom a bien changé.

C’était simple ? Oui, alors maintenant on va faire tous les champs d’un coup, en modifiant le `schema.json`. L’état actuel est sur github avec le tag `v5-fullname` : https://github.com/thedamfr/cv-strapi-tutorial/tree/v5-fullname 

Dernière étape du tutoriel Strapi : transformer l’ensemble de la page en contenu CMS.

Pour la suite du CV, il y a des éléments comme les compétences ou les expériences qui peuvent être implémentés de deux manières différentes.

La première solution serait de faire un Collection Type. C’est pertinent si cet objet va servir à plusieurs endroits dans le site web. Par exemple, si pour les projets on veut qu’ils aient une page dédiée ensuite avec plus d’éléments, ou qu’ils apparaissent sur une autre page “Portfolio”. À ce moment-là, on leur dédierait un Collection Type “Projects”, avec les champs appropriés, et on appellerait ce endpoint dans le front à différents endroits. On pourrait avoir un “Single Type” appelé “Porfolio” qui comprend plusieurs “Projects” et les sélectionner un par un.

Ici le site est assez simple alors on va retenir l’autre solution : les Composants Répétables.
On va les ajouter comme “Components”, avec tout ce qu’il faut dans chaque composant.

tutoriel Strapi : capture d’écran d’un Component.

Capture d’écran d’un Component.

Ceux-ci peuvent être ajoutés au Single Type appelé Resume. Il faut indiquer ces composants comme “Repeatable”.

tutoriel Strapi : le Content Type Resume comprend maintenant tous les champs.

Le Content Type Resume comprend maintenant tous les champs.

Pour saisir le contenu, on peut se rendre dans la section “Content Manager”. Les champs sont tous vide, on a besoin d’un peu de temps et d’imagination pour les compléter. On a choisi une autre photo pour que ça corresponde mieux au texte.

tutoriel Strapi : le Content Manager montre tous les nouveaux champs

Le Content Manager montre tous les nouveaux champs

Il suffit de rafraîchir et on voit bien les changements sur le site web. Voilà !

tutoriel Strapi : tout le contenu est bien reflété sur le site web.

Tout le contenu est bien reflété sur le site web.

Cette version complètement dynamique est bien disponible sur github avec le tag `v6-complete-resume`.

Qu’est-ce qu’on a appris aujourd’hui avec ce tutoriel Strapi ?

On fait le point rapidement avant de conclure.

Si vous devez retenir que quelques éléments de ce tutoriel Strapi: 

  1. Commencer par le front change tout. En commençant par un site statique cohérent, on évite de transformer le front en un patchwork de contenus dictés par le CMS.
  2. Un headless CMS n’est pas “plug and play”. Permissions, build, stockage, Content Service Policy, tout est cadré dans Strapi. Ça le rend très fiable en production et extensible pour des usages “Entreprise”. D’ailleurs on aurait pu aller plus loin en ajoutant une clé API pour s’assurer que seul notre front consomme le contenu CMS.
  3. Le schéma du CMS est dans le code. Versionné et facile à déployer. Pas besoin de ressaisir les éléments dans chaque environnement. Cela vous permet de rollback facilement aussi.

Comment on va en prod maintenant ? Le docker-compose doit être facile à envoyer sur un VPS. Sinon, mon infra précédente, je l’avais montée sur CleverCloud avec un Cellar pour stocker les médias. Et, ça marche avec la même configuration.

Maintenant qu’on a fait tout ça, soyons honnêtes, ça demande beaucoup d’infrastructure si on ne va pas aller modifier le contenu régulièrement. On aurait pu rajouter un Blog complet. C’est un Content-Type et de nouvelles vues à ajouter dans Next. Cela prend quelques heures de manipulation. Je vous encourage à le faire vous-même. Et, là ça aurait eu plus de sens de déployer un CMS.
En tout cas, je ne pense pas utiliser WordPress là où un Strapi peut suffire.

Bref, on sera contents de savoir ce que vous construisez suite à ce tutoriel Strapi. Pensez à nous partager ça sur notre page LinkedIn.

Damien Cavaillès

Auteur Damien Cavaillès

Plus d'articles par Damien Cavaillès

Laisser un commentaire