> ## Documentation Index
> Fetch the complete documentation index at: https://docs.siftly.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Sanity Integration

> Connect Siftly to your Sanity Studio and publish GEO-optimized content as structured documents using Sanity's Content Lake API.

<Frame>
  <img src="https://mintcdn.com/siftly/IwND2vAxRbhCiRev/images/cms-integrations.svg?fit=max&auto=format&n=IwND2vAxRbhCiRev&q=85&s=2704e9affce00587d23137bf707339c7" alt="Supported CMS platforms including Sanity" className="rounded-xl" width="900" height="280" data-path="images/cms-integrations.svg" />
</Frame>

## Overview

This guide is for Sanity users who want to publish Siftly-generated GEO content and apply SEO/GEO field recommendations (meta tags, JSON-LD, keywords) directly to their Sanity schema. After following it, you'll have a complete SEO schema, working field mapping, and frontend rendering code for all GEO-critical fields.

**Difficulty:** ❌ Requires developer — Sanity is headless; you build the schema and the frontend rendering yourself.

Sanity is a headless CMS built around structured content and a powerful API. The Siftly–Sanity integration pushes content directly to your Sanity Content Lake as new documents, using the unified 3-tier field mapping system and Sanity's GROQ-powered API under the hood.

This integration is ideal for teams using Sanity to power content-heavy websites, documentation, or multi-channel publishing pipelines.

***

## Prerequisites

Before connecting, you'll need:

* A Sanity project with a **schema** that includes a document type for your content (e.g., `post`, `article`)
* A **Sanity API token** with **Editor** or **Deploy Studio** write permissions
* Your Sanity **Project ID** and **Dataset name**

***

## Step 1: Get your Sanity credentials

### Project ID and Dataset

