🎯 Exemples recommandés
Balanced sample collections from various categories for you to explore
Exemples Sanity CMS Temps Réel
Exemples complets de Sanity couvrant le modelage de contenu, l'intégration d'API et les patterns de déploiement
💻 Modelage de Contenu Sanity javascript
🟡 intermediate
⭐⭐⭐
Définitions complètes de content types, configuration de schéma et modelage de documents pour divers cas d'usage
⏱️ 35 min
🏷️ sanity, content modeling, cms
Prerequisites:
Sanity basics, JavaScript, Schema concepts, Real-time content management
// Sanity Content Modeling Examples
// File: schemas/blogPost.js
export default {
name: 'blogPost',
title: 'Blog Post',
type: 'document',
fields: [
{
name: 'title',
title: 'Title',
type: 'string',
validation: (Rule) => Rule.required().min(10).max(100),
},
{
name: 'slug',
title: 'Slug',
type: 'slug',
options: {
source: 'title',
maxLength: 200,
},
},
{
name: 'excerpt',
title: 'Excerpt',
type: 'text',
rows: 3,
validation: (Rule) => Rule.max(300),
},
{
name: 'content',
title: 'Content',
type: 'array',
of: [
{ type: 'block' },
{ type: 'image', options: { hotspot: true } },
{ type: 'file' },
{ type: 'reference', to: [{ type: 'author' }, { type: 'category' }, { type: 'tag' }] },
],
},
{
name: 'featuredImage',
title: 'Featured Image',
type: 'image',
options: { hotspot: true },
fields: [
{
name: 'alt',
title: 'Alternative Text',
type: 'string',
},
{
name: 'caption',
title: 'Caption',
type: 'text',
},
],
},
{
name: 'gallery',
title: 'Gallery',
type: 'array',
of: [
{
type: 'image',
options: { hotspot: true },
},
],
},
{
name: 'author',
title: 'Author',
type: 'reference',
to: [{ type: 'author' }],
},
{
name: 'category',
title: 'Category',
type: 'reference',
to: [{ type: 'category' }],
},
{
name: 'tags',
title: 'Tags',
type: 'array',
of: [{ type: 'reference', to: [{ type: 'tag' }] }],
},
{
name: 'seo',
title: 'SEO',
type: 'seo',
fields: [
{
name: 'title',
title: 'Meta Title',
type: 'string',
description: 'Recommended: 50-60 characters',
validation: (Rule) => Rule.max(60),
},
{
name: 'description',
title: 'Meta Description',
type: 'text',
rows: 3,
description: 'Recommended: 50-160 characters',
validation: (Rule) => Rule.max(160),
},
{
name: 'keywords',
title: 'Keywords',
type: 'array',
of: [{ type: 'string' }],
},
{
name: 'image',
title: 'Social Share Image',
type: 'image',
description: 'Recommended: 1200x630px',
},
],
},
{
name: 'publishedAt',
title: 'Published Date',
type: 'datetime',
initialValue: () => new Date().toISOString(),
},
{
name: 'readingTime',
title: 'Reading Time',
type: 'number',
description: 'Reading time in minutes',
validation: (Rule) => Rule.min(1).positive(),
},
],
orderings: [
{
name: 'publishedAtDesc',
title: 'Published Date (Newest First)',
by: [{ field: 'publishedAt', direction: 'desc' }],
},
{
name: 'titleAsc',
title: 'Title (A-Z)',
by: [{ field: 'title', direction: 'asc' }],
},
],
preview: {
select: {
title: 'title',
slug: 'slug',
excerpt: 'excerpt',
featuredImage: 'featuredImage',
},
prepare: (selection) => ({
...selection,
url: `/blog/${selection.slug?.current}`,
}),
},
},
};
// File: schemas/product.js
export default {
name: 'product',
title: 'Product',
type: 'document',
fields: [
{
name: 'name',
title: 'Product Name',
type: 'string',
validation: (Rule) => Rule.required(),
},
{
name: 'slug',
title: 'Slug',
type: 'slug',
options: {
source: 'name',
maxLength: 200,
},
},
{
name: 'description',
title: 'Description',
type: 'text',
rows: 3,
},
{
name: 'shortDescription',
title: 'Short Description',
type: 'text',
rows: 2,
},
{
name: 'sku',
title: 'SKU',
type: 'string',
validation: (Rule) => Rule.required().regex(/^[A-Z0-9-]+$/),
},
{
name: 'price',
title: 'Price',
type: 'number',
validation: (Rule) => Rule.required().positive(),
},
{
name: 'comparePrice',
title: 'Compare Price',
type: 'number',
},
{
name: 'currency',
title: 'Currency',
type: 'string',
options: {
list: [
{ title: 'USD', value: 'USD' },
{ title: 'EUR', value: 'EUR' },
{ title: 'GBP', value: 'GBP' },
{ title: 'JPY', value: 'JPY' },
],
defaultValue: 'USD',
},
},
{
name: 'images',
title: 'Product Images',
type: 'array',
of: [
{
type: 'image',
options: { hotspot: true },
},
],
},
{
name: 'variants',
title: 'Product Variants',
type: 'array',
of: [
{
type: 'object',
fields: [
{
name: 'name',
title: 'Variant Name',
type: 'string',
},
{
name: 'sku',
title: 'SKU',
type: 'string',
},
{
name: 'price',
title: 'Price',
type: 'number',
validation: (Rule) => Rule.positive(),
},
{
name: 'color',
title: 'Color',
type: 'string',
},
{
name: 'size',
title: 'Size',
type: 'string',
},
{
name: 'image',
title: 'Variant Image',
type: 'image',
},
{
name: 'inventory',
title: 'Inventory',
type: 'number',
validation: (Rule) => Rule.min(0),
},
],
},
],
},
{
name: 'category',
title: 'Category',
type: 'reference',
to: [{ type: 'category' }],
validation: (Rule) => Rule.required(),
},
{
'name': 'tags',
'title': 'Tags',
'type': 'array',
'of': [{ 'type': 'reference', to: [{ 'type': 'tag' }] }],
},
{
name: 'attributes',
title: 'Product Attributes',
type: 'array',
of: [
{
type: 'object',
fields: [
{
name: 'name',
title: 'Attribute Name',
type: 'string',
},
{
name: 'value',
title: 'Attribute Value',
type: 'string',
},
],
},
],
},
{
name: 'inventory',
title: 'Inventory Information',
type: 'object',
fields: [
{
name: 'quantity',
title: 'Quantity',
type: 'number',
validation: (Rule) => Rule.min(0),
},
{
name: 'trackQuantity',
title: 'Track Quantity',
type: 'boolean',
initialValue: true,
},
{
name: 'allowBackorder',
title: 'Allow Backorder',
type: 'boolean',
initialValue: false,
},
{
name: 'lowStockThreshold',
title: 'Low Stock Threshold',
type: 'number',
},
],
},
{
name: 'shipping',
title: 'Shipping Information',
type: 'object',
fields: [
{
name: 'weight',
title: 'Weight (kg)',
type: 'number',
},
{
name: 'dimensions',
title: 'Dimensions (L×W×H)',
type: 'string',
},
{
name: 'requiresShipping',
title: 'Requires Shipping',
type: 'boolean',
initialValue: true,
},
],
},
{
name: 'isActive',
title: 'Active',
type: 'boolean',
initialValue: true,
},
],
orderings: [
{
name: 'nameAsc',
title: 'Name (A-Z)',
by: [{ field: 'name', direction: 'asc' }],
},
{
name: 'priceAsc',
title: 'Price (Low to High)',
by: [{ field: 'price', direction: 'asc' }],
},
{
name: 'priceDesc',
title: 'Price (High to Low)',
by: [{ field: 'price', 'direction: 'desc' }],
},
],
};
// File: schemas/author.js
export default {
name: 'author',
title: 'Author',
type: 'document',
fields: [
{
name: 'name',
title: 'Name',
type: 'string',
validation: (Rule) => Rule.required(),
},
{
name: 'email',
title: 'Email',
type: 'email',
validation: (Rule) => Rule.required(),
},
{
name: 'bio',
title: 'Bio',
type: 'text',
rows: 3,
},
{
name: 'avatar',
title: 'Avatar',
type: 'image',
options: {
hotspot: true,
},
fields: [
{
name: 'alt',
title: 'Alternative Text',
type: 'string',
},
],
},
{
name: 'social',
title: 'Social Media',
type: 'object',
fields: [
{
name: 'twitter',
title: 'Twitter',
type: 'url',
},
{
name: 'github',
title: 'GitHub',
type: 'url',
},
{
name: 'linkedin',
title: 'LinkedIn',
type: 'url',
},
{
name: 'website',
title: 'Website',
type: 'url',
},
],
},
{
name: 'role',
title: 'Role',
type: 'string',
},
{
name: 'expertise',
title: 'Areas of Expertise',
type: 'array',
of: [{ type: 'string' }],
},
],
};
// File: schemas/category.js
export default {
name: 'category',
title: 'Category',
type: 'document',
fields: [
{
name: 'name',
title: 'Name',
type: 'string',
validation: (Rule) => Rule.required(),
},
{
name: 'slug',
title: 'Slug',
type: 'slug',
options: {
source: 'name',
},
},
{
name: 'description',
title: 'Description',
type: 'text',
rows: 2,
},
{
name: 'color',
title: 'Color',
type: 'color',
options: {
disableAlpha: true,
},
},
{
name: 'icon',
title: 'Icon',
type: 'image',
},
{
name: 'parent',
title: 'Parent Category',
type: 'reference',
to: [{ type: 'category' }],
},
{
name: 'seo',
title: 'SEO',
type: 'seo',
fields: [
{
name: 'title',
title: 'Meta Title',
type: 'string',
},
{
name: 'description',
title: 'Meta Description',
type: 'text',
},
],
},
],
},
};
// File: schemas/tag.js
export default {
name: 'tag',
title: 'Tag',
type: 'document',
fields: [
{
name: 'name',
title: 'Name',
type: 'string',
validation: (Rule) => Rule.required(),
},
{
name: 'slug',
title: 'Slug',
type: 'slug',
options: {
source: 'name',
},
},
{
name: 'description',
title: 'Description',
type: 'text',
rows: 2,
},
{
name: 'color',
title: 'Color',
type: 'color',
options: {
disableAlpha: true,
},
},
],
};
// File: schemas/settings.js
export default {
name: 'settings',
title: 'Site Settings',
type: 'document',
fields: [
{
name: 'siteName',
title: 'Site Name',
type: 'string',
validation: (Rule) => Rule.required(),
},
{
name: 'siteUrl',
title: 'Site URL',
type: 'url',
validation: (Rule) => Rule.required(),
},
{
name: 'description',
title: 'Site Description',
type: 'text',
rows: 3,
},
{
name: 'logo',
title: 'Logo',
type: 'image',
options: { hotspot: true },
fields: [
{
name: 'darkModeLogo',
title: 'Dark Mode Logo',
type: 'image',
},
],
},
{
name: 'favicon',
title: 'Favicon',
type: 'image',
description: 'Recommended: 32×32 or 16×16 pixels',
},
{
name: 'ogImage',
title: 'Default Social Share Image',
type: 'image',
description: 'Recommended: 1200×630 pixels',
},
{
name: 'contact',
title: 'Contact Information',
type: 'object',
fields: [
{
name: 'email',
title: 'Email',
type: 'email',
},
{
name: 'phone',
title: 'Phone',
type: 'string',
},
{
name: 'address',
title: 'Address',
type: 'object',
fields: [
{
name: 'street',
title: 'Street',
type: 'text',
},
{
name: 'city',
title: 'city',
type: 'string',
},
{
name: 'country',
title: 'Country',
type: 'string',
},
{
name: 'postalCode',
title: 'Postal Code',
type: 'string',
},
],
},
],
},
{
name: 'social',
title: 'Social Media Links',
type: 'object',
fields: [
{
name: 'twitter',
title: 'Twitter',
type: 'url',
},
{
name: 'facebook',
title: 'Facebook',
type: 'url',
},
{
name: 'instagram',
title: 'Instagram',
type: 'url',
},
{
name: 'youtube',
title: 'YouTube',
type: 'url',
},
{
name: 'linkedin',
title: 'LinkedIn',
type: 'url',
},
],
},
{
name: 'analytics',
title: 'Analytics Settings',
type: 'object',
fields: [
{
name: 'googleAnalytics',
title: 'Google Analytics',
type: 'object',
fields: [
{
name: 'trackingId',
title: 'Tracking ID',
type: 'string',
},
{
name: 'enableEnhancedMeasurement',
title: 'Enable Enhanced Measurement',
type: 'boolean',
initialValue: true,
},
],
},
{
name: 'facebookPixel',
title: 'Facebook Pixel',
type: 'object',
fields: [
{
name: 'pixelId',
title: 'Pixel ID',
type: 'string',
},
{
name: 'enableAdvancedMatching',
title: 'Enable Advanced Matching',
type: 'boolean',
initialValue: true,
},
],
},
],
},
{
name: 'seo',
title: 'Global SEO Settings',
type: 'object',
fields: [
{
name: 'metaRobots',
title: 'Meta Robots',
type: 'string',
initialValue: 'index,follow',
},
{
name: 'googleSearchConsole',
title: 'Google Search Console Verification',
type: 'string',
description: 'Enter your verification code',
},
{
name: 'bingSiteAuth',
title: 'Bing Site Verification',
type: 'string',
description: 'Enter your verification code',
},
],
},
],
},
};
// File: plugins/visionPlugin.js
export const visionPlugin = {
name: 'sanity-plugin-media-library',
options: {
selectable: true,
sources: [
{
name: 'unsplash',
provider: 'unsplash',
},
{
name: 'pexels',
provider: 'pexels',
},
],
},
};
// File: plugins/tableOfContentsPlugin.js
export const tableOfContentsPlugin = {
name: '@sanity/plugin/table-of-contents',
options: {
minLevel: 2,
maxLevel: 4,
},
};
// File: plugins/altTextPlugin.js
export const altTextPlugin = {
name: '@sanity/plugin-asset-source-unsplash',
options: {
mode: 'auto',
},
};
💻 Intégration d'API Sanity javascript
🔴 complex
⭐⭐⭐⭐
Exemples complets d'intégration d'API incluant les requêtes GROQ, les abonnements en temps réel, configuration de client et implémentation frontend
⏱️ 40 min
🏷️ sanity, api, integration, frontend
Prerequisites:
Sanity basics, GROQ, React hooks, JavaScript ES6+, Real-time updates
// Sanity API Integration Examples
// File: lib/sanityClient.js - API Client Setup
import { createClient, createClient, groq } from '@sanity/client';
import { visionTool } from '@sanity/vision';
// Create clients
export const sanityClient = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET || 'production',
apiVersion: '2023-10-16',
useCdn: true,
perspective: 'published',
stega: {
studioUrl: process.env.NEXT_PUBLIC_SANITY_STUDIO_URL,
token: process.env.SANITY_STUDIO_TOKEN,
},
browser: {
projectApiVersion: '2023-10-16',
},
});
export const sanityConfig = {
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET || 'production',
projectId: process.env.npm_package_config_SANITY_PROJECT_ID,
apiVersion: '2023-10-16',
useCdn: process.env.NODE_ENV === 'production',
};
export const client = createClient(sanityConfig);
// Vision tool for image optimization
export const builder = imageUrlBuilder({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
dataset: process.env.NEXT_PUBLIC_SANITIZE_DATASET || 'production',
apiVersion: '2023-10-16',
});
// File: lib/groqQueries.js - Predefined GROQ Queries
export const groqQueries = {
// Blog posts with population
getBlogPosts: groq`
*[_type == "blogPost"] {
_id,
title,
slug,
excerpt,
content,
publishedAt,
readingTime,
"author": author->{
_id,
name,
email,
avatar,
bio,
role,
},
"category": category->{
_id,
name,
slug,
color,
"parent": parent->{
name,
slug,
},
},
"tags": tags[]->{
_id,
name,
slug,
color,
},
featuredImage,
gallery,
seo {
metaTitle,
metaDescription,
keywords,
},
} | order(publishedAt desc)
`,
getBlogPostBySlug: (slug) => groq`
*[_type == "blogPost" && slug == ${slug}] {
_id,
title,
slug,
content,
publishedAt,
readingTime,
"author": author->{
_id,
name,
email,
avatar,
bio,
social,
},
"category": category->{
_id,
name,
slug,
description,
color,
},
"tags": tags[]->{
_id,
name,
slug,
},
featuredImage,
gallery,
seo {
metaTitle,
metaDescription,
keywords,
image,
},
}
`,
getRelatedBlogPosts: (categoryId, currentPostId, limit = 3) => groq`
*[_type == "blogPost" && _id != ${currentPostId}] {
_id,
title,
slug,
excerpt,
publishedAt,
"category": category->{
_id,
name,
slug,
},
featuredImage,
}[${limit}] | order(publishedAt desc)
`,
getFeaturedBlogPosts: (limit = 5) => groq`
*[_type == "blogPost" && isFeatured == true] {
_id,
title,
slug,
excerpt,
publishedAt,
"author": author->{
name,
avatar,
},
featuredImage,
}[${limit}] | order(publishedAt desc)
`,
getBlogPostsByCategory: (categoryId, limit = 10) => groq`
*[_type == "blogPost" && category._ref == ${categoryId}` {
_id,
title,
slug,
excerpt,
publishedAt,
"author": author->{
name,
avatar,
},
featuredImage,
}[${limit}] | order(publishedAt desc)
`,
// Products with population
getProducts: groq`
*[_type == "product"] {
_id,
name,
slug,
shortDescription,
description,
sku,
price,
comparePrice,
currency,
images,
variants,
category,
"brand": brand->{
_id,
name,
logo,
website,
},
tags,
attributes,
isActive,
} | order(name asc)
`,
getProductBySlug: (slug) => groq`
*[_type == "product" && slug == ${slug}] {
_id,
name,
slug,
description,
shortDescription,
sku,
price,
comparePrice,
currency,
images,
variants,
inventory,
shipping,
category,
"brand": brand->{
_id,
name,
logo,
website,
},
tags,
attributes,
isActive,
}
`,
getProductsByCategory: (categoryId) => groq`
*[_type == "product" && category._ref == ${categoryId}` {
_id,
name,
slug,
shortDescription,
price,
currency,
images,
category,
isActive,
} | order(name asc)
`,
// Categories
getCategories: groq`
*[_type == "category"] {
_id,
name,
slug,
description,
color,
icon,
"parent": parent->{
name,
slug,
},
count: count(*[_type == "blogPost" && references(^.^\.category._ref) == ${_id}]),
} | order(name asc)
`,
// Authors
getAuthors: groq`
*[_type == "author"] {
_id,
name,
email,
bio,
avatar,
social,
role,
expertise,
postCount: count(*[_type == "blogPost" && references(^.^\.author._ref) == ${_id}]),
}
`,
// Tags
getTags: groq`
*[_type == "tag"] {
_id,
name,
slug,
description,
color,
postCount: count(*[_type == "blogPost" && references(^.^\.tags[]._ref) == ${_id}]),
}
`,
// Settings
getSettings: groq`
*[_type == "settings"] {
siteName,
siteUrl,
description,
logo,
favicon,
ogImage,
contact,
social,
analytics,
seo,
}
`,
};
// File: hooks/useSanity.js - React Hooks
import { useState, useEffect, useCallback } from 'react';
import { sanityClient } from '../lib/sanityClient';
export function useSanityQuery(query, dependencies = []) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const result = await sanityClient.fetch(query);
setData(result);
} catch (err) {
setError(err);
console.error('Sanity query error:', err);
} finally {
setLoading(false);
}
}, [query, sanityClient]);
useEffect(() => {
fetchData();
}, [fetchData, ...dependencies]);
const refetch = useCallback(() => {
return fetchData();
}, [fetchData]);
return { data, loading, error, refetch };
}
export function useSanitySubscription(query, callback, dependencies = []) {
const [subscription, setSubscription] = useState(null);
useEffect(() => {
const newSubscription = sanityClient.subscribe(query, callback);
setSubscription(newSubscription);
return () => {
newSubscription.unsubscribe();
};
}, [query, callback, sanityClient, ...dependencies]);
return subscription;
}
export function useSanityClient() {
return sanityClient;
}
// File: services/sanity-service.js - Service Layer
export class SanityService {
constructor(client = sanityClient) {
this.client = client;
}
// Blog posts
async getBlogPosts(params = {}) {
const { limit = 10, offset = 0, category, tag, featured } = params;
let query = groqQueries.getBlogPosts;
if (offset > 0) {
query = `${query}[${offset}...${offset + limit}]`;
} else if (limit) {
query = `${query}[${limit}]`;
}
if (category) {
query = query.replace(
'*',
`*[_type == "blogPost" && category._ref == ${category}`
);
}
if (tag) {
query = query.replace(
'*',
`*[_type == "blogPost" && ${tag} in tags[]->slug]`
);
}
if (featured) {
query = query.replace(
'*',
`*[_type == "BlogPost" && isFeatured == true`
);
}
const result = await this.client.fetch(query);
return {
posts: result,
total: result.length,
};
}
async getBlogPostBySlug(slug, preview = false) {
const client = preview ? contentfulPreviewClient : this.client;
const query = groqQueries.getBlogPostBySlug(slug);
const result = await client.fetch(query);
return result[0] || null;
}
async getRelatedBlogPosts(categoryId, currentPostId, limit = 3) {
const query = groqQueries.getRelatedBlogPosts(categoryId, currentPostId, limit);
const result = await this.client.fetch(query);
return result;
}
async createBlogPost(postData) {
const mutations = [
groq`
mutation {
createBlogPost {
_id,
_createdAt,
_rev,
_type
}
}
`,
];
const mutation = mutations[0];
const result = await this.client.mutate(mutation);
return result;
}
async updateBlogPost(postId, patch) {
const mutations = [
groq`
mutation {
updateBlogPost(id: ${postId}, patch: ${JSON.stringify(patch)}) {
_id,
_rev
}
}
`,
];
const mutation = mutations[0];
const result = await this.client.mutate(mutation);
return result;
}
async deleteBlogPost(postId) {
const mutations = [
groq`
mutation {
deleteBlogPost(id: ${postId}) {
id,
deletedAt,
status: 'deleted'
}
}
`,
];
const mutation = mutations[0];
const result = await this.client.mutate(mutation);
return result;
}
// Products
async getProducts(params = {}) {
const { limit = 20, offset = 0, category, brand, search } = params;
let query = groqQueries.getProducts;
if (offset > 0) {
query = `${query}[${offset}...${offset + limit}]`;
} else if (limit) {
query = `${query}[${limit}]`;
}
if (category) {
query = query.replace(
'*',
`*[_type == "product" && category._ref == ${category}`
);
}
if (brand) {
query = query.replace(
'*',
`*[_type == "product" && brand._ref == ${brand}`
);
}
if (search) {
query = query.replace(
'*',
`*[_type == "product" && name match ${search}* || shortDescription match ${search}*`
);
}
const result = await this.client.fetch(query);
return {
products: result,
total: result.length,
};
}
async getProductBySlug(slug) {
const query = groqQueries.getProductBySlug(slug);
const result = await this.client.fetch(query);
return result[0] || null;
}
async searchAll(searchTerm, options = {}) {
const { limit = 50, types = ['blogPost', 'product', 'category', 'tag'] } = options;
const searchQueries = types.map(type => {
switch (type) {
case 'blogPost':
`*[_type == "blogPost" && (title match ${searchTerm}* || content match ${searchTerm}*)] {
_id,
title,
slug,
excerpt,
featuredImage,
}`;
case 'product':
`*[_type == "product" && (name match ${searchTerm}* || description match ${searchTerm}*)] {
_id,
name,
slug,
price,
currency,
images[0],
}`;
case 'category':
`*[_type == "category" && name match ${searchTerm}*` {
_id,
name,
slug,
description,
color,
}`;
case 'tag':
`*[_type == "tag" && name match ${searchTerm}*` {
_id,
name,
slug,
}`;
default:
null;
}
}).filter(Boolean);
const allResults = await Promise.all(
searchQueries.map(query =>
query ? this.client.fetch(query) : []
)
);
const flatResults = allResults.flat();
return {
items: flatResults,
total: flatResults.length,
searchTerm,
};
}
// Categories
async getCategories() {
const query = groqQueries.getCategories;
const result = await this.client.fetch(query);
return result;
}
// Authors
async getAuthors() {
const query = groqQueries.getAuthors;
const result = await this.client.client.fetch(query);
return result;
}
// Settings
async getSettings() {
const query = groqQueries.getSettings;
const result = await this.client.fetch(query);
return result[0];
}
// Utility methods
async publishDocument(docId) {
const mutation = groq`
mutation {
publish(id: ${docId}) {
status: "published",
_id,
_rev
}
}
`;
const result = await this.client.mutate(mutation);
return result;
}
async unpublishDocument(docId) {
const mutation = groq`
mutation {
unPublish(id: ${docId}) {
status: "draft",
_id,
_rev
}
}
`;
const result = await this.client.mutate(mutation);
return result;
}
async duplicateDocument(docId, overrides = {}) {
const mutation = groq`
mutation {
duplicate(
id: ${docId}
${Object.entries(overrides).map(([key, value]) =>
${key}: ${JSON.stringify(value)}`
).join(',')}
) {
_id,
_rev
}
}
`;
const result = await this.client.mutate(mutation);
return result;
}
}
// File: components/BlogPostCard.js - Frontend Component
import React from 'react';
import imageUrlBuilder from '@sanity/image-url';
import { PortableText } from '@portabletext/react';
import Link from 'next/link';
import { format } from 'date-fns';
import { groq } from 'next-sanity';
import { client } from '../lib/sanityClient';
const BlogPostCard = ({ post, className = '' }) => {
if (!post) return null;
const {
title,
slug,
excerpt,
publishedAt,
readingTime,
author,
category,
tags,
featuredImage,
} = post;
const authorName = author?.name;
const categoryName = category?.name;
const imageUrl = featuredImage
? imageUrlBuilder
.image(featuredImage)
.width(400)
.height(250)
.format('webp')
.url()
: null;
const postUrl = `/blog/${slug?.current}`;
return (
<article className={`blog-post-card ${className}`}>
{imageUrl && (
<div className="blog-post-card__image">
<Link href={postUrl}>
<Image
src={imageUrl}
alt={title}
width={400}
height={250}
className="object-cover w-full h-full"
style={{
transition: 'transform 0.2s',
}}
whileHover={{ scale: 1.05 }}
/>
</Link>
</div>
)}
<div className="blog-post-card__content">
{categoryName && (
<span
className="blog-post-card__category"
style={{
backgroundColor: category?.color || '#0070f3',
}}
>
{categoryName}
</span>
)}
<h2 className="blog-post-card__title">
<Link href={postUrl}>
{title}
</Link>
</h2>
{excerpt && (
<PortableText
value={excerpt}
components={{
em: ({ children }) => <em>{children}</em>,
a: ({ href, children }) => <Link href={href}>{children}</Link>,
strong: ({ children }) => <strong>{children}</strong>,
code: ({ children }) => (
<code className="bg-gray-100 px-1 py-0.5 text-sm">...contentful-container{
}
}}
className="blog-post-card__excerpt"
/>
)}
<div className="blog-post-card__meta">
{authorName && (
<span className="blog-post-card__author">
By {authorName}
</span>
)}
{publishedAt && (
<time className="blog-post__date">
{format(new Date(publishedAt), 'MMM d, yyyy')}
</time>
)}
{readingTime && (
<span className="blog-post__reading-time">
{readingTime} min read
</span>
)}
</div>
{tags && tags.length > 0 && (
<div className="blog-post-card__tags">
{tags.slice(0, 3).map((tag) => (
<span
key={tag._id} || tag.slug?.current}
className="blog-post-card__tag"
style={{
backgroundColor: tag.color || '#e5e7eb',
}}
>
{tag.name}
</span>
))}
{tags.length > 3 && (
<span className="blog-post__more-tags">
+{tags.length - 3} more
</span>
)}
</div>
)}
</div>
</article>
);
};
export default BlogPostCard;
// File: hooks/useRealtimeBlogPosts.js - Real-time Updates
import { useState, useEffect } from 'react';
import { useSanitySubscription } from './useSanity';
import { groq } from 'next-sanity';
export function useRealtimeBlogPosts() {
const [posts, setPosts] = useState([]);
const [isLoading, setIsLoading] = useState(true);
// Set up real-time subscription
const subscription = useSanitySubscription(
groq`
*[_type == "blogPost"] {
_id,
_type,
title,
slug,
publishedAt,
status
}
`,
(update) => {
if (update.type === 'create') {
// New post added
setPosts(prev => [update.result, ...prev]);
} else if (update.type === 'update') {
// Post updated
setPosts(prev =>
prev.map(post =>
post._id === update.result._id ? update.result : post
)
);
} else if (update.type === 'delete') {
// Post deleted
setPosts(prev => prev.filter(post => post._id !== update.result._id));
}
},
[posts]
);
// Initial load
useEffect(() => {
const loadPosts = async () => {
try {
setIsLoading(true);
const sanityService = new SanityService();
const { posts: initialPosts } = await sanityService.getBlogPosts({
limit: 10,
});
setPosts(initialPosts);
setIsLoading(false);
} catch (error) {
console.error('Failed to load initial blog posts:', error);
setIsLoading(false);
}
};
loadPosts();
}, []);
return { posts, isLoading, subscription };
}
💻 Mises à Jour en Temps Réel et Webhooks Sanity javascript
🔴 complex
⭐⭐⭐⭐
Mises à jour de contenu en temps réel, configuration de webhooks et flots de travail automatisés
⏱️ 30 min
🏷️ sanity, webhooks, real-time
Prerequisites:
Sanity basics, Webhook concepts, Event-driven architecture, Node.js
// Sanity Real-time Updates & Webhooks Examples
// File: lib/sanity-webhook-server.js
const express = require('express');
const crypto = require('crypto');
const { createClient, groq } = require('@sanity/client');
class SanityWebhookServer {
constructor() {
this.app = express();
this.client = createClient({
projectId: process.env.SANITY_PROJECT_ID,
dataset: process.env.SANITY_DOCUMENT_DATASET,
token: process.env.SANITY_WEBHOOK_SECRET,
});
this.setupRoutes();
}
setupRoutes() {
this.app.use(express.json());
// Webhook verification endpoint
this.app.post('/api/sanity/webhook', (req, res) => {
const signature = req.headers['sanity-webhook-signature'];
const body = JSON.stringify(req.body);
if (!this.verifyWebhook(body, signature)) {
return res.status(401).json({ error: 'Invalid signature' });
}
this.processWebhook(req.body);
res.status(200).json({ success: true });
});
// Content change notification endpoint
this.app.post('/api/sanity/content-change', async (req, res) => {
const { type, id, fields } = req.body;
// Send notification to connected clients
this.broadcastContentChange(type, id, fields);
res.status(200).json({ success: true });
});
}
verifyWebhook(body, signature) {
const webhookSecret = process.env.SANITY_WEBHOOK_SECRET;
const expectedSignature = crypto
.createHmac('sha256', webhookSecret)
.update(body)
.digest('hex');
return signature === expectedSignature;
}
processWebhook(payload) {
const { type, ids } = payload;
switch (type) {
case 'create':
this.handleContentCreate(ids);
break;
case 'update':
this.handleContentUpdate(ids);
break;
case 'delete':
this.handleContentDelete(ids);
break;
case 'publish':
this.handleContentPublish(ids);
break;
case 'unpublish':
this.handleContentUnpublish(ids);
break;
}
}
handleContentCreate(ids) {
console.log(`New content created: ${ids.join(', ')}`);
this.triggerWebhook('content.created', { ids });
}
handleContentUpdate(ids) {
console.log(`Content updated: ${ids.join(', ')}`);
this.triggerWebhook('content.updated', { ids });
}
handleContentDelete(ids) {
console.log(`Content deleted: ${ids.join(', ')}`);
this.triggerWebhook('content.deleted', { ids });
}
handleContentPublish(ids) {
console.log(`Content published: ${ids.join(', ')}`);
this.triggerWebhook('content.published', { ids });
}
handleContentUnpublish(ids) {
console.log(`Content unpublished: ${ids.join(', ')}`);
this.triggerWebhook('content.unpublished', { ids });
}
triggerWebhook(event, data) {
// Send webhook to configured endpoints
this.sendToSlack(event, data);
this.sendToDiscord(event, data);
}
async sendToSlack(event, data) {
if (!process.env.SLACK_WEBHOOK_URL) {
return;
}
const message = this.formatSlackMessage(event, data);
try {
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ text: message }),
});
} catch (error) {
console.error('Failed to send Slack notification:', error);
}
}
async sendToDiscord(event, data) {
if (!process.env.DISCORD_WEBHOOK_URL) {
return;
}
const message = this.formatDiscordMessage(event, data);
try {
await fetch(process.env.DISCORD_WEBHOOK_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ embed: message }),
});
} catch (error) {
console.error('Failed to send Discord notification:', error);
}
}
formatSlackMessage(event, data) {
const eventEmojis = {
'content.created': '📝',
'content.updated': '✏�',
'content.deleted': '🗑️',
'content.published': '✅',
'content.unpublished': '📝',
};
return ${eventEmojis[event]} Sanity ${event.replace('_', ' ').toUpperCase()}
` +
`Content IDs: ${data.ids?.join(', ')}`;
}
formatDiscordMessage(event, data) {
const eventEmojis = {
'content.created': '📝',
'content.updated': '✏�',
'content.deleted': '🗑️',
'content.published': '✅',
'content.unpublished': '📝',
};
return {
title: `Sanity Content ${event.replace('_', ' ).toUpperCase()}`,
description: `Content IDs: ${data.ids?.join(', ')}`,
color: this.getDiscordColor(event),
};
}
getDiscordColor(event) {
const colors = {
'content.created': '#22c55e0',
'content.updated': '#f59e0b',
'content.deleted': '#ef4444',
'content.published': '#22c55e0',
'content.unpublish': '#f59e0b',
};
return colors[event] || '#808080';
}
broadcastContentChange(type, ids, fields) {
// This would typically use WebSockets or Server-Sent Events
console.log(`Broadcasting content change: ${type}`, ids, fields);
}
start(port = 3001) {
this.app.listen(port, () => {
console.log(`Sanity webhook server running on port ${port}`);
});
}
}
// File: scripts/deploy-sanity.js - Deployment Script
const { exec } = require('child_process');
class SanityDeployer {
constructor() {
this.projectId = process.env.SANITY_PROJECT_ID;
this.dataset = process.env.SANITY_DATASET;
this.token = process.env.SANITY_TOKEN;
}
async deploy() {
try {
console.log('Starting Sanity deployment...');
// 1. Validate configuration
await this.validateConfig();
// 2. Backup current data
await this.backupData();
// 3. Deploy schema
await this.deploySchema();
// 4. Import content
await this.importContent();
// 5. Run migrations
await this.runMigrations();
// 6. Deploy hooks
await this.deployHooks();
// 7. Run tests
await this.runTests();
console.log('Sanity deployment completed successfully!');
} catch (error) {
console.error('Sanity deployment failed:', error);
throw error;
}
}
validateConfig() {
console.log('Validating Sanity configuration...');
if (!this.projectId) {
throw new Error('SANITY_PROJECT_ID is required');
}
if (!this.token) {
throw new Error('SANITY_TOKEN is required');
}
console.log(`Project ID: ${this.projectId}`);
console.log(`Dataset: ${this.dataset}`);
console.log('Configuration validated ✓');
}
async backupData() {
console.log('Backing up current data...');
const backupDir = './backups';
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = `${backupDir}/sanity-backup-${timestamp}.json`;
exec(`mkdir -p ${backupDir}`);
const backupCommand = `npx sanity export ${this.dataset} --out ${backupPath}`;
console.log(`Running: ${backupCommand}`);
return new Promise((resolve, reject) => {
exec(backupCommand, { cwd: process.cwd() }, (error, stdout, stderr) => {
if (error) {
console.error('Backup failed:', error);
return reject(error);
}
console.log('Backup completed:', backupPath);
resolve(backupPath);
});
});
}
async deploySchema() {
console.log('Deploying schema...');
const schemaDir = './schemas';
const deployCommand = `npx sanity deploy --project ${this.projectId} --no-verify ${schemaDir}`;
console.log(`Running: ${deployCommand}`);
return new Promise((resolve, reject) => {
exec(deployCommand, { cwd: process.cwd() }, (error, stdout, stderr) => {
if (error) {
console.error('Schema deployment failed:', error);
return reject(error);
}
console.log('Schema deployment completed');
resolve();
});
});
}
async importContent() {
console.log('Importing content...');
const importDir = './data/content';
const importCommand = `npx sanity import ${this.dataset} --replace ${importDir}`;
console.log(`Running: ${importCommand}`);
return new Promise((resolve, reject) => {
exec(importCommand, { cwd: process.cwd() }, (error, stdout, stderr) => {
if (error) {
console.error('Content import failed:', error);
return reject(error);
}
console.log('Content import completed');
resolve();
});
});
}
async runMigrations() {
console.log('Running migrations...');
const migrationsDir = './migrations';
const migrateCommand = `npx sanity migration ${migrationsDir}`;
console.log(`Running: ${migrateCommand}`);
return new Promise((resolve, reject) => {
exec(migrateCommand, { cwd: process.cwd() }, (error, stdout, stderr) => {
if (error) {
console.error('Migrations failed:', error);
return reject(error);
}
console.log('Migrations completed');
resolve();
});
});
}
async deployHooks() {
console.log('Deploying hooks...');
const hooksDir = './hooks';
const deployCommand = `npx sanity hook deploy ${hooksDir}`;
console.log(`Running: ${deployCommand}`);
return new Promise((resolve, reject) => {
exec(deployCommand, { cwd: process.cwd() }, (error, stdout, stderr) => {
if (error) {
console.error('Hooks deployment failed:', error);
return reject(error);
}
console.log('Hooks deployment completed');
resolve();
});
}
async runTests() {
console.log('Running tests...');
const testCommand = 'npm run test:sanity';
console.log(`Running: ${testCommand}`);
return new Promise((resolve, reject) => {
exec(testCommand, { cwd: process.cwd() }, (error, stdout, stderr) => {
if (error) {
console.error('Tests failed:', error);
return reject(error);
}
console.log('Tests passed');
resolve();
});
}
async rollback() {
console.log('Rolling back deployment...');
const backupDir = './backups';
const rollbackCommand = `npx import ${this.dataset} --replace ${backupDir}`;
return new Promise((resolve, reject) => {
exec(rollbackCommand, { cwd: process.cwd() }, (error, stdout, stderr) => {
if (error) {
console.error('Rollback failed:', error);
return reject(error);
}
console.log('Rollback completed');
resolve();
});
}
}
// File: lib/webhook-client.js - Webhook Client for Frontend
class SanityWebhookClient {
constructor(webhookUrl, secret) {
this.webhookUrl = webhookUrl;
this.secret = secret;
}
async sendWebhook(event, data = {}) {
const payload = {
event,
timestamp: new Date().toISOString(),
...data,
};
const signature = this.generateSignature(JSON.stringify(payload));
try {
const response = await fetch(this.webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': signature,
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Webhook request failed: ${response.status}`);
}
return response.json();
} catch (error) {
console.error('Webhook error:', error);
throw error;
}
}
generateSignature(payload) {
return crypto
.createHmac('sha256', this.secret)
.update(payload)
.digest('hex');
}
}
// Usage example:
const webhookClient = new SanityWebhookClient(
process.env.NEXT_PUBLIC_SANITY_WEBHOOK_URL,
process.env.SANITY_WEBHOOK_SECRET
);
// Send webhook notification
await webhookClient.sendWebhook('content.updated', {
ids: ['abc123', 'def456'],
changes: ['title', 'content'],
});