Sanity 实时CMS 示例

全面的Sanity示例,涵盖实时内容管理、Studio自定义、API集成和部署模式

💻 Sanity 内容建模 javascript

🟡 intermediate ⭐⭐⭐

完整的内容类型定义、模式配置和各种用例的文档建模

⏱️ 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',
  },
};

💻 Sanity API 集成 javascript

🔴 complex ⭐⭐⭐⭐

完整的API集成示例,包含GROQ查询、实时订阅、客户端设置和前端实现

⏱️ 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 };
}

💻 Sanity 实时更新和Webhook javascript

🔴 complex ⭐⭐⭐⭐

实时内容更新、Webhook配置和自动化工作流

⏱️ 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'],
});