
Dans ce tutoriel, je vais vous guider pas à pas pour créer un blog avec Next.js et Sanity comme headless CMS pour la gestion du contenu, et des techniques SEO adaptées pour booster la visibilité de votre blog sur les moteurs de recherche.
Prérequis
Avant de commencer, assurez-vous d’avoir les éléments suivants :
- Node.js et npm installés sur votre machine.
- Un compte Sanity.io. Créez-en un gratuitement sur Sanity.
Étape 1 : Créer un projet Next.js
Nous allons démarrer par la création de notre projet avec Next.js. Ce framework React est parfait pour créer des applications web performantes, car il permet le rendu côté serveur (SSR) et la génération de pages statiques (SSG), ce qui est idéal pour le SEO. Nous utiliserons Tailwind CSS pour le styling.
Lancez la commande suivante pour créer un nouveau projet Next.js que je nommerai foody-blog:
npx create-next-app@latest foody-blog
cd foody-blog
Étape 2 : Initialiser Sanity
Une fois votre projet Next.js prêt, il est temps de configurer Sanity. Sanity est un CMS headless, ce qui signifie qu’il sépare la gestion de contenu de la présentation (front-end). Il est très flexible et parfaitement adapté à Next.js pour des projets dynamiques comme un blog.
Lancez la commande suivante pour initialiser Sanity :
npx sanity@latest init
Suivez les étapes de l’interface pour créer un nouveau projet, puis sélectionnez le modèle « Schéma de Blog ». Vous aurez ainsi un projet de blog de base avec un modèle de contenu.
Étape 3 : Personnaliser le Schéma de Données
Dans Sanity, Le schéma de données définit la structure des données que vous allez stocker dans la base de données. Chaque schéma est comme une « table » dans une base de données relationnelle. Ces tables contiennent des champs spécifiques qui permettront de structurer et d’organiser les informations sur vos articles. Le schéma de données par défaut de Sanity est déjà bien adapté pour un blog, mais vous pouvez le personnaliser en ajoutant ou en modifiant des champs en fonction des besoins de votre projet. Dans ce tutoriel, mes articles de blog contiennent un titre, un slug, une description, des catégories, des tags, des mots-clés SEO, un auteur, une image, une date de publication et le contenu de l’article. On définit les types nécessaires (Article, Contenu, Auteur, Catégorie, Tag) et leurs champs dans le répertoire schemaTypes de votre projet Sanity.
Voici le schéma pour un article de blog :
import { defineType, defineField } from 'sanity';
export default defineType({
name: 'post',
title: 'Post',
type: 'document',
fields: [
defineField({
name: 'title',
title: 'Title',
type: 'string',
validation: Rule => Rule.required(),
}),
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
options: {
source: 'title',
maxLength: 96,
},
validation: Rule => Rule.required(),
}),
defineField({
name: 'description',
title: 'Description',
type: 'text',
validation: Rule => Rule.max(300),
}),
defineField({
name: 'body',
title: 'Body',
type: 'blockContent',
}),
defineField({
name: 'author',
title: 'Author',
type: 'reference',
to: [{ type: 'author' }],
}),
defineField({
name: 'categories',
title: 'Categories',
type: 'array',
of: [{ type: 'reference', to: [{ type: 'category' }] }],
}),
defineField({
name: 'tags',
title: 'Tags',
type: 'array',
of: [{ type: 'reference', to: [{ type: 'tag' }] }],
}),
defineField({
name: 'mainImage',
title: 'Main Image',
type: 'image',
options: { hotspot: true },
}),
defineField({
name: 'publishedAt',
title: 'Published at',
type: 'datetime',
}),
defineField({
name: 'queries',
title: 'SEO Queries',
type: 'array',
of: [
{
type: 'object',
fields: [
{
name: 'query',
title: 'SEO Query',
type: 'string',
},
],
},
],
}),
],
preview: {
select: {
title: 'title',
author: 'author.name',
media: 'mainImage',
body: 'body',
},
prepare(selection) {
const { author, body } = selection;
const words = body ? body.reduce((acc, block) => {
if (block._type === 'block' && block.children) {
return acc + block.children.map(child => child.text).join(' ').split(' ').length;
}
return acc;
}, 0) : 0;
return {
...selection,
subtitle: `${author ? `by ${author} — ` : ''}`,
};
},
},
});
Schéma du Contenu de Bloc
Pour le contenu de l’article, nous allons définir un schéma « blockContent » qui permet d’utiliser des blocs de texte et des images :
import { defineArrayMember, defineType } from 'sanity';
export default defineType({
title: 'Block Content',
name: 'blockContent',
type: 'array',
of: [
defineArrayMember({
title: 'Block',
type: 'block',
styles: [
{ title: 'Normal', value: 'normal' },
{ title: 'H1', value: 'h1' },
{ title: 'H2', value: 'h2' },
{ title: 'H3', value: 'h3' },
{ title: 'Quote', value: 'blockquote' },
],
lists: [{ title: 'Bullet', value: 'bullet' }],
marks: {
decorators: [
{ title: 'Strong', value: 'strong' },
{ title: 'Emphasis', value: 'em' },
],
annotations: [
{
title: 'URL',
name: 'link',
type: 'object',
fields: [{ title: 'URL', name: 'href', type: 'url' }],
},
],
},
}),
defineArrayMember({
type: 'image',
options: { hotspot: true },
}),
],
});
Schéma de l’Auteur
Le schéma de l’auteur contient des informations sur le nom de l’auteur, son image, et sa biographie :
import { defineField, defineType } from 'sanity';
export default defineType({
name: 'author',
title: 'Author',
type: 'document',
fields: [
defineField({
name: 'name',
title: 'Name',
type: 'string',
}),
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
options: {
source: 'name',
maxLength: 96,
},
}),
defineField({
name: 'image',
title: 'Image',
type: 'image',
options: {
hotspot: true,
},
}),
defineField({
name: 'bio',
title: 'Bio',
type: 'array',
of: [
{
title: 'Block',
type: 'block',
styles: [{ title: 'Normal', value: 'normal' }],
},
],
}),
],
preview: {
select: {
title: 'name',
media: 'image',
},
},
});
Schémas Tags et Catégories
Les schémas de tags et de catégories sont similaires et simples, ils contiennent uniquement un champ name
et un champ slug
.
import { defineField, defineType } from 'sanity';
export default defineType({
name: 'tag',
title: 'Tag',
type: 'document',
fields: [
defineField({
name: 'name',
title: 'Name',
type: 'string',
validation: Rule => Rule.required()
}),
],
});
Étape 4 : Connexion de Sanity avec Next.js
Une fois votre schéma configuré, vous devez intégrer Sanity dans votre projet Next.js pour récupérer les articles et les afficher.
- Allez dans le tableau de bord de Sanity et copiez le nom du projet et l’ID du projet.
- Ajoutez ces informations dans votre fichier
.env.local
dans le projet Next.js :
NEXT_PUBLIC_SANITY_PROJECT_ID=your_project_id
NEXT_PUBLIC_SANITY_PROJECT_TITLE=your_project_name
Dans votre projet Next.js le client Sanity devrait être configuré par défaut, si ce n’est pas le cas , créez un fichier sanity/lib/client.js
pour configurer le client Sanity :
import { createClient } from 'next-sanity';
import { apiVersion, dataset, projectId } from '../env';
export const client = createClient({
projectId,
dataset,
apiVersion,
useCdn: true,
});
Sanity Studio
Sanity Studio est l’interface d’administration qui vous permet de gérer ajouter, modifier et supprimer les articles et les données que vous avez définies dans vos schémas (articles, auteurs, catégories, etc.). Vous pouvez accéder à Sanity Studio via votresite.com/studio ou via http://localhost:3333
Étape 5 : Récupérer les Articles de Sanity
Maintenant, nous allons utiliser GROQ (Graph-Relational Object Queries), un langage de requête puissant pour interroger la base de données Sanity et récupérer les données.
Créez un fichier sanity/lib/queries.js avec cette fonction pour récupérer tous les articles :
import { client } from '@/sanity/lib/client';
export async function getAllPosts() {
const query = `*[_type == "post"] | order(publishedAt desc) {
title,
slug { current },
description,
tags[]-> { title },
readingTime,
"authorName": author->name,
"mainImage": mainImage.asset->url,
"categories": categories[]->{title},
publishedAt,
body
}`;
try {
return await client.fetch(query);
} catch (error) {
return [];
}
}
Étape 6 : Afficher les Articles dans Next.js
Dans votre composant ou page Next.js récupérez les articles et affichez-les :
import ArticleCard from '@/components/ArticleCard';
import { getAllPosts } from '@/sanity/lib/queries';
export default async function HomePage() {
const articles = await getAllPosts();
return (
<div>
<div className="max-w-7xl mx-auto p-6 md:flex">
<div className="w-full flex flex-col items-center font-bold">
<h1 className="text-primary p-6 text-2xl pb-8">
Nos dernières recettes
</h1>
<main className="w-full grid grid-cols-1 md:grid-cols-2 lg:md:grid-cols-3 lg:gap-12 gap-6">
{articles.length > 0 ? (
articles.map((article) => (
<ArticleCard
key={article.slug.current}
title={article.title}
description={article.description}
imageUrl={article.mainImage}
slug={article.slug.current}
categories={article.categories}
/>
))
) : (
<p className="text-center">Aucun article disponible.</p>
)}
</main>
</div>
Afficher un article détaillé :
Créez un fichier pour la page dynamique de chaque article de blog. Cela se fait dans articles/[slug]/page.js
Dans ce fichier, vous allez récupérer les données de l’article en utilisant la fonction getArticleBySlug
et afficher le contenu avec PortableText afin de personnaliser l’affichage du contenu structuré de Sanity, tel que des paragraphes, des images, des titres, etc. Enfin dans la fonction generateMetadata
nous configurons les métadonnées de la page (title, description, OpenGraph, et Twitter) pour améliorer le SEO de l’article.
Installer la bibliothèque PortableText :
npm install @portabletext/react
Voici le code du fichier articles/[slug]/page.js:
import { getArticleBySlug } from '@/sanity/lib/queries';
import { urlFor } from '@/sanity/lib/image';
import { notFound } from 'next/navigation';
import { PortableText } from '@portabletext/react';
import Navbar from '@/components/Navbar';
import Footer from '@/components/Footer';
const myPortableTextComponents = {
types: {
image: ({ value }) => (
<img
src={urlFor(value).width(800).url()}
alt={value.alt || 'Image'}
className="w-full object-cover rounded-lg"
/>
),
},
block: {
h1: ({ children }) => <h1 className="text-4xl font-bold my-4 text-gray-800">{children}</h1>,
h2: ({ children }) => <h2 className="text-3xl font-bold my-3 text-gray-700">{children}</h2>,
h3: ({ children }) => <h3 className="text-2xl font-bold my-2 text-gray-600">{children}</h3>,
normal: ({ children }) => <p className="text-lg my-2 text-gray-700 text-justify">{children}</p>,
blockquote: ({ children }) => (
<blockquote className="border-l-4 pl-4 italic my-4 text-gray-600 bg-gray-100 p-3">
{children}
</blockquote>
),
ul: ({ children }) => <ul className="list-disc pl-6 text-gray-700 space-y-1">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal pl-6 text-gray-700 space-y-1">{children}</ol>,
li: ({ children }) => <li className="ml-4">{children}</li>,
code: ({ children }) => (
<pre className="bg-gray-900 text-white p-4 rounded-md overflow-x-auto">
<code className="font-mono">{children}</code>
</pre>
),
},
marks: {
strong: ({ children }) => <strong className="font-bold text-gray-900">{children}</strong>,
em: ({ children }) => <em className="italic text-gray-700">{children}</em>,
link: ({ value, children }) => (
<a
href={value.href}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
{children}
</a>
),
},
listItem: {
bullet: ({ children }) => <li className="text-lg text-gray-700 text-justify">{children}</li>,
number: ({ children }) => <li className="text-lg text-gray-700 text-justify">{children}</li>,
},
types: {
table: ({ value }) => (
<div className="overflow-x-auto my-4">
<table className="w-full border-collapse border border-gray-300">
<thead className="bg-gray-200">
<tr>
{value.rows[0].cells.map((cell, index) => (
<th key={index} className="border border-gray-300 p-2 text-left">
{cell}
</th>
))}
</tr>
</thead>
<tbody>
{value.rows.slice(1).map((row, rowIndex) => (
<tr key={rowIndex} className="odd:bg-gray-50">
{row.cells.map((cell, cellIndex) => (
<td key={cellIndex} className="border border-gray-300 p-2">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
),
},
};
export async function generateMetadata({ params }) {
const { slug } = await params;
const post = await getArticleBySlug(slug);
if (!post) {
return notFound();
}
return {
title: post.title,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
url: `/articles/${slug}`,
images: post.imageUrl ? [{ url: urlFor(post.imageUrl).width(1200).url() }] : [],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.description,
images: post.imageUrl ? [urlFor(post.imageUrl).width(1200).url()] : [],
},
};
}
export default async function postPage({ params }) {
const { slug } = params;
const post = await getArticleBySlug(slug);
if (!post) {
return notFound();
}
return (
<div className="bg-gray-100 min-h-screen">
<Navbar />
<div className="max-w-4xl mx-auto p-4 lg:p-12 bg-white shadow-lg rounded-lg mt-10">
<div className="relative w-full h-80 overflow-hidden rounded-t-lg">
<img
src={post.imageUrl ? urlFor(post.imageUrl).width(1200).url() : '/default-image.jpg'}
alt={post.title}
className="w-full h-full object-cover"
/>
</div>
<div className="p-6 text-center">
<h1 className="text-4xl font-bold text-gray-800">{post.title}</h1>
<p className="text-gray-500 mt-2 text-sm">
Écrit par {post.author} • {new Date(post.publishedAt).toLocaleDateString()}
</p>
</div>
<div className="prose max-w-none px-6">
<PortableText value={post.body} components={myPortableTextComponents} />
</div>
</div>
<Footer />
</div>
);
}
Conclusion
Vous avez maintenant un blog complet avec Next.js pour le front-end et Sanity comme CMS headless. Ce projet est non seulement rapide et moderne, mais aussi optimisé pour le SEO grâce à l’intégration des métadonnées et des requêtes personnalisées. Vous pouvez facilement étendre ce blog en ajoutant plus de fonctionnalités comme filtrer les articles par catégories et des tags.
Le code source complet de ce projet est disponible sur mon GitHub. Vous pouvez y accéder via le lien suivant : GitHub – Sanity-Next-Blog.