1. Log in to [sanity.io/manage](https://sanity.io/manage)
2. Select your project
3. Copy the **Project ID** from the project overview page
4. Note your **Dataset** name (usually `production`)

### API Token

1. In Sanity Manage, go to **API -> Tokens**
2. Click **Add API Token**
3. Give it a descriptive name (e.g., "Siftly Integration")
4. Set the permission level to **Editor**
5. Click **Save** and copy the token

<Warning>
  Sanity API tokens with Editor permissions can create, modify, and delete documents, and upload assets (images) to the Content Lake. Store the token securely and restrict it to the minimum dataset it needs access to.
</Warning>

<Note>
  The **Editor** permission level includes asset upload access. Siftly uploads hero images and inline content images to the Sanity Assets API automatically at publish time — no additional permissions are needed.
</Note>

***

## Step 2: Connect Sanity in Siftly

1. In Siftly, go to **Settings -> Integrations**
2. Click **Connect** next to **Sanity**
3. Enter your:
   * Project ID
   * Dataset name
   * API Token
4. Click **Verify Connection**

Siftly will connect to your Content Lake and validate the credentials. If successful, you move to the schema mapping step.

***

## Step 3: Schema Mapping

After credentials are validated, Siftly discovers your schema and presents the mapping interface.

### Document Type Selection

Choose the Sanity document type that Siftly should create when publishing. Siftly automatically discovers all document types in your schema and presents them as a dropdown. You can also enter a custom type name.

When you select a type, Siftly fetches a sample document via GROQ and discovers all available fields — including **nested fields** inside objects (e.g., `seo.metaTitle`, `schemaOrg.datePublished`).

### Field Mapping

Siftly uses a 3-tier field mapping system. Select fields from the dropdown (discovered from your schema) for each field you want Siftly to populate.

#### Required Fields

| Siftly Field | Typical Sanity Field | Notes                                                                     |
| ------------ | -------------------- | ------------------------------------------------------------------------- |
| **Title**    | `title`              | Plain text                                                                |
| **Slug**     | `slug`               | Sanity handles the `{_type: "slug", current: "..."}` format automatically |
| **Body**     | `body`               | Rich text — maps to Portable Text or HTML embed (see below)               |

#### Optional Fields

Leave any of these blank to skip them. When mapped, they are auto-populated from your content data.

| Siftly Field         | Typical Sanity Field  | Notes                                          |
| -------------------- | --------------------- | ---------------------------------------------- |
| **Meta Title**       | `seo.metaTitle`       | Dot-notation supported for nested objects      |
| **Meta Description** | `seo.metaDescription` | Dot-notation supported for nested objects      |
| **Keywords**         | `tags`                | Array of strings or comma-separated string     |
| **Categories**       | `category`            | String value                                   |
| **JSON-LD**          | `schema`              | String or JSON — controlled by the type toggle |
| **Word Count**       | `wordCount`           | Number field                                   |
| **Hero Image**       | `featuredImage`       | Image field                                    |

#### JSON-LD Type Toggle

When a JSON-LD field is mapped, a **text/json** toggle appears:

* **json** (default): Sends as a parsed JSON object (with `@` prefixes stripped for Sanity compatibility)
* **text**: Sends as a serialized JSON string

Choose based on your Sanity schema's field type for this field.

### Nested Field Discovery

Siftly recursively discovers fields inside nested objects up to 3 levels deep. For example, an SEO settings object with sub-fields appears as:

* `seo.metaTitle` (String)
* `seo.metaDescription` (String)
* `seo.ogImage` (Image)
* `schemaOrg.datePublished` (String)

These are shown in the dropdown with `>` separators: **SEO > Meta Title**.

### Body Format

Toggle **Store body as HTML embed** to control how body content is stored:

* **Off** (default): Content is converted to Portable Text blocks (headings, paragraphs, lists, links)
* **On**: Content is stored as an `htmlEmbed` block containing the full HTML. Your schema needs an `htmlEmbed` type defined in the body array, and your frontend should render it with proper styling.

### Portable Text

When HTML embed is off, Siftly automatically converts generated HTML content to valid Portable Text:

* Headings (H2, H3, H4)
* Bold, italic, and inline code
* Ordered and unordered lists
* Hyperlinks (including internal links generated by Siftly)
* Block quotes

<Note>
  Custom Portable Text block types (e.g., call-out cards, custom embeds) are not supported by the auto-converter.
</Note>

***

## Custom Fields

Add extra fields your Sanity document type requires that aren't part of Siftly's standard content schema.

### Field Types

| Type    | Description                  | Example                           |
| ------- | ---------------------------- | --------------------------------- |
| String  | Plain text                   | Author name, subtitle             |
| Number  | Numeric value                | Priority, reading time            |
| Boolean | True/false                   | Featured post, comments enabled   |
| JSON    | Structured object            | SEO config, social media metadata |
| Select  | Pick from predefined options | Category, status, content type    |

### Select Options with Separate Labels and Values

When adding a **Select** custom field, you define the available options in a textarea. Each option can have a separate **value** (sent to CMS) and **label** (shown in the dropdown):

```
value:label
```

**Example — Labels differ from values:**

```
1:Health
2:Tech
3:Food
4:Fashion
```

The dropdown shows `Health`, `Tech`, etc., but Siftly sends `1`, `2`, etc. to the CMS API.

**Example — Labels same as values:**

```
food
tech
health
fashion
```

When no `:` is present, the option text is used as both the label and value. This is backward-compatible with existing configurations.

<Tip>
  Use the `value:label` format when your CMS expects specific IDs, slugs, or enum values that differ from the human-readable names shown to content editors.
</Tip>

### Default Values

Set a default value when adding a custom field. This value pre-fills at publish time — you can override it for each post.

### Use Existing Field (Source Fields)

Instead of setting a manual default, link a custom field to an existing content source. The value is auto-populated at publish time — no manual input needed.

Available sources:

* **Title**, **Slug**, **Meta Title**, **Meta Description**, **Excerpt**
* **Keywords**, **Categories**, **Word Count**
* **JSON-LD**, **Hero Image**
* **Date Published**, **Date Modified** (extracted from the JSON-LD graph)

This is especially useful for nested fields. For example:

* `seo.metaTitle` -> Use Existing: **Meta Title**
* `seo.wordCount` -> Use Existing: **Word Count**
* `schemaOrg.dateModified` -> Use Existing: **Date Modified**

### Editing Custom Fields

Click the **pencil icon** on any existing custom field to edit its configuration (field path, label, type, source, default value).

***

## Auto-Populated Fields

Siftly automatically sets these fields on every document if not already provided by a custom field mapping:

| Field         | Value                                                   |
| ------------- | ------------------------------------------------------- |
| `status`      | `"published"` or `"draft"` based on your publish choice |
| `publishedAt` | ISO timestamp (only for published, not draft)           |

***

## Publishing

### Draft vs Published

When publishing, choose between:

* **Draft** — the document is created with `drafts.` prefix ID, visible only in Studio
* **Published** — the document is created as a live document with `publishedAt` set

### Custom Fields at Publish Time

Custom fields with **Use Existing Field** set show as "Auto-populated from \[source]" in the publish dialog — no input needed. Manual custom fields appear with their default values pre-filled and editable.

### Republishing

Siftly uses `createOrReplace` for idempotent updates. Republishing the same content updates the existing Sanity document (same ID: `siftly-{content_id}`). Publishing also deletes any stray draft/published counterpart to avoid duplicates.

***

## GROQ Previewing

After publishing, verify the document in Sanity Studio's Vision tool:

```groq theme={null}
*[_type == "post" && slug.current == "your-slug"][0] {
  title,
  slug,
  body,
  publishedAt,
  status
}
```

***

## Webhooks (optional)

For teams with automated publishing workflows, Siftly can call a Sanity webhook after creating a document — for example, to trigger a re-build of your Next.js or Remix frontend.

1. In Sanity Manage, go to **API → Webhooks → Add Webhook**
2. Set the URL to your frontend's webhook endpoint
3. Copy the webhook URL
4. In Siftly → **Settings → Integrations → Sanity → Advanced**, paste the webhook URL in **Post-publish webhook**

Siftly will call this URL with a POST request after each successful publish.

***

## Where to apply Siftly's recommendations on Sanity

Sanity is fully headless — you define the schema, Siftly pushes data into it, and your frontend renders it as HTML meta tags and structured data. Below is the complete recommended schema and rendering setup.

### Recommended SEO schema object

Create a reusable `seo` object type that you can add to any document type:

<CodeGroup>
  ```typescript sanity/schemaTypes/objects/seo.ts theme={null}
  import { defineType, defineField } from 'sanity';

  export const seo = defineType({
    name: 'seo',
    title: 'SEO & GEO Fields',
    type: 'object',
    fields: [
      defineField({
        name: 'metaTitle',
        title: 'Meta Title',
        type: 'string',
        description: 'Page title for search engines and AI citations (50-60 characters ideal)',
        validation: (Rule) => Rule.max(70).warning('Keep under 60 characters for best results'),
      }),
      defineField({
        name: 'metaDescription',
        title: 'Meta Description',
        type: 'text',
        rows: 3,
        description: 'Page description for search engines and social sharing (150-160 characters ideal)',
        validation: (Rule) => Rule.max(160).warning('Keep under 160 characters'),
      }),
      defineField({
        name: 'ogImage',
        title: 'Open Graph Image',
        type: 'image',
        description: 'Social sharing image (1200x630px recommended)',
        options: { hotspot: true },
      }),
      defineField({
        name: 'canonicalUrl',
        title: 'Canonical URL',
        type: 'url',
        description: 'Override canonical URL (leave empty to use default page URL)',
      }),
      defineField({
        name: 'keywords',
        title: 'Keywords',
        type: 'array',
        of: [{ type: 'string' }],
        description: 'Target keywords for this content',
        options: { layout: 'tags' },
      }),
      defineField({
        name: 'jsonLd',
        title: 'JSON-LD Structured Data',
        type: 'text',
        rows: 10,
        description: 'Paste Siftly\'s JSON-LD recommendation here. Must be valid JSON.',
      }),
    ],
  });
  ```

  ```typescript sanity/schemaTypes/documents/post.ts theme={null}
  import { defineType, defineField } from 'sanity';

  export const post = defineType({
    name: 'post',
    title: 'Blog Post',
    type: 'document',
    fields: [
      defineField({ name: 'title', type: 'string' }),
      defineField({ name: 'slug', type: 'slug', options: { source: 'title' } }),
      defineField({ name: 'body', type: 'array', of: [{ type: 'block' }] }),
      defineField({ name: 'publishedAt', type: 'datetime' }),
      defineField({ name: 'tags', type: 'array', of: [{ type: 'string' }], options: { layout: 'tags' } }),
      defineField({ name: 'wordCount', type: 'number' }),
      // SEO object — all GEO fields in one place
      defineField({
        name: 'seo',
        title: 'SEO & GEO',
        type: 'seo',
      }),
    ],
  });
  ```
</CodeGroup>

### Meta Title

**Schema location:** `seo.metaTitle`

**Siftly mapping:** Map Siftly's **Meta Title** field to `seo.metaTitle` in your integration settings.

**Frontend rendering (Next.js App Router):**

```tsx theme={null}
// app/blog/[slug]/page.tsx
import { client } from '@/sanity/lib/client';

export async function generateMetadata({ params }) {
  const post = await client.fetch(
    `*[_type == "post" && slug.current == $slug][0]{
      title,
      "metaTitle": seo.metaTitle,
      "metaDescription": seo.metaDescription,
      "ogImage": seo.ogImage.asset->url
    }`,
    { slug: params.slug }
  );

  return {
    title: post.metaTitle || post.title,
    description: post.metaDescription,
    openGraph: {
      title: post.metaTitle || post.title,
      description: post.metaDescription,
      images: post.ogImage ? [{ url: post.ogImage }] : [],
    },
  };
}
```

### Meta Description

**Schema location:** `seo.metaDescription`

**Siftly mapping:** Map Siftly's **Meta Description** field to `seo.metaDescription`.

Rendered via the same `generateMetadata` function above.

### Open Graph Image

**Schema location:** `seo.ogImage` (Sanity image type with asset reference)

<Note>
  Siftly does not push image uploads to Sanity. After publishing, upload your OG image to the document in Sanity Studio. Alternatively, use a text field (`seo.ogImageUrl`) for external image URLs.
</Note>

### Canonical URL

**Schema location:** `seo.canonicalUrl`

**Frontend rendering:**

```tsx theme={null}
export async function generateMetadata({ params }) {
  const post = await client.fetch(`*[_type == "post" && slug.current == $slug][0]{
    "canonicalUrl": seo.canonicalUrl,
    "slug": slug.current
  }`, { slug: params.slug });

  return {
    alternates: {
      canonical: post.canonicalUrl || `/blog/${post.slug}`,
    },
  };
}
```

### Tags & Categories

**Schema location:** `tags` (array of strings at document root)

**Siftly mapping:** Map Siftly's **Keywords** field to `tags`.

Tags are rendered in your frontend for navigation, filtering, or as meta keywords:

```tsx theme={null}
<meta name="keywords" content={post.tags?.join(', ')} />
```

### JSON-LD Structured Data

**Schema location:** `seo.jsonLd` (text field storing raw JSON)

**Siftly mapping:** Map Siftly's **JSON-LD** field to `seo.jsonLd` (or `jsonLd` at root).

**Frontend rendering:**

```tsx theme={null}
// app/blog/[slug]/page.tsx
export default async function PostPage({ params }) {
  const post = await client.fetch(
    `*[_type == "post" && slug.current == $slug][0]{
      title,
      body,
      "jsonLd": seo.jsonLd
    }`,
    { slug: params.slug }
  );

  return (
    <>
      {post.jsonLd && (
        <script
          type="application/ld+json"
          dangerouslySetInnerHTML={{ __html: post.jsonLd }}
        />
      )}
      <article>
        {/* Render post.body with PortableText */}
      </article>
    </>
  );
}
```

<Tip>
  For maximum GEO impact, Siftly's JSON-LD recommendations typically include Article, FAQ, or HowTo schema. Store the entire JSON-LD object in the `seo.jsonLd` field and render it verbatim in your frontend's `<head>`.
</Tip>

### Keywords

**Schema location:** `seo.keywords` (array of strings) or `tags` at the document root.

Siftly pushes keywords as an array. Your frontend renders them in a `<meta name="keywords">` tag or uses them for internal search/filtering.

***

## Pushing Siftly recommendations automatically

<Steps>
  <Step title="Generate content in Siftly">
    Siftly produces your article plus meta title, meta description, keywords, JSON-LD, and word count.
  </Step>

  <Step title="Click Publish → Sanity">
    Select your document type. Choose Draft or Published status.
  </Step>

  <Step title="All fields pushed in one API call">
    Siftly creates a new document in your Content Lake with all mapped fields populated — body (as Portable Text), SEO object fields, tags, and JSON-LD.
  </Step>

  <Step title="Frontend picks up new content">
    Your frontend queries the new document via GROQ and renders all meta tags, JSON-LD, and content from the populated fields.
  </Step>
</Steps>

***

## GROQ query for all SEO fields

Use this query to fetch everything your frontend needs for a complete GEO-optimized page:

```groq theme={null}
*[_type == "post" && slug.current == $slug][0] {
  title,
  "slug": slug.current,
  body,
  publishedAt,
  tags,
  wordCount,
  "seo": {
    "metaTitle": seo.metaTitle,
    "metaDescription": seo.metaDescription,
    "ogImage": seo.ogImage.asset->url,
    "canonicalUrl": seo.canonicalUrl,
    "keywords": seo.keywords,
    "jsonLd": seo.jsonLd
  }
}
```

***

## Platform-specific quirks & limitations

<Warning>
  **Nothing renders automatically.** Sanity stores structured content — it does NOT generate any HTML, meta tags, or JSON-LD output. Your frontend is 100% responsible for reading these fields and rendering them as proper HTML `<meta>` tags and `<script type="application/ld+json">` blocks.
</Warning>

<Note>
  **Portable Text conversion:** Siftly converts HTML to Portable Text automatically when publishing. Standard formatting (headings, lists, links, bold, italic, blockquotes) is preserved. Custom block types in your schema are NOT supported by the auto-converter.
</Note>

* **Image fields:** Siftly cannot upload images to Sanity's asset pipeline. Use text URL fields for OG images or upload images manually in Sanity Studio after publishing.
* **References:** If your schema uses references (e.g., `author` → Author document), Siftly cannot create or link references. Keep these fields optional or set them manually.
* **Array of objects:** Complex nested arrays (e.g., FAQ items as an array of `{question, answer}` objects) cannot be mapped from Siftly's flat field structure. Store FAQ schema as JSON-LD in the `seo.jsonLd` text field instead.
* **Real-time preview:** Sanity's real-time preview (via `next-sanity` or `@sanity/preview-kit`) will show new Siftly-published documents immediately in draft mode without a page refresh.
* **Dataset isolation:** If you use multiple datasets (e.g., `production` and `staging`), make sure Siftly is connected to the correct dataset for your publishing workflow.

***

## Validating the setup

After publishing your first document from Siftly:

<Steps>
  <Step title="Check in Sanity Studio">
    Open Sanity Studio → find your new document → verify all fields (title, body, SEO object, tags, JSON-LD) are populated.
  </Step>

  <Step title="Run your GROQ query">
    Open **Vision** in Sanity Studio and run the GROQ query above with your test slug. Confirm all SEO fields return data.
  </Step>

  <Step title="View the live frontend page source">
    Open the rendered page. Right-click → **View Page Source**. Search for:

    * `<title>` — should contain your meta title from Sanity
    * `<meta name="description"` — should contain your meta description
    * `<script type="application/ld+json">` — should contain your JSON-LD
  </Step>

  <Step title="Run Google Rich Results Test">
    Go to [Google Rich Results Test](https://search.google.com/test/rich-results) and paste your URL. Confirm structured data is detected and valid.
  </Step>

  <Step title="Run Schema.org Validator">
    Go to [Schema.org Validator](https://validator.schema.org/) for additional validation.
  </Step>
</Steps>

***

## Difficulty & setup recap

<Card title="Sanity Setup Summary" icon="circle-dot">
  | Aspect                  | Rating                                                             |
  | ----------------------- | ------------------------------------------------------------------ |
  | **Initial setup**       | ❌ Developer required — schema design + frontend code               |
  | **Applying SEO fields** | ❌ Must build schema object and frontend rendering                  |
  | **JSON-LD**             | ❌ Stored as text; frontend must render `<script>` tag explicitly   |
  | **Ongoing editing**     | ✅ Easy — publish from Siftly, review in Sanity Studio              |
  | **Developer needed?**   | Yes — for schema and frontend. Not for ongoing content publishing. |
  | **GEO control level**   | ✅ Maximum — you control every field and rendering detail           |
</Card>

***

## Related

<CardGroup cols={2}>
  <Card title="CMS Integrations Overview" icon="grid-2" href="/integrations/overview">
    Compare all supported platforms and the unified field mapping system.
  </Card>

  <Card title="Quickstart Guide" icon="rocket" href="/quickstart">
    Set up your brand and run your first GEO analysis.
  </Card>

  <Card title="Content Generation" icon="file-pen" href="/features/content-generation">
    How Siftly generates GEO-optimized content and recommendations.
  </Card>

  <Card title="Choosing a CMS for AI Visibility" icon="book" href="https://siftly.ai/blog/cms-and-ai-visibility">
    Our guide to picking the right CMS for GEO optimization.
  </Card>
</CardGroup>

***

## Troubleshooting

<AccordionGroup>
  <Accordion title="403 Forbidden on connection">
    Your API token doesn't have write access to the selected dataset. Regenerate the token with **Editor** permissions in Sanity Manage.
  </Accordion>

  <Accordion title="Document created but fields are empty">
    Check your field mapping. Fields with **Use Existing Field** require the source data to exist in your content. Verify the mapping dropdown matches your actual schema field names.
  </Accordion>

  <Accordion title="Slug field isn't saving correctly">
    Siftly automatically wraps slugs in Sanity's `{_type: "slug", current: "your-slug"}` format. Just map to the slug field name.
  </Accordion>

  <Accordion title="JSON-LD shows 'Unknown fields' warning in Studio">
    When JSON-LD type is set to **json**, the `@` prefixes are stripped (`@type` becomes `type`). Your Sanity schema needs matching sub-fields (e.g., `context`, `graph`) defined on the object, or use **text** mode to store as a string.
  </Accordion>

  <Accordion title="Two versions of the document appear (draft + published)">
    This can happen from failed previous attempts. Republish the content — Siftly now automatically deletes the opposite version when publishing.
  </Accordion>
</AccordionGroup>
