` or GFM tables).
+- **Code:** Need a `code` type (HTML `` or MD code fences).
diff --git a/.agents/skills/sanity-best-practices/references/nextjs.md b/.agents/skills/sanity-best-practices/references/nextjs.md
new file mode 100644
index 0000000..cad3d23
--- /dev/null
+++ b/.agents/skills/sanity-best-practices/references/nextjs.md
@@ -0,0 +1,544 @@
+---
+title: Next.js & Sanity Integration Rules
+description: Integration guide for Next.js App Router, Live Content API, and Sanity Studio (Embedded or Standalone).
+---
+
+# Next.js & Sanity Integration Rules
+
+Jump to the section that matches the task instead of reading this guide end-to-end.
+
+## Table of Contents
+
+- Architecture patterns
+- Data fetching (Live Content API)
+- Caching and revalidation
+- Visual Editing and clean data
+- Embedded Studio setup
+- Draft Mode setup
+- Error handling
+- Presentation queries
+- Pagination pattern
+
+## 1. Architecture Patterns
+
+### Option A: Embedded Studio (Recommended)
+**Best for:** Most Next.js projects. Unified deployment, simpler setup.
+
+The Studio lives inside your Next.js app at `/app/studio/[[...tool]]/page.tsx`.
+- **Config:** `sanity.config.ts` lives in the project root.
+- See `project-structure.md` rule for detailed structure.
+
+### Option B: Monorepo (Alternative)
+**Best for:** Separation of concerns, multiple frontends, or strict dependency isolation.
+
+The Studio and Next.js app live in separate folders:
+```
+apps/
+├── studio/ # Sanity Studio (standalone)
+└── web/ # Next.js frontend
+```
+
+- **Config:** Add your Next.js app URL to **CORS Origins** in [Sanity Manage](https://www.sanity.io/manage).
+- See `project-structure.md` rule for detailed structure.
+
+## 2. Data Fetching (Live Content API)
+
+We use `defineLive` (next-sanity v11+) to enable real-time content updates and Visual Editing automatically.
+
+### Setup (`src/sanity/lib/live.ts`)
+
+```typescript
+import { defineLive } from 'next-sanity'
+import { client } from './client'
+
+export const { sanityFetch, SanityLive } = defineLive({
+ client: client.withConfig({
+ apiVersion: '2026-02-01'
+ }),
+ serverToken: process.env.SANITY_API_READ_TOKEN,
+ browserToken: process.env.SANITY_API_READ_TOKEN,
+})
+```
+
+### Rendering (`src/app/layout.tsx`)
+
+You **must** render `` in the root layout to enable real-time updates.
+
+```typescript
+import { SanityLive } from '@/sanity/lib/live'
+import { VisualEditing } from 'next-sanity/visual-editing'
+import { draftMode } from 'next/headers'
+
+export default async function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+ {(await draftMode()).isEnabled && }
+
+
+ )
+}
+```
+
+## 3. Caching & Revalidation
+
+### Prefer Live Content API (Default)
+
+**Use `defineLive` by default.** It handles fetching, caching, and invalidation automatically. Only implement manual caching when you need fine-grained control.
+
+### When to Use Manual Caching
+
+| Scenario | Approach |
+|----------|----------|
+| Real-time updates, Visual Editing | `defineLive` (default) |
+| Static marketing pages, rarely updated | Time-based revalidation |
+| Blog posts, products with frequent edits | Tag-based revalidation |
+| Critical accuracy (stock levels, prices) | Path-based + short revalidation |
+
+### Debugging: Enable Fetch Logging
+
+See every fetch with cache HIT/MISS status:
+
+```typescript
+// next.config.ts
+const nextConfig: NextConfig = {
+ logging: {
+ fetches: {
+ fullUrl: true,
+ },
+ },
+};
+```
+
+Console output shows cache status:
+```text
+GET /posts 200 in 39ms
+ │ GET https://...apicdn.sanity.io/... 200 in 5ms (cache hit)
+```
+
+### Sanity CDN vs API
+
+| Setting | Speed | Freshness | Use When |
+|---------|-------|-----------|----------|
+| `useCdn: true` | Fast | May have brief delay | Default for all runtime fetches |
+| `useCdn: false` | Slower | Guaranteed fresh | `generateStaticParams`, webhooks |
+
+Override per-request:
+```typescript
+// For static generation, use API directly
+export async function generateStaticParams() {
+ const slugs = await client
+ .withConfig({ useCdn: false })
+ .fetch(SLUGS_QUERY);
+ return slugs;
+}
+```
+
+### Manual `sanityFetch` Helper (Advanced)
+
+For manual caching control, create a wrapper:
+
+```typescript
+// src/sanity/lib/client.ts
+export async function sanityFetch({
+ query,
+ params = {},
+ revalidate = 60,
+ tags = [],
+}: {
+ query: QueryString;
+ params?: QueryParams;
+ revalidate?: number | false;
+ tags?: string[];
+}) {
+ return client.fetch(query, params, {
+ next: {
+ revalidate: tags.length ? false : revalidate,
+ tags,
+ },
+ });
+}
+```
+
+### Time-Based Revalidation
+
+Simple and predictable. Good for content that changes infrequently.
+
+```typescript
+const posts = await sanityFetch({
+ query: POSTS_QUERY,
+ revalidate: 3600, // Revalidate every hour
+});
+```
+
+**The "Typo Problem":** With time-based only, content authors may wait up to an hour to see changes. Use webhooks for instant updates.
+
+### Path-Based Revalidation
+
+Surgically revalidate specific routes when documents change.
+
+**1. Create API Route:**
+```typescript
+// src/app/api/revalidate/path/route.ts
+import { revalidatePath } from 'next/cache';
+import { type NextRequest, NextResponse } from 'next/server';
+import { parseBody } from 'next-sanity/webhook';
+
+type WebhookPayload = { path?: string };
+
+export async function POST(req: NextRequest) {
+ try {
+ const { isValidSignature, body } = await parseBody(
+ req,
+ process.env.SANITY_REVALIDATE_SECRET,
+ true // Add delay to allow CDN to update
+ );
+
+ if (!isValidSignature) {
+ return new Response('Invalid signature', { status: 401 });
+ }
+ if (!body?.path) {
+ return new Response('Missing path', { status: 400 });
+ }
+
+ revalidatePath(body.path);
+ return NextResponse.json({ revalidated: body.path });
+ } catch (err) {
+ return new Response((err as Error).message, { status: 500 });
+ }
+}
+```
+
+**2. Create GROQ-Powered Webhook:**
+- URL: `https://yoursite.com/api/revalidate/path`
+- Filter: `_type in ["post"]`
+- Projection: `{ "path": "/posts/" + slug.current }`
+- Add `SANITY_REVALIDATE_SECRET` to webhook and `.env.local`
+
+### Tag-Based Revalidation
+
+"Update once, revalidate everywhere" — best for referenced content.
+
+**1. Tag Your Queries:**
+```typescript
+// Posts index - revalidate when ANY post, author, or category changes
+const posts = await sanityFetch({
+ query: POSTS_QUERY,
+ tags: ['post', 'author', 'category'],
+});
+
+// Individual post - more granular, includes slug-specific tag
+const post = await sanityFetch({
+ query: POST_QUERY,
+ params,
+ tags: [`post:${params.slug}`, 'author', 'category'],
+});
+```
+
+**2. Create API Route:**
+```typescript
+// src/app/api/revalidate/tag/route.ts
+import { revalidateTag } from 'next/cache';
+import { type NextRequest, NextResponse } from 'next/server';
+import { parseBody } from 'next-sanity/webhook';
+
+type WebhookPayload = { tags: string[] };
+
+export async function POST(req: NextRequest) {
+ try {
+ const { isValidSignature, body } = await parseBody(
+ req,
+ process.env.SANITY_REVALIDATE_SECRET,
+ true
+ );
+
+ if (!isValidSignature) {
+ return new Response('Invalid signature', { status: 401 });
+ }
+ if (!Array.isArray(body?.tags) || !body.tags.length) {
+ return new Response('Missing tags', { status: 400 });
+ }
+
+ body.tags.forEach((tag) => revalidateTag(tag));
+ return NextResponse.json({ revalidated: body.tags });
+ } catch (err) {
+ return new Response((err as Error).message, { status: 500 });
+ }
+}
+```
+
+**3. Create GROQ-Powered Webhook:**
+- URL: `https://yoursite.com/api/revalidate/tag`
+- Filter: `_type in ["post", "author", "category"]`
+- Projection: `{ "tags": [_type, _type + ":" + slug.current] }`
+
+### Stale Data After Webhook?
+
+Webhooks fire *before* Sanity CDN updates. If you see stale data:
+
+1. **Add delay** — Pass `true` as third arg to `parseBody`
+2. **Or bypass CDN** — Set `useCdn: false` in client config (use sparingly)
+
+## 4. Visual Editing (Stega) & Clean Data
+
+Visual Editing injects invisible characters into strings to enable click-to-edit.
+
+### A. The Golden Rule of Stega
+
+If a string field controls logic (alignment, colors, IDs), you **must** clean it before comparing.
+
+```typescript
+import { stegaClean } from "@sanity/client/stega";
+
+export function Layout({ align }: { align: string }) {
+ // ❌ Bad: Will fail in Edit Mode due to invisible chars
+ // if (align === 'center') ...
+
+ // ✅ Good: Clean the value first
+ const cleanAlign = stegaClean(align);
+ return
+}
+```
+
+### B. Metadata & SEO (Critical)
+
+**Never** let Stega characters leak into `` tags. Always set `stega: false` for metadata fetching.
+
+```typescript
+export async function generateMetadata({ params }) {
+ const { data } = await sanityFetch({
+ query: SEO_QUERY,
+ params: await params,
+ stega: false // 👈 Critical for SEO
+ })
+ return { title: data?.title }
+}
+```
+
+### C. Static Params
+
+When generating static params, fetch only published content and disable stega.
+
+```typescript
+export async function generateStaticParams() {
+ const { data } = await sanityFetch({
+ query: SLUGS_QUERY,
+ perspective: 'published', // 👈 No drafts
+ stega: false
+ })
+ return data
+}
+```
+
+## 5. Setup: Embedded Studio
+
+Mount the Studio on a Next.js route.
+
+**`src/app/studio/[[...tool]]/page.tsx`:**
+
+```typescript
+import { NextStudio } from 'next-sanity/studio'
+import config from '../../../../sanity.config'
+
+export const dynamic = 'force-static'
+export { metadata, viewport } from 'next-sanity/studio'
+
+export default function StudioPage() {
+ return
+}
+```
+
+## 6. Setup: Draft Mode
+
+Enable Presentation Tool and Visual Editing by setting up a draft mode route.
+
+**`src/app/api/draft-mode/enable/route.ts`:**
+
+```typescript
+import { client } from '@/sanity/lib/client'
+import { defineEnableDraftMode } from 'next-sanity/draft-mode'
+import { token } from '@/sanity/lib/token' // Helper to get token
+
+export const { GET } = defineEnableDraftMode({
+ client: client.withConfig({ token }),
+})
+```
+
+## 7. Error Handling
+
+Use `notFound()` for missing documents. Common errors:
+
+| Error | Cause | Solution |
+|-------|-------|----------|
+| 401 Unauthorized | Invalid/missing token | Check `SANITY_API_READ_TOKEN` |
+| 403 Forbidden | CORS not configured | Add URL to CORS origins |
+| Query syntax error | Invalid GROQ | Test in Vision plugin first |
+| Empty result | Wrong filter/params | Log params, check `_type` spelling |
+
+```typescript
+import { notFound } from 'next/navigation'
+
+export default async function PostPage({ params }: Props) {
+ const { data } = await sanityFetch({ query: POST_QUERY, params: await params })
+ if (!data) notFound()
+ return
+}
+```
+
+## 8. Presentation Queries (`usePresentationQuery`)
+
+For faster live editing in the Presentation Tool, use `usePresentationQuery` to fetch only the specific block being edited, rather than re-rendering the entire page.
+
+### Why Use This
+
+- **Without:** Editing a hero title re-fetches the whole page, re-renders all blocks
+- **With:** Only the hero block re-fetches and re-renders
+
+This is especially valuable for pages with many Page Builder blocks or complex Portable Text.
+
+### Basic Pattern
+
+```typescript
+'use client'
+import { usePresentationQuery } from 'next-sanity/hooks'
+import { HERO_PRESENTATION_QUERY } from '@/sanity/lib/queries'
+
+type HeroProps = {
+ _key: string
+ documentId: string
+ title: string
+ subtitle?: string
+ // ... other initial props from page query
+}
+
+export function Hero({ _key, documentId, title, subtitle, ...rest }: HeroProps) {
+ // Fetch block-specific data for faster updates in Presentation Tool
+ const { data } = usePresentationQuery({
+ query: HERO_PRESENTATION_QUERY,
+ params: { documentId, blockKey: _key },
+ })
+
+ // Use presentation data if available, fallback to initial server props
+ const blockData = data?.heroBlock || { title, subtitle, ...rest }
+
+ return (
+
+ {blockData.title}
+ {blockData.subtitle && {blockData.subtitle}
}
+
+ )
+}
+```
+
+### The Presentation Query
+
+Create a query that targets the specific block by `_key`:
+
+```typescript
+// queries.ts
+export const HERO_PRESENTATION_QUERY = defineQuery(`
+ *[_id == $documentId][0]{
+ _id,
+ _type,
+ "heroBlock": pageBuilder[_key == $blockKey && _type == "hero"][0]{
+ title,
+ subtitle,
+ image,
+ theme,
+ // Include all fields the component needs
+ }
+ }
+`)
+```
+
+### Passing Document Context
+
+Your PageBuilder component needs to pass `documentId` to each block:
+
+```typescript
+export function PageBuilder({ content, documentId }: { content: Block[]; documentId: string }) {
+ return (
+
+ {content.map((block) => {
+ switch (block._type) {
+ case "hero":
+ return
+ // ... other blocks
+ }
+ })}
+
+ )
+}
+```
+
+### For Portable Text Blocks
+
+The same pattern works for custom blocks inside Portable Text:
+
+```typescript
+export const PTE_IMAGE_PRESENTATION_QUERY = defineQuery(`
+ *[_id == $documentId][0]{
+ "pteImageBlock": body[_key == $blockKey && _type == "pteImage"][0]{
+ image,
+ caption,
+ alt
+ }
+ }
+`)
+```
+
+**See also:** `visual-editing.md` for the conceptual overview and `page-builder.md` for full Page Builder patterns.
+
+## 9. Pagination Pattern
+
+For listing pages with many entries, use offset-based pagination with a count query.
+
+### Queries
+```typescript
+// Paginated listing
+export const ARTICLES_QUERY = defineQuery(`
+ *[_type == "article" && defined(slug.current)]
+ | order(date desc) [$start...$end] {
+ _id, title, "slug": slug.current, date
+ }
+`);
+
+// Total count for pagination UI
+export const ARTICLES_COUNT_QUERY = defineQuery(`
+ count(*[_type == "article" && defined(slug.current)])
+`);
+```
+
+### Listing Page
+```typescript
+const ENTRIES_PER_PAGE = 10;
+
+export default async function BlogPage({
+ searchParams
+}: {
+ searchParams: Promise<{ page?: string }>
+}) {
+ const { page: pageParam } = await searchParams;
+ const page = parseInt(pageParam || "1");
+ const start = (page - 1) * ENTRIES_PER_PAGE;
+ const end = start + ENTRIES_PER_PAGE;
+
+ const [{ data: articles }, { data: total }] = await Promise.all([
+ sanityFetch({ query: ARTICLES_QUERY, params: { start, end } }),
+ sanityFetch({ query: ARTICLES_COUNT_QUERY })
+ ]);
+
+ const totalPages = Math.ceil(total / ENTRIES_PER_PAGE);
+
+ return (
+
+ {articles.map(article => (
+
+ ))}
+
+
+ );
+}
+```
diff --git a/.agents/skills/sanity-best-practices/references/nuxt.md b/.agents/skills/sanity-best-practices/references/nuxt.md
new file mode 100644
index 0000000..4169ccf
--- /dev/null
+++ b/.agents/skills/sanity-best-practices/references/nuxt.md
@@ -0,0 +1,84 @@
+---
+title: Nuxt & Sanity Integration Rules
+description: Integration guide for Nuxt, including @nuxtjs/sanity, visual editing, and data fetching.
+---
+
+# Nuxt & Sanity Integration Rules
+
+## 1. Setup & Configuration
+
+### Configuration (`nuxt.config.ts`)
+Use the official `@nuxtjs/sanity` module.
+
+**Important:** Ensure the `minimal` client is NOT enabled if you want full features.
+
+```typescript
+export default defineNuxtConfig({
+ modules: ["@nuxtjs/sanity"],
+ sanity: {
+ projectId: process.env.NUXT_SANITY_PROJECT_ID,
+ dataset: process.env.NUXT_SANITY_DATASET,
+ apiVersion: "2026-02-01",
+ // Live Visual Editing Configuration
+ visualEditing: {
+ studioUrl: process.env.NUXT_SANITY_STUDIO_URL,
+ token: process.env.NUXT_SANITY_API_READ_TOKEN, // Required for fetching drafts
+ stega: true, // Enable stega for visual editing
+ mode: 'live-visual-editing', // Default: enables live updates
+ },
+ },
+});
+```
+
+## 2. Data Fetching
+
+### `useSanityQuery`
+Use the composable provided by the module for reactive fetching. It automatically handles preview state when configured.
+
+```vue
+
+
+
+
+
+```
+
+## 3. Visual Editing (Live Preview)
+
+### Automatic Setup
+When `visualEditing` is configured in `nuxt.config.ts`, the module handles:
+1. Injecting the Visual Editing overlays.
+2. Refreshing data when content changes in the Studio.
+3. Enabling Stega encoding.
+
+### Handling Stega in Logic
+Just like Next.js, if you use stega-encoded strings in logic (e.g. `v-if="post.layout === 'full'"`), you must clean them.
+
+```typescript
+import { stegaClean } from "@sanity/client/stega";
+
+const layout = computed(() => stegaClean(props.layout));
+```
+
+## 4. Components
+
+### Portable Text
+Use the `` component (if installed via `@portabletext/vue` or provided by the module).
+
+```vue
+
+```
+
+### Images
+Use `@sanity/image-url` helper or a dedicated image component.
+
+```typescript
+import imageUrlBuilder from '@sanity/image-url'
+const builder = imageUrlBuilder(useSanity().client)
+// ... url generation logic
+```
diff --git a/.agents/skills/sanity-best-practices/references/page-builder.md b/.agents/skills/sanity-best-practices/references/page-builder.md
new file mode 100644
index 0000000..c9d6452
--- /dev/null
+++ b/.agents/skills/sanity-best-practices/references/page-builder.md
@@ -0,0 +1,307 @@
+---
+title: "Sanity Page Builder Patterns"
+description: Patterns for Sanity Page Builder arrays, block components, and live editing.
+---
+
+# Sanity Page Builder Patterns
+
+This guide covers **Page Builder** patterns—arrays of block objects that allow content teams to compose flexible page layouts. For Portable Text (rich text within documents), see `portable-text.md`.
+
+## 1. What is a Page Builder?
+
+A page builder is an **array of objects** (`pageBuilder[]`) that allows content teams to compose pages from reusable blocks without developer intervention.
+
+**When to use:**
+- Flexible layouts needed (marketing pages, landing pages)
+- Content can be reordered
+- Different components on different pages
+
+**When NOT to use:**
+- Rigid, formulaic content (blog posts, product pages)
+- Highly structured data that doesn't change layout
+- Rich text within a document body—use Portable Text instead
+
+## 2. Schema Organization
+
+### Directory Structure
+```
+schemaTypes/
+├── blocks/ # Page builder blocks (objects)
+│ ├── heroType.ts
+│ ├── featuresType.ts
+│ └── faqsType.ts
+├── pageBuilderType.ts # The array definition
+└── pageType.ts # Document using the page builder
+```
+
+### Objects vs References
+
+| Use **Objects** | Use **References** |
+|-----------------|-------------------|
+| Content is unique to this page | Content reused across many pages |
+| Simpler queries | Needs central management |
+| Default choice | FAQs, CTAs, testimonials |
+
+**Rule:** Use references sparingly. Most blocks should be objects.
+
+### Page Builder Array
+```typescript
+// pageBuilderType.ts
+import { defineType, defineArrayMember } from "sanity";
+
+export const pageBuilderType = defineType({
+ name: "pageBuilder",
+ type: "array",
+ of: [
+ defineArrayMember({ type: "hero" }),
+ defineArrayMember({ type: "splitImage" }),
+ defineArrayMember({ type: "features" }),
+ defineArrayMember({ type: "faqs" }),
+ ],
+ options: {
+ insertMenu: {
+ views: [
+ // Optional: Show visual thumbnails in the insert menu grid
+ { name: "grid", previewImageUrl: (type) => `/block-previews/${type}.png` },
+ ],
+ },
+ },
+});
+```
+
+### Block Preview Pattern
+Every block should have consistent previews:
+
+```typescript
+import { defineType } from "sanity";
+import { BlockContentIcon } from "@sanity/icons";
+
+export const splitImageType = defineType({
+ name: "splitImage",
+ type: "object",
+ icon: BlockContentIcon,
+ fields: [/* ... */],
+ preview: {
+ select: { title: "title", media: "image" },
+ prepare({ title, media }) {
+ return {
+ title: title || "Untitled",
+ subtitle: "Split Image", // Block type name
+ media: media ?? BlockContentIcon, // Fallback to icon
+ };
+ },
+ },
+});
+```
+
+## 3. Querying Page Builders
+
+Expand references only for blocks that need them:
+
+```groq
+*[_type == "page" && slug.current == $slug][0]{
+ ...,
+ content[]{
+ ...,
+ _type == "faqs" => {
+ ...,
+ faqs[]-> // Expand only FAQ references
+ }
+ }
+}
+```
+
+## 4. Rendering Page Builders
+
+### TypeScript Typing
+Use `Extract` to type individual blocks from the query result:
+
+```typescript
+import { PAGE_QUERYResult } from "@/sanity/types";
+
+type HeroProps = Extract<
+ NonNullable["content"]>[number],
+ { _type: "hero" }
+>;
+
+export function Hero({ title, image }: HeroProps) {
+ // Fully typed!
+}
+```
+
+### Switch-Based Rendering
+```typescript
+export function PageBuilder({ content }: { content: Block[] }) {
+ if (!Array.isArray(content)) return null;
+
+ return (
+
+ {content.map((block) => {
+ switch (block._type) {
+ case "hero":
+ return ;
+ case "features":
+ return ;
+ case "splitImage":
+ return ;
+ default:
+ return Unknown: {block._type}
;
+ }
+ })}
+
+ );
+}
+```
+
+**Always use `_key` for React keys:**
+```typescript
+// Breaks Visual Editing and causes hydration issues
+{items.map((item, i) => )}
+
+// Always use Sanity's _key
+{items.map((item) => )}
+```
+
+### Cleaning Values for Logic
+Use `stegaClean` when block fields control rendering logic:
+
+```typescript
+import { stegaClean } from "next-sanity";
+
+function SplitImage({ orientation, title, image }) {
+ return (
+
+ );
+}
+```
+
+## 5. Presentation Queries for Live Editing (Next.js)
+
+For faster live updates in the Presentation Tool, use **presentation queries** that fetch only the specific block being edited, rather than re-fetching the entire page.
+
+> **Note:** This pattern uses `usePresentationQuery` from `next-sanity/hooks`. For other frameworks, check your loader package for equivalent functionality.
+
+### The Pattern
+
+1. **Create a block-specific presentation query:**
+
+```typescript
+// queries.ts
+export const HERO_PRESENTATION_QUERY = defineQuery(`
+ *[_id == $documentId][0]{
+ _id,
+ _type,
+ "heroBlock": pageBuilder[_key == $blockKey && _type == "hero"][0]{
+ title,
+ subtitle,
+ image,
+ // ... all fields the component needs
+ }
+ }
+`)
+```
+
+2. **Use `usePresentationQuery` in your component:**
+
+```typescript
+'use client'
+import { usePresentationQuery } from 'next-sanity/hooks'
+import { HERO_PRESENTATION_QUERY } from '@/sanity/lib/queries'
+
+type HeroProps = {
+ _key: string
+ documentId: string
+ // ... initial props from page query
+}
+
+export function Hero({ _key, documentId, ...initialProps }: HeroProps) {
+ // Fetch block-specific data for faster updates
+ const { data } = usePresentationQuery({
+ query: HERO_PRESENTATION_QUERY,
+ params: { documentId, blockKey: _key },
+ })
+
+ // Use presentation data if available, fallback to initial props
+ const blockData = data?.heroBlock || initialProps
+
+ return (
+
+ {blockData.title}
+ {/* ... */}
+
+ )
+}
+```
+
+### Why This Is Faster
+
+- **Without:** Editing a field triggers a full page re-render with all blocks
+- **With:** Only the specific block re-renders with its targeted query
+
+This pattern is especially valuable for pages with many blocks or complex nested data.
+
+**Note:** See `nextjs.md` for more details on `usePresentationQuery` and `visual-editing.md` for the conceptual overview.
+
+## 6. Page Builder Pitfalls
+
+| Pitfall | Solution |
+|---------|----------|
+| Too many block variations | Split into separate blocks if >2 variants |
+| Paradox of choice | Limit blocks per document type |
+| Overusing references | Default to objects; references only for truly shared content |
+| Unused blocks accumulate | Prune regularly; see deprecation patterns |
+| Inconsistent previews | Always set title, subtitle (block name), and media/icon |
+
+## 7. Component Alignment Pattern
+Map Sanity "alignment" fields (usually string/select) to CSS classes using utility functions.
+
+**Schema:**
+```typescript
+defineField({
+ name: 'align',
+ type: 'string',
+ options: { list: ['left', 'center', 'right'], layout: 'radio' }
+})
+```
+
+**Implementation (Utility):**
+```typescript
+import { stegaClean } from "@sanity/client/stega";
+
+export function getTextAlign(align?: string) {
+ // CLEAN the value before switching!
+ switch (stegaClean(align)) {
+ case 'left': return 'text-left';
+ case 'right': return 'text-right';
+ default: return 'text-center';
+ }
+}
+```
+
+## 8. Semantic Heading Levels
+**Rule:** Do NOT store heading levels (h1, h2) in Sanity schema options. Determine them dynamically in the frontend to ensure accessibility.
+
+**Bad Schema:**
+```typescript
+// Don't do this
+{ name: 'level', type: 'string', options: { list: ['h1', 'h2'] } }
+```
+
+**Good Component:**
+Pass a `semanticLevel` prop based on the component's context/nesting.
+
+```typescript
+type Props = {
+ block: HeroBlock;
+ level?: 'h1' | 'h2' | 'h3'; // Default to h2 if undefined
+}
+
+export default function Section({ block, level = 'h2' }: Props) {
+ const Tag = level;
+ return {block.title};
+}
+```
+
+*Note: For Image patterns, see `image.md`. For Portable Text patterns, see `portable-text.md`.*
diff --git a/.agents/skills/sanity-best-practices/references/portable-text.md b/.agents/skills/sanity-best-practices/references/portable-text.md
new file mode 100644
index 0000000..8433938
--- /dev/null
+++ b/.agents/skills/sanity-best-practices/references/portable-text.md
@@ -0,0 +1,365 @@
+---
+title: "Sanity Portable Text Rules"
+description: Portable Text (Rich Text) rendering and custom component creation for React/Next.js.
+---
+
+# Sanity Portable Text Rules
+
+Portable Text is Sanity's rich text format, used for content like article bodies (`body[]`). This guide covers rendering and creating custom PTE components.
+
+**Note:** For page-level layout blocks (`pageBuilder[]`), see `page-builder.md`.
+
+## 1. The Component
+Use the `PortableText` component from `next-sanity` (or `@portabletext/react`).
+
+```typescript
+import { PortableText } from "next-sanity";
+// or import { PortableText } from "@portabletext/react";
+
+export function Content({ value }: { value: any }) {
+ return ;
+}
+```
+
+## 2. Custom Components (`components` prop)
+**Always** define a typed components object to handle custom blocks, marks, and list styles.
+
+```typescript
+import { PortableTextComponents } from "next-sanity";
+
+const components: PortableTextComponents = {
+ // 1. Block styles (paragraphs, headings)
+ block: {
+ h1: ({ children }) => {children}
,
+ h2: ({ children }) => {children}
,
+ blockquote: ({ children }) => {children}
,
+ },
+
+ // 2. Custom types (non-text blocks like images, videos)
+ types: {
+ image: ({ value }) => ,
+ callToAction: ({ value }) => ,
+ },
+
+ // 3. Marks (inline decorators and annotations)
+ marks: {
+ strong: ({ children }) => {children},
+ em: ({ children }) => {children},
+ link: ({ children, value }) => {
+ const rel = !value.href.startsWith("/") ? "noreferrer noopener" : undefined;
+ return {children};
+ },
+ },
+
+ // 4. Lists
+ list: {
+ bullet: ({ children }) => ,
+ number: ({ children }) => {children}
,
+ },
+};
+```
+
+## 3. Component Categories
+
+Portable Text has three types of custom components, each with different patterns:
+
+| Type | Examples | Pattern |
+|------|----------|---------|
+| **Block styles** | h1, h2, blockquote, normal | Text blocks with `children` prop |
+| **Custom types** | image, video, callToAction | Non-text blocks with `value` prop |
+| **Marks** | link, strong, productRef | Inline annotations wrapping text |
+
+## 4. Creating Block Style Components
+
+Block styles are text blocks like headings and paragraphs. For simple styling, inline components work fine:
+
+```typescript
+block: {
+ h2: ({ children }) => {children}
,
+ normal: ({ children }) => {children}
,
+}
+```
+
+### With Visual Editing Support
+
+For live editing in the Presentation Tool, block style components may need **both** a client and server version:
+
+```typescript
+// Heading2.tsx (Server - simple SSR for production)
+export function Heading2({ children }: { children: React.ReactNode }) {
+ return {children}
;
+}
+
+// Heading2Client.tsx (Client - for visual editing context)
+'use client'
+export function Heading2Client({ children, value }: { children: React.ReactNode; value: any }) {
+ // Can access block data via `value` for advanced patterns
+ return {children}
;
+}
+```
+
+Use `useIsPresentationTool` to conditionally render the client version:
+
+```typescript
+import { useIsPresentationTool } from 'next-sanity/hooks'
+
+function Heading2Wrapper(props) {
+ const isPresentationTool = useIsPresentationTool()
+
+ if (isPresentationTool) {
+ return
+ }
+ return
+}
+```
+
+## 5. Creating Custom Type Components
+
+Custom types are non-text blocks like images, videos, or CTAs embedded in rich text.
+
+### Schema Definition
+
+```typescript
+// schemaTypes/blocks/pteImageBlock.ts
+import { defineType, defineField } from 'sanity'
+
+export const pteImageBlock = defineType({
+ name: 'pteImage',
+ title: 'Image',
+ type: 'object',
+ fields: [
+ defineField({ name: 'image', type: 'image', options: { hotspot: true } }),
+ defineField({ name: 'caption', type: 'string' }),
+ defineField({ name: 'alt', type: 'string', validation: (r) => r.required() }),
+ ],
+ preview: {
+ select: { title: 'caption', media: 'image' },
+ },
+})
+```
+
+### Register in Body Schema
+
+```typescript
+defineField({
+ name: 'body',
+ type: 'array',
+ of: [
+ { type: 'block' }, // Standard text
+ { type: 'pteImage' }, // Custom image block
+ { type: 'pteVideo' }, // Custom video block
+ ],
+})
+```
+
+### Frontend Component
+
+```typescript
+// PteImageComponent.tsx
+'use client'
+
+type PteImageProps = {
+ value: {
+ _key: string
+ image: any
+ caption?: string
+ alt: string
+ }
+}
+
+export function PteImageComponent({ value }: PteImageProps) {
+ if (!value.image) return null
+
+ return (
+
+
+ {value.caption && (
+ {value.caption}
+ )}
+
+ )
+}
+
+// Register in components
+const components: PortableTextComponents = {
+ types: {
+ pteImage: PteImageComponent,
+ },
+}
+```
+
+## 6. Creating Mark Components
+
+Marks are inline annotations that wrap text—links, highlights, or custom references.
+
+### Schema Definition (Annotation)
+
+```typescript
+// In your block configuration
+defineField({
+ name: 'body',
+ type: 'array',
+ of: [
+ {
+ type: 'block',
+ marks: {
+ decorators: [
+ { title: 'Strong', value: 'strong' },
+ { title: 'Emphasis', value: 'em' },
+ { title: 'Highlight', value: 'highlight' },
+ ],
+ annotations: [
+ {
+ name: 'link',
+ type: 'object',
+ title: 'Link',
+ fields: [
+ { name: 'href', type: 'url', title: 'URL' },
+ { name: 'openInNewTab', type: 'boolean', title: 'Open in new tab' },
+ ],
+ },
+ {
+ name: 'productRef',
+ type: 'object',
+ title: 'Product Reference',
+ fields: [
+ { name: 'product', type: 'reference', to: [{ type: 'product' }] },
+ ],
+ },
+ ],
+ },
+ },
+ ],
+})
+```
+
+### Frontend Component
+
+```typescript
+// LinkMark.tsx
+type LinkMarkProps = {
+ children: React.ReactNode
+ value: {
+ href: string
+ openInNewTab?: boolean
+ }
+}
+
+export function LinkMark({ children, value }: LinkMarkProps) {
+ const { href, openInNewTab } = value
+ const target = openInNewTab ? '_blank' : undefined
+ const rel = openInNewTab ? 'noopener noreferrer' : undefined
+
+ return (
+
+ {children}
+
+ )
+}
+
+// Register in components
+const components: PortableTextComponents = {
+ marks: {
+ link: LinkMark,
+ highlight: ({ children }) => {children},
+ },
+}
+```
+
+## 7. Presentation Queries for PTE Blocks
+
+For faster live editing of custom PTE blocks, use presentation queries that fetch only the specific block:
+
+```typescript
+// queries.ts
+export const PTE_IMAGE_PRESENTATION_QUERY = defineQuery(`
+ *[_id == $documentId][0]{
+ _id,
+ _type,
+ "pteImageBlock": body[_key == $blockKey && _type == "pteImage"][0]{
+ _key,
+ image,
+ caption,
+ alt
+ }
+ }
+`)
+```
+
+Then in your component:
+
+```typescript
+'use client'
+import { usePresentationQuery } from 'next-sanity/hooks'
+
+export function PteImageComponent({ value, documentId }: { value: any; documentId?: string }) {
+ const { data } = usePresentationQuery({
+ query: PTE_IMAGE_PRESENTATION_QUERY,
+ params: { documentId, blockKey: value._key },
+ })
+
+ const blockData = data?.pteImageBlock || value
+
+ // ... render with blockData
+}
+```
+
+**Note:** You'll need to pass `documentId` through to your PTE components. See `visual-editing.md` for context patterns.
+
+## 8. GROQ Fragment for PTE
+
+When querying documents with Portable Text, expand custom blocks:
+
+```groq
+*[_type == "article" && slug.current == $slug][0]{
+ ...,
+ body[]{
+ ...,
+ _type == "pteImage" => {
+ ...,
+ "imageUrl": image.asset->url
+ },
+ _type == "pteVideo" => {
+ ...,
+ video->{ title, url }
+ }
+ }
+}
+```
+
+## 9. Stega and Visual Editing
+
+When Visual Editing is enabled, text content contains invisible stega characters for click-to-edit functionality.
+
+**For text rendering:** Let stega characters pass through—they enable overlays:
+```typescript
+// Good - stega preserved for click-to-edit
+{children}
+```
+
+**For logic/comparisons:** Clean the values first:
+```typescript
+import { stegaClean } from '@sanity/client/stega'
+
+// Clean before using in logic
+const cleanedStyle = stegaClean(block.style)
+if (cleanedStyle === 'h2') { ... }
+```
+
+## 10. Type Safety
+When using TypeGen, the Portable Text value usually has a complex generated type. You can often use `any` or `PortableTextBlock[]` for the *prop*, but cast specific blocks if needed.
+
+```typescript
+import { PortableTextBlock } from "next-sanity";
+
+type Props = {
+ value: PortableTextBlock[];
+};
+```
+
+## 11. Best Practices
+
+- **Tailwind Typography:** For simple blogs, wrap `` in a `` (from `@tailwindcss/typography`) instead of manually styling every block.
+- **Handling Nulls:** Always check if `value` exists and is an array before rendering.
+- **Keys:** The `PortableText` component handles React keys automatically using the `_key` from Sanity. Do not add keys manually.
+- **Separate from Page Builder:** PTE blocks live in `body[]` (rich text fields), not `pageBuilder[]` (page layout). Keep these patterns separate.
diff --git a/.agents/skills/sanity-best-practices/references/project-structure.md b/.agents/skills/sanity-best-practices/references/project-structure.md
new file mode 100644
index 0000000..03a6e5b
--- /dev/null
+++ b/.agents/skills/sanity-best-practices/references/project-structure.md
@@ -0,0 +1,116 @@
+---
+title: Sanity Project Structure
+description: Project structure patterns for Sanity projects including monorepo and embedded Studio setups.
+---
+
+# Sanity Project Structure
+
+## Standalone Studio
+
+Best for content-only projects, API-first architectures, or when frontend is managed separately.
+
+```
+your-project/
+├── schemaTypes/
+│ ├── index.ts
+│ ├── documents/
+│ ├── objects/
+│ └── blocks/
+├── sanity.config.ts
+├── sanity.cli.ts
+└── package.json
+```
+
+**Use cases:**
+- Content modeling with MCP/AI tools (no frontend needed)
+- Headless CMS with external consumers
+- Prototyping and content design
+
+## Embedded Studio (Recommended for Next.js)
+
+Best for most Next.js projects. Unified deployment, simpler setup.
+
+```
+your-project/
+├── src/
+│ ├── app/ # Next.js App Router
+│ │ └── studio/[[...tool]]/ # Embedded Studio route
+│ └── sanity/
+│ ├── lib/
+│ │ ├── client.ts
+│ │ ├── live.ts # defineLive setup
+│ │ └── queries.ts
+│ └── schemaTypes/
+│ ├── index.ts
+│ ├── documents/
+│ ├── objects/
+│ └── blocks/
+├── sanity.config.ts
+├── sanity.cli.ts # CLI + TypeGen configuration
+└── sanity.types.ts # Generated types (from TypeGen)
+```
+
+## Monorepo
+
+Best when you need separation of concerns, multiple frontends, or strict dependency isolation.
+
+```
+your-project/
+├── apps/
+│ ├── studio/ # Sanity Studio (standalone)
+│ │ ├── src/
+│ │ │ └── schemaTypes/
+│ │ │ ├── index.ts
+│ │ │ ├── documents/
+│ │ │ ├── objects/
+│ │ │ └── blocks/
+│ │ ├── sanity.config.ts
+│ │ ├── sanity.cli.ts
+│ │ └── package.json
+│ └── web/ # Next.js (or other framework)
+│ ├── src/
+│ │ ├── app/
+│ │ └── sanity/
+│ │ ├── client.ts
+│ │ ├── live.ts
+│ │ └── queries.ts
+│ └── package.json
+├── pnpm-workspace.yaml
+└── package.json
+```
+
+**Setup:**
+1. Add web app URL to CORS origins in [Sanity Manage](https://www.sanity.io/manage)
+2. Configure `typegen` in `sanity.cli.ts` to read schema from `apps/studio` and output types to `apps/web`
+
+## File Naming Conventions
+
+- **kebab-case** for all files: `user-profile.ts`, `hero-block.ts`
+- `.ts` for schemas/utilities, `.tsx` for React components
+- Each schema exports a named const matching filename
+
+## Schema Directory Structure
+
+```
+schemaTypes/
+├── index.ts # Exports all types
+├── documents/ # Standalone content types
+│ ├── post.ts
+│ └── author.ts
+├── objects/ # Embeddable/reusable types
+│ ├── seo.ts
+│ └── link.ts
+├── blocks/ # Portable Text blocks
+│ ├── hero.ts
+│ └── callout.ts
+└── shared/ # Shared field definitions
+ └── seoFields.ts
+```
+
+## Key Files
+
+| File | Purpose |
+|------|---------|
+| `sanity.config.ts` | Studio configuration (plugins, schema, structure) |
+| `sanity.cli.ts` | CLI configuration (project ID, dataset, TypeGen config) |
+| `structure.ts` | Custom desk structure |
diff --git a/.agents/skills/sanity-best-practices/references/remix.md b/.agents/skills/sanity-best-practices/references/remix.md
new file mode 100644
index 0000000..5ee232a
--- /dev/null
+++ b/.agents/skills/sanity-best-practices/references/remix.md
@@ -0,0 +1,134 @@
+---
+title: React Router (Remix) & Sanity Integration Rules
+description: Integration guide for React Router (formerly Remix) with Sanity, including Loaders and Visual Editing.
+---
+
+# React Router (Remix) & Sanity Integration Rules
+
+## Version Note
+
+This guide covers both:
+- **Remix v2** (`@remix-run/*` packages)
+- **React Router v7** (the successor to Remix, `react-router` package)
+
+The Sanity integration pattern is the same for both. Import paths differ slightly:
+
+| Remix v2 | React Router v7 |
+|----------|-----------------|
+| `@remix-run/node` | `react-router` |
+| `@remix-run/react` | `react-router` |
+| `remix.config.js` | `react-router.config.ts` |
+
+The examples below use Remix v2 imports. Adjust if using React Router v7.
+
+## 1. Setup & Client Pattern
+
+To support both server-side fetching and client-side live previews, use the **Split Loader Pattern**.
+
+### A. Shared Loader (`app/sanity/loader.ts`)
+Defines the store config (SSR enabled, client deferred).
+
+```typescript
+import { createQueryStore } from '@sanity/react-loader'
+
+export const {
+ loadQuery,
+ setServerClient,
+ useQuery,
+ useLiveMode,
+} = createQueryStore({ client: false, ssr: true })
+```
+
+### B. Server Loader (`app/sanity/loader.server.ts`)
+Initializes the server client.
+
+```typescript
+import { createClient } from '@sanity/client'
+import { loadQuery, setServerClient } from './loader'
+
+const client = createClient({
+ projectId: process.env.SANITY_PROJECT_ID,
+ dataset: process.env.SANITY_DATASET,
+ useCdn: true,
+ apiVersion: '2026-02-01',
+ stega: {
+ enabled: true,
+ studioUrl: 'https://my-studio-url.com',
+ },
+})
+
+setServerClient(client)
+
+export { loadQuery }
+```
+
+## 2. Data Fetching (Loaders)
+
+Use `loadQuery` from your **server** file in route loaders.
+
+```typescript
+import type { LoaderFunctionArgs } from "@remix-run/node";
+import { useLoaderData } from "@remix-run/react";
+import { loadQuery } from "~/sanity/loader.server";
+import { POSTS_QUERY } from "~/sanity/queries";
+
+export async function loader({ params }: LoaderFunctionArgs) {
+ const initial = await loadQuery(POSTS_QUERY, params);
+ return { initial, query: POSTS_QUERY, params };
+}
+
+export default function Index() {
+ const { initial, query, params } = useLoaderData
();
+ // ... pass to component
+}
+```
+
+## 3. Real-time Preview & Visual Editing
+
+### A. Use `useQuery` in Components
+Import `useQuery` from your **shared** loader file.
+
+```typescript
+import { useQuery } from "~/sanity/loader";
+
+export default function Page() {
+ const { initial, query, params } = useLoaderData();
+
+ const { data, encodeDataAttribute } = useQuery(query, params, {
+ initial
+ });
+
+ return (
+
+ {data?.title}
+
+ );
+}
+```
+
+### B. Enable Live Mode (`VisualEditing.tsx`)
+Create a component to handle the connection.
+
+```typescript
+import { enableVisualEditing } from '@sanity/visual-editing'
+import { useLiveMode } from '~/sanity/loader'
+import { client } from '~/sanity/client' // Your browser-safe client
+import { useEffect } from 'react'
+
+export default function VisualEditing() {
+ useEffect(() => enableVisualEditing(), [])
+ useLiveMode({ client })
+ return null
+}
+```
+
+Render this component in `root.tsx` only when valid (e.g., check env vars or user session).
+
+## 4. Stega Cleaning
+When using data for logic (routing, classNames), use `stegaClean`.
+
+```typescript
+import { stegaClean } from "@sanity/client/stega"
+// ...
+if (stegaClean(slug) === 'home') { ... }
+```
diff --git a/.agents/skills/sanity-best-practices/references/schema.md b/.agents/skills/sanity-best-practices/references/schema.md
new file mode 100644
index 0000000..33d650d
--- /dev/null
+++ b/.agents/skills/sanity-best-practices/references/schema.md
@@ -0,0 +1,378 @@
+---
+title: Sanity Schema Best Practices
+description: Rules for defining Sanity Content Models (Schemas), including field definitions, strict typing, and validation patterns.
+---
+
+# Sanity Schema Best Practices
+
+Use this contents list to jump to the schema design decision you are making.
+
+## Table of Contents
+
+- Core philosophy: data over presentation
+- Strict definition syntax
+- Shared fields pattern
+- Field patterns
+- References vs nested objects
+- Safe schema updates
+- Validation patterns
+
+## 1. Core Philosophy: Data > Presentation
+Model **what things are**, not **what they look like**.
+- ❌ **Bad:** `bigHeroText`, `redButton`, `threeColumnRow`, `color`, `fontSize`
+- ✅ **Good:** `heroStatement`, `callToAction`, `featuresSection`, `status`, `role`
+
+**The test:** "If we redesigned the site, would this field name still make sense?"
+- `threeColumnLayout` → ❌ Fails (what if we go to 2 columns?)
+- `features` → ✅ Passes (features are features regardless of layout)
+
+## 2. Strict Definition Syntax
+Always use the helper functions from `sanity` for type safety and autocompletion.
+
+- **ALWAYS** use `defineType` for the root export.
+- **ALWAYS** use `defineField` for fields.
+- **ALWAYS** use `defineArrayMember` for items inside arrays.
+
+```typescript
+import { defineType, defineField, defineArrayMember } from 'sanity'
+import { TagIcon } from '@sanity/icons'
+
+export const article = defineType({
+ name: 'article',
+ title: 'Article',
+ type: 'document',
+ icon: TagIcon,
+ fields: [
+ defineField({
+ name: 'title',
+ type: 'string',
+ validation: (rule) => rule.required(),
+ }),
+ defineField({
+ name: 'tags',
+ type: 'array',
+ of: [
+ // ALWAYS use defineArrayMember for array items
+ defineArrayMember({ type: 'reference', to: [{ type: 'tag' }] })
+ ]
+ })
+ ]
+})
+```
+
+## 3. Shared Fields Pattern
+Export arrays of fields to reuse common patterns (e.g., SEO, standard page headers).
+
+```typescript
+// src/schemaTypes/shared/seoFields.ts
+export const seoFields = [
+ defineField({ name: 'seoTitle', type: 'string', title: 'SEO Title' }),
+ defineField({ name: 'seoDesc', type: 'text', title: 'SEO Description' })
+]
+
+// Usage
+defineType({
+ name: 'page',
+ fields: [
+ defineField({ name: 'title', type: 'string' }),
+ ...seoFields // Spread shared fields
+ ]
+})
+```
+
+## 4. Field Patterns
+
+### A. Array Keys (`_key`)
+Every item in a Sanity array automatically gets a `_key` property. This is **critical** for:
+- React reconciliation (use as `key` prop)
+- Visual Editing overlays (click-to-edit)
+- Portable Text rendering
+
+**Schema:** Sanity auto-generates `_key` for array items. You don't define it.
+
+**Frontend:** Always use `_key` as React's `key`:
+```typescript
+// ✅ Correct
+{items.map((item) => )}
+
+// ❌ Wrong - index keys break Visual Editing
+{items.map((item, i) => )}
+```
+
+**Querying:** Always include `_key` in array projections:
+```groq
+*[_type == "page"][0]{
+ pageBuilder[]{
+ _key, // Always include _key in queries
+ _type,
+ ...
+ }
+}
+```
+
+### B. Icons
+Always assign an icon from `@sanity/icons` to documents and objects. This improves the Studio UX significantly. Browse all icons at [icons.sanity.build](https://icons.sanity.build/all).
+
+| Content Type | Icon |
+|--------------|------|
+| Article, Post | `DocumentTextIcon` |
+| Author, Person | `UserIcon` |
+| Category, Tag | `TagIcon` |
+| Settings | `CogIcon` |
+| Page | `DocumentIcon` |
+| Image block | `ImageIcon` |
+| Video block | `PlayIcon` |
+| FAQ | `HelpCircleIcon` |
+| Link | `LinkIcon` |
+
+### C. Boolean vs. List
+Avoid boolean fields for binary states that might expand later.
+- **Prefer:** `options.list` with "radio" layout.
+
+```typescript
+defineField({
+ name: 'status',
+ type: 'string',
+ options: {
+ list: [
+ { title: 'Draft', value: 'draft' },
+ { title: 'Published', value: 'published' }
+ ],
+ layout: 'radio'
+ }
+})
+```
+
+### D. The "Toggle" Pattern (Conditional Fields)
+Use a radio/boolean field to toggle visibility of other fields (often grouped in fieldsets).
+
+```typescript
+defineField({
+ name: 'linkType',
+ type: 'string',
+ options: { list: ['internal', 'external'], layout: 'radio' }
+}),
+defineField({
+ name: 'internalLink',
+ type: 'reference',
+ hidden: ({ parent }) => parent?.linkType !== 'internal'
+}),
+defineField({
+ name: 'externalUrl',
+ type: 'url',
+ hidden: ({ parent }) => parent?.linkType !== 'external'
+})
+```
+
+## 5. References vs Nested Objects
+
+A **critical modeling decision**: when to use `reference` vs embedding an `object`.
+
+### Use References When:
+- Content is **reusable** across documents (authors, categories, products)
+- Content needs its **own editing interface** in Studio
+- You need to query/filter by the related content independently
+- Multiple documents should share the **same instance** (update once, reflect everywhere)
+
+```typescript
+// ✅ Author is reusable and independently editable
+defineField({
+ name: 'author',
+ type: 'reference',
+ to: [{ type: 'author' }]
+})
+```
+
+### Use Nested Objects When:
+- Content is **specific to this document** (not shared)
+- Content doesn't make sense on its own (address, SEO metadata)
+- You want **simpler editing** (all fields in one place)
+- You need the data to be **copied** not linked
+
+```typescript
+// ✅ SEO is document-specific, not shared
+defineField({
+ name: 'seo',
+ type: 'object',
+ fields: [
+ defineField({ name: 'title', type: 'string' }),
+ defineField({ name: 'description', type: 'text' })
+ ]
+})
+```
+
+### Quick Decision Matrix
+
+| Scenario | Use |
+|----------|-----|
+| Blog post author | `reference` (reusable) |
+| Product category | `reference` (shared taxonomy) |
+| Page SEO fields | `object` (page-specific) |
+| Hero section content | `object` (page-specific) |
+| Team member on About page | `reference` (might be used elsewhere) |
+| Call-to-action button | `object` (usually page-specific) |
+
+### Querying Differences
+```groq
+// Reference requires expansion
+*[_type == "post"]{ author->{ name, bio } }
+
+// Object is already inline
+*[_type == "post"]{ seo { title, description } }
+```
+
+## 6. Safe Schema Updates (The Deprecation Pattern)
+
+**NEVER** delete a field that contains production data. It will cause data loss or Studio crashes. Instead, follow the **ReadOnly -> Hidden -> Deprecated** lifecycle.
+
+### The Pattern
+1. **`deprecated`**: Adds a visual warning and reason.
+2. **`readOnly: true`**: Prevents new edits but keeps data visible.
+3. **`hidden`**: Hides it from *new* documents (where value is undefined).
+4. **`initialValue: undefined`**: Ensures new documents don't get this field.
+
+```typescript
+defineField({
+ name: 'oldTitle', // The field you want to remove
+ title: 'Article Title (Deprecated)',
+ type: 'string',
+ deprecated: {
+ reason: 'Use the new "seoTitle" field instead. This will be removed in v2.'
+ },
+ readOnly: true,
+ hidden: ({ value }) => value === undefined,
+ initialValue: undefined
+})
+```
+
+### Migration Workflow
+
+**Phase 1: Deprecate** — Apply the deprecation pattern above. Deploy.
+
+**Phase 2: Migrate** — Update frontend to use new fields (with `coalesce()` fallbacks). Create a migration:
+
+```typescript
+// migrations/rename-oldTitle-to-newTitle/index.ts
+import {defineMigration, at, setIfMissing, unset} from 'sanity/migrate'
+
+export default defineMigration({
+ title: 'Rename oldTitle to newTitle',
+ documentTypes: ['article'],
+ filter: 'defined(oldTitle) && !defined(newTitle)',
+ migrate: {
+ document(doc) {
+ if (!doc.oldTitle || doc.newTitle) return
+ return [
+ at('newTitle', setIfMissing(doc.oldTitle)),
+ at('oldTitle', unset())
+ ]
+ }
+ }
+})
+```
+
+```bash
+# Dry run first (default)
+sanity migration run rename-oldTitle-to-newTitle
+
+# Execute when ready
+sanity migration run rename-oldTitle-to-newTitle --no-dry-run
+```
+
+**Phase 3: Remove** — Once `oldTitle` is undefined for all documents, delete the field definition.
+
+## 7. Validation Patterns
+
+Beyond `rule.required()`, Sanity offers powerful validation options.
+
+### Common Patterns
+
+```typescript
+// Email validation
+defineField({
+ name: 'email',
+ type: 'string',
+ validation: (rule) => rule.email().required()
+})
+
+// URL validation (with custom message)
+defineField({
+ name: 'website',
+ type: 'url',
+ validation: (rule) => rule.uri({
+ scheme: ['http', 'https']
+ }).error('Must be a valid URL starting with http:// or https://')
+})
+
+// Length constraints
+defineField({
+ name: 'excerpt',
+ type: 'text',
+ validation: (rule) => rule.max(200).warning('Keep it under 200 characters for best SEO')
+})
+
+// Regex pattern
+defineField({
+ name: 'slug',
+ type: 'slug',
+ validation: (rule) => rule.required().custom((slug) => {
+ if (!slug?.current) return 'Required'
+ if (!/^[a-z0-9-]+$/.test(slug.current)) {
+ return 'Slug must be lowercase with hyphens only'
+ }
+ return true
+ })
+})
+```
+
+### Cross-Field Validation
+
+```typescript
+defineField({
+ name: 'endDate',
+ type: 'datetime',
+ validation: (rule) => rule.custom((endDate, context) => {
+ const startDate = context.document?.startDate
+ if (startDate && endDate && new Date(endDate) < new Date(startDate)) {
+ return 'End date must be after start date'
+ }
+ return true
+ })
+})
+```
+
+### Array Validation
+
+```typescript
+defineField({
+ name: 'tags',
+ type: 'array',
+ of: [{ type: 'string' }],
+ validation: (rule) => rule
+ .min(1).error('Add at least one tag')
+ .max(10).warning('Too many tags may hurt SEO')
+ .unique()
+})
+```
+
+### Async Validation (Uniqueness Check)
+
+```typescript
+defineField({
+ name: 'slug',
+ type: 'slug',
+ validation: (rule) => rule.required().custom(async (slug, context) => {
+ if (!slug?.current) return true
+
+ const client = context.getClient({ apiVersion: '2026-02-01' })
+ const id = context.document?._id?.replace(/^drafts\./, '')
+
+ const existing = await client.fetch(
+ `count(*[_type == "post" && slug.current == $slug && _id != $id])`,
+ { slug: slug.current, id }
+ )
+
+ return existing === 0 || 'Slug already exists'
+ })
+})
+```
diff --git a/.agents/skills/sanity-best-practices/references/seo.md b/.agents/skills/sanity-best-practices/references/seo.md
new file mode 100644
index 0000000..5031275
--- /dev/null
+++ b/.agents/skills/sanity-best-practices/references/seo.md
@@ -0,0 +1,331 @@
+---
+title: Sanity SEO Best Practices
+description: SEO best practices for Sanity with Next.js, including metadata, Open Graph, sitemaps, redirects, and JSON-LD structured data.
+---
+
+# Sanity SEO Best Practices
+
+## 1. Core Philosophy
+
+SEO doesn't require complex configurations. A few core principles, applied consistently:
+
+- **Smart defaults with optional overrides** — Don't require SEO fields; use existing content as fallback
+- **Use GROQ for fallback logic** — Move conditional logic into queries, not components
+- **Leverage Next.js APIs** — Use `generateMetadata`, `sitemap.ts`, not manual `` tags
+- **Structured content = structured data** — Your content model is already SEO-ready
+
+## 2. SEO Schema Type (Reusable)
+
+Create a reusable SEO object type for consistent metadata across document types.
+
+```typescript
+// schemaTypes/seoType.ts
+import { defineField, defineType } from "sanity";
+
+export const seoType = defineType({
+ name: "seo",
+ title: "SEO",
+ type: "object",
+ fields: [
+ defineField({
+ name: "title",
+ description: "Overrides the page title if provided",
+ type: "string",
+ }),
+ defineField({
+ name: "description",
+ type: "text",
+ rows: 3,
+ }),
+ defineField({
+ name: "image",
+ description: "Image for social sharing (1200x630 recommended)",
+ type: "image",
+ options: { hotspot: true },
+ }),
+ defineField({
+ name: "noIndex",
+ description: "Hide this page from search engines",
+ type: "boolean",
+ initialValue: false,
+ }),
+ ],
+});
+```
+
+**Usage in document types:**
+```typescript
+defineField({
+ name: "seo",
+ type: "seo",
+})
+```
+
+## 3. GROQ Queries with Fallbacks
+
+Use `coalesce()` to provide fallback values. This keeps frontend logic clean.
+
+```groq
+*[_type == "page" && slug.current == $slug][0]{
+ ...,
+ "seo": {
+ // Use SEO field if provided, otherwise fall back to main title
+ "title": coalesce(seo.title, title, ""),
+ "description": coalesce(seo.description, ""),
+ "image": seo.image,
+ "noIndex": seo.noIndex == true
+ }
+}
+```
+
+**Key principle:** `seo.title` will never be `null` — it contains either the SEO override, the page title, or empty string.
+
+## 4. Next.js Metadata (The Right Way)
+
+Use `generateMetadata` — never render `` or `` tags directly in components.
+
+```typescript
+// app/(frontend)/[slug]/page.tsx
+import type { Metadata } from "next";
+import { urlFor } from "@/sanity/lib/image";
+
+type RouteProps = {
+ params: Promise<{ slug: string }>;
+};
+
+// Extract fetch to reuse in both functions
+const getPage = async (params: RouteProps["params"]) =>
+ sanityFetch({
+ query: PAGE_QUERY,
+ params: await params,
+ stega: false, // Critical for SEO!
+ });
+
+export async function generateMetadata({ params }: RouteProps): Promise {
+ const { data: page } = await getPage(params);
+
+ if (!page) return {};
+
+ const metadata: Metadata = {
+ title: page.seo.title,
+ description: page.seo.description,
+ };
+
+ // Open Graph image
+ if (page.seo.image) {
+ metadata.openGraph = {
+ images: {
+ url: urlFor(page.seo.image).width(1200).height(630).url(),
+ width: 1200,
+ height: 630,
+ },
+ };
+ }
+
+ // noIndex robots directive
+ if (page.seo.noIndex) {
+ metadata.robots = "noindex";
+ }
+
+ return metadata;
+}
+
+export default async function Page({ params }: RouteProps) {
+ const { data: page } = await getPage(params);
+ // ... render page
+}
+```
+
+**Critical:** Always set `stega: false` when fetching for metadata. Stega characters in `` destroy SEO.
+
+## 5. Dynamic Sitemap
+
+Use Next.js `sitemap.ts` convention to auto-generate from Sanity content.
+
+### GROQ Query
+```groq
+*[_type in ["page", "post"] && defined(slug.current) && seo.noIndex != true] {
+ "href": select(
+ _type == "page" => "/" + slug.current,
+ _type == "post" => "/posts/" + slug.current,
+ slug.current
+ ),
+ _updatedAt
+}
+```
+
+### Route Implementation
+```typescript
+// app/sitemap.ts
+import { MetadataRoute } from "next";
+import { client } from "@/sanity/lib/client";
+import { SITEMAP_QUERY } from "@/sanity/lib/queries";
+
+export default async function sitemap(): Promise {
+ const baseUrl = process.env.VERCEL_URL
+ ? `https://${process.env.VERCEL_URL}`
+ : "http://localhost:3000";
+
+ try {
+ const paths = await client.fetch(SITEMAP_QUERY);
+ if (!paths) return [];
+
+ return paths.map((path) => ({
+ url: new URL(path.href!, baseUrl).toString(),
+ lastModified: new Date(path._updatedAt),
+ changeFrequency: "weekly",
+ priority: 1,
+ }));
+ } catch (error) {
+ console.error("Sitemap generation failed:", error);
+ return [];
+ }
+}
+```
+
+**Note:** Sitemap limit is 50,000 URLs per file. For larger sites, use sitemap index.
+
+## 6. Redirects (Managed in Sanity)
+
+Create a redirect document type for content team management.
+
+### Schema
+```typescript
+// schemaTypes/redirectType.ts
+import { defineField, defineType, SanityDocumentLike } from "sanity";
+import { LinkIcon } from "@sanity/icons";
+
+function isValidPath(value: string | undefined) {
+ if (!value) return "Required";
+ if (!value.startsWith("/")) return "Must start with /";
+ if (/[^a-zA-Z0-9\-_/:]/.test(value)) return "Invalid characters";
+ return true;
+}
+
+export const redirectType = defineType({
+ name: "redirect",
+ title: "Redirect",
+ type: "document",
+ icon: LinkIcon,
+ validation: (Rule) =>
+ Rule.custom((doc: SanityDocumentLike | undefined) => {
+ if (doc?.source === doc?.destination) {
+ return "Source and destination cannot be the same";
+ }
+ return true;
+ }),
+ fields: [
+ defineField({
+ name: "source",
+ type: "string",
+ validation: (Rule) => Rule.required().custom(isValidPath),
+ }),
+ defineField({
+ name: "destination",
+ type: "string",
+ validation: (Rule) => Rule.required(),
+ }),
+ defineField({
+ name: "permanent",
+ description: "301 (permanent) or 302 (temporary)",
+ type: "boolean",
+ initialValue: true,
+ }),
+ defineField({
+ name: "isEnabled",
+ type: "boolean",
+ initialValue: true,
+ }),
+ ],
+});
+```
+
+### Next.js Config
+```typescript
+// next.config.ts
+import { fetchRedirects } from "@/sanity/lib/fetchRedirects";
+
+const nextConfig: NextConfig = {
+ async redirects() {
+ return await fetchRedirects();
+ },
+};
+```
+
+**Limits:** Vercel allows max 1,024 redirects in `next.config`. For more, use middleware.
+
+## 7. Dynamic Open Graph Images
+
+Generate OG images on-the-fly using Next.js Edge Runtime at `/api/og`.
+
+```typescript
+// app/api/og/route.tsx
+import { ImageResponse } from "next/og";
+export const runtime = "edge";
+
+export async function GET(request: Request) {
+ const id = new URL(request.url).searchParams.get("id");
+ if (!id) return new Response("Missing id", { status: 400 });
+
+ const data = await client.fetch(`*[_id == $id][0]{ title }`, { id });
+
+ return new ImageResponse(
+
+
{data?.title || "Untitled"}
+ ,
+ { width: 1200, height: 630 }
+ );
+}
+```
+
+Use as fallback in metadata: `url: page.seo.image ? urlFor(page.seo.image).url() : \`/api/og?id=\${page._id}\``
+
+## 8. JSON-LD Structured Data
+
+Use `schema-dts` for type-safe structured data.
+
+```bash
+npm install schema-dts
+```
+
+### FAQ Example
+```typescript
+import { FAQPage, WithContext } from "schema-dts";
+
+const generateFaqData = (faqs: FAQ[]): WithContext => ({
+ "@context": "https://schema.org",
+ "@type": "FAQPage",
+ mainEntity: faqs.map((faq) => ({
+ "@type": "Question",
+ name: faq.title,
+ acceptedAnswer: {
+ "@type": "Answer",
+ text: faq.text, // Use pt::text() in GROQ to get plain text
+ },
+ })),
+});
+
+// In component
+
+```
+
+### GROQ for Plain Text
+```groq
+faqs[]->{
+ _id,
+ title,
+ body,
+ "text": pt::text(body) // Convert Portable Text to plain string
+}
+```
+
+## 9. Testing Tools
+
+- **Open Graph:** [opengraph.ing](https://opengraph.ing/)
+- **Facebook:** [Sharing Debugger](https://developers.facebook.com/tools/debug/)
+- **Twitter:** [Card Validator](https://cards-dev.twitter.com/validator)
+- **LinkedIn:** [Post Inspector](https://www.linkedin.com/post-inspector/)
+- **Sitemap:** [XML Sitemaps Validator](https://www.xml-sitemaps.com/validate-xml-sitemap.html)
diff --git a/.agents/skills/sanity-best-practices/references/studio-structure.md b/.agents/skills/sanity-best-practices/references/studio-structure.md
new file mode 100644
index 0000000..51bcd6d
--- /dev/null
+++ b/.agents/skills/sanity-best-practices/references/studio-structure.md
@@ -0,0 +1,136 @@
+---
+title: "Sanity Studio Structure Rules"
+description: Rules for customizing the Sanity Studio Structure (S.structure).
+---
+
+# Sanity Studio Structure Rules
+
+## 1. Setup
+Custom structure is defined in `sanity.config.ts` using the `structureTool`.
+
+```typescript
+import { structureTool } from 'sanity/structure'
+import { structure } from './src/structure'
+
+export default defineConfig({
+ // ...
+ plugins: [
+ structureTool({ structure })
+ ]
+})
+```
+
+## 2. Structure Definition
+**Location:** `src/structure/index.ts`
+
+Use a function that receives `S` (StructureBuilder).
+
+```typescript
+import type { StructureResolver } from 'sanity/structure'
+
+export const structure: StructureResolver = (S) =>
+ S.list()
+ .title('Content')
+ .items([
+ // ... items
+ ])
+```
+
+## 3. Organization Principles
+1. **Singletons First:** Place critical site-wide settings (Global Settings, Homepage) at the top.
+2. **Dividers:** Use `S.divider()` to visually separate logical groups.
+3. **Filtered Lists:** Always exclude Singleton documents from generic `documentTypeList` items to avoid duplication.
+
+## 4. Singleton Pattern (Critical)
+
+**Singletons are enforced via Structure, NOT schema options.** There is no `singleton: true` schema option.
+
+### How Singletons Work
+1. Use `S.document().documentId('fixed-id')` to lock the document to a specific ID.
+2. Filter the type from generic lists to prevent duplicate entries.
+
+### Singleton Helper Function
+```typescript
+// Helper to create singleton list items
+function createSingleton(S: StructureBuilder, typeName: string, title: string, icon?: ComponentType) {
+ return S.listItem()
+ .title(title)
+ .icon(icon)
+ .child(
+ S.document()
+ .schemaType(typeName)
+ .documentId(typeName) // Fixed ID = singleton
+ .title(title)
+ )
+}
+
+// Usage
+createSingleton(S, 'settings', 'Site Settings', CogIcon)
+```
+
+### Querying Singletons
+```groq
+// By fixed ID (most efficient)
+*[_id == "settings"][0]
+
+// By type (works but slower)
+*[_type == "settings"][0]
+```
+
+**For localized singletons** (e.g., homepage per language), see `localization.md` Section 6.
+
+## 5. Implementation Pattern
+
+```typescript
+// Define singleton types to exclude from generic lists
+const SINGLETONS = ['settings', 'homePage']
+
+export const structure: StructureResolver = (S) =>
+ S.list()
+ .title('Website Content')
+ .items([
+ // 1. Singletons
+ S.listItem()
+ .title('Site Settings')
+ .icon(CogIcon)
+ .child(S.document().schemaType('settings').documentId('settings')),
+
+ S.divider(),
+
+ // 2. Content Verticals
+ S.listItem()
+ .title('Blog')
+ .child(
+ S.list()
+ .title('Blog Content')
+ .items([
+ S.documentTypeListItem('post').title('Posts'),
+ S.documentTypeListItem('author').title('Authors'),
+ ])
+ ),
+
+ S.divider(),
+
+ // 3. Remaining Documents (Filtered)
+ ...S.documentTypeListItems().filter(
+ (listItem) => !SINGLETONS.includes(listItem.getId() as string)
+ )
+ ])
+```
+
+## 6. Views (Split Pane)
+Add "Web Preview" or other views to documents.
+
+```typescript
+export const defaultDocumentNode: DefaultDocumentNodeResolver = (S, { schemaType }) => {
+ switch (schemaType) {
+ case `post`:
+ return S.document().views([
+ S.view.form(), // Default form
+ S.view.component(PreviewComponent).title('Preview') // Custom view
+ ])
+ default:
+ return S.document().views([S.view.form()])
+ }
+}
+```
diff --git a/.agents/skills/sanity-best-practices/references/svelte.md b/.agents/skills/sanity-best-practices/references/svelte.md
new file mode 100644
index 0000000..aeca87d
--- /dev/null
+++ b/.agents/skills/sanity-best-practices/references/svelte.md
@@ -0,0 +1,155 @@
+---
+title: "SvelteKit & Sanity Integration Rules"
+description: Integration guide for SvelteKit with Sanity, including @sanity/svelte-loader, Visual Editing, and Preview Mode.
+---
+
+# SvelteKit & Sanity Integration Rules
+
+## 1. Setup & Configuration
+
+### Installation
+```bash
+npm install @sanity/svelte-loader @sanity/client @sanity/visual-editing
+```
+
+### Client Configuration (`src/lib/sanity.ts`)
+Define the client with `stega` enabled for the studio URL.
+
+```typescript
+import { createClient } from '@sanity/client'
+import { PUBLIC_SANITY_PROJECT_ID, PUBLIC_SANITY_DATASET, PUBLIC_SANITY_API_VERSION, PUBLIC_SANITY_STUDIO_URL } from '$env/static/public'
+
+export const client = createClient({
+ projectId: PUBLIC_SANITY_PROJECT_ID,
+ dataset: PUBLIC_SANITY_DATASET,
+ apiVersion: PUBLIC_SANITY_API_VERSION,
+ useCdn: true,
+ stega: {
+ studioUrl: PUBLIC_SANITY_STUDIO_URL,
+ },
+})
+```
+
+### Server Client (`src/lib/server/sanity.ts`)
+Use the read token for fetching preview content.
+
+```typescript
+import { SANITY_API_READ_TOKEN } from '$env/static/private'
+import { client } from '$lib/sanity'
+
+export const serverClient = client.withConfig({
+ token: SANITY_API_READ_TOKEN,
+ stega: true, // Optional: enable stega on server too if needed
+})
+```
+
+## 2. Hooks & Request Handler (Critical)
+
+You **must** configure `createRequestHandler` in `src/hooks.server.ts` to handle preview sessions and inject `loadQuery` into locals.
+
+```typescript
+// src/hooks.server.ts
+import { createRequestHandler, setServerClient } from '@sanity/svelte-loader'
+import { serverClient } from '$lib/server/sanity'
+
+setServerClient(serverClient)
+
+export const handle = createRequestHandler()
+```
+
+**Update `app.d.ts` types:**
+```typescript
+import type { LoaderLocals } from '@sanity/svelte-loader'
+
+declare global {
+ namespace App {
+ interface Locals extends LoaderLocals {}
+ }
+}
+```
+
+## 3. Preview State Propagation
+
+Pass the preview state from the server to the client via the root layout.
+
+**Server Layout (`src/routes/+layout.server.ts`):**
+```typescript
+import type { LayoutServerLoad } from './$types'
+
+export const load: LayoutServerLoad = ({ locals: { preview } }) => {
+ return { preview }
+}
+```
+
+**Client Layout (`src/routes/+layout.ts`):**
+```typescript
+import { setPreviewing } from '@sanity/svelte-loader'
+import type { LayoutLoad } from './$types'
+
+export const load: LayoutLoad = ({ data: { preview } }) => {
+ setPreviewing(preview)
+}
+```
+
+## 4. Data Fetching (Loaders)
+
+Use `locals.loadQuery` in your page server loaders.
+
+```typescript
+// src/routes/[slug]/+page.server.ts
+import type { PageServerLoad } from './$types'
+
+export const load: PageServerLoad = async ({ locals: { loadQuery }, params }) => {
+ const initial = await loadQuery(QUERY, params)
+ return { initial }
+}
+```
+
+## 5. Real-time Preview & Visual Editing
+
+### Component Usage (`useQuery`)
+Use `useQuery` in your Svelte component to handle real-time updates.
+
+```svelte
+
+
+
+{#if !loading && post}
+
+
+ {post.title}
+
+{/if}
+```
+
+### Enable Visual Editing (`+layout.svelte`)
+Enable Visual Editing and Live Mode in your root layout.
+
+```svelte
+
+
+
+```
diff --git a/.agents/skills/sanity-best-practices/references/typegen.md b/.agents/skills/sanity-best-practices/references/typegen.md
new file mode 100644
index 0000000..8635b75
--- /dev/null
+++ b/.agents/skills/sanity-best-practices/references/typegen.md
@@ -0,0 +1,215 @@
+---
+title: Sanity TypeGen Rules
+description: Workflow for generating TypeScript types from Sanity Schema and GROQ queries.
+---
+
+# Sanity TypeGen Rules
+
+## 1. The Workflow
+Sanity TypeGen generates TypeScript types from your schema and GROQ queries. Types can be generated automatically or manually.
+
+### Automatic (Recommended)
+Enable in `sanity.cli.ts` — types regenerate during `sanity dev` and `sanity build`:
+
+```typescript
+// sanity.cli.ts
+import { defineCliConfig } from 'sanity/cli'
+
+export default defineCliConfig({
+ typegen: {
+ enabled: true,
+ },
+})
+```
+
+### Manual
+Run the extract + generate cycle whenever schema or queries change:
+
+1. **Extract:** Converts your Schema (TS/JS) into a static JSON representation.
+2. **Generate:** Scans your codebase for GROQ queries and generates TypeScript types.
+
+```bash
+npx sanity schema extract && npx sanity typegen generate
+```
+
+### Watch Mode (for separate frontends)
+If your frontend is in a separate repo from the Studio, use watch mode:
+
+```bash
+npx sanity typegen generate --watch
+```
+
+## 2. The "Update Types" Pattern
+For manual workflows, implement a single script:
+
+**package.json:**
+```json
+"scripts": {
+ "typegen": "sanity schema extract && sanity typegen generate"
+}
+```
+
+### Git Strategy for Generated Files
+
+**Option A: Commit generated types (Recommended for most teams)**
+- Types available immediately after `git pull`
+- CI/CD doesn't need to run typegen
+- Can cause merge conflicts
+
+**Option B: Generate in CI (Recommended for larger teams)**
+Add to `.gitignore`:
+```gitignore
+# Sanity TypeGen (generated)
+sanity.types.ts
+schema.json
+```
+
+Then ensure CI runs typegen before build:
+```yaml
+# Example GitHub Actions
+- run: npm run typegen
+- run: npm run build
+```
+
+## 3. Configuration (`sanity.cli.ts`)
+
+> **Note:** `sanity-typegen.json` is deprecated. Move your configuration to `sanity.cli.ts`.
+
+```typescript
+// sanity.cli.ts
+import { defineCliConfig } from 'sanity/cli'
+
+export default defineCliConfig({
+ typegen: {
+ enabled: true, // Auto-generate during sanity dev/build
+ path: "./src/**/*.{ts,tsx,js,jsx,astro,svelte,vue}", // Glob to find queries
+ schema: "schema.json", // Schema file from extract
+ generates: "./sanity.types.ts", // Output file
+ overloadClientMethods: true, // Auto-type client.fetch() calls
+ },
+})
+```
+
+### Project Structure Examples
+
+**Single Repo / Embedded Studio (most common):**
+Use defaults — no extra config needed.
+
+**Monorepo** (Studio in `apps/studio`, Frontend in `apps/web`):
+```typescript
+export default defineCliConfig({
+ typegen: {
+ path: "../web/src/**/*.{ts,tsx,js,jsx}",
+ schema: "schema.json",
+ generates: "../web/sanity.types.ts",
+ },
+})
+```
+
+**Separate Repos:**
+Use `--watch` mode in your frontend: `sanity typegen generate --watch`
+
+## 4. Usage in Code
+
+### Automatic Type Inference (Recommended)
+With `overloadClientMethods: true` (default), `client.fetch()` automatically returns typed results when you use `defineQuery`:
+
+```typescript
+import { defineQuery } from "groq";
+import { createClient } from "@sanity/client";
+
+const client = createClient({...});
+
+const POSTS_QUERY = defineQuery(`*[_type == "post"]{ title, slug }`);
+
+// Return type is automatically inferred — no manual type import needed!
+const posts = await client.fetch(POSTS_QUERY);
+```
+
+### Manual Type Import (Alternative)
+You can also import generated types directly:
+
+```typescript
+import { defineQuery } from "groq";
+// Next.js re-exports defineQuery for convenience:
+// import { defineQuery } from "next-sanity";
+
+const AUTHOR_QUERY = defineQuery(`*[_type == "author" && slug.current == $slug][0]{ name, bio }`);
+
+import type { AUTHOR_QUERYResult } from "@/sanity.types";
+
+export default function Author({ data }: { data: AUTHOR_QUERYResult }) {
+ return {data.name}
+}
+```
+
+### Required Fields
+Use `--enforce-required-fields` during extraction to translate `validation: rule => rule.required()` into non-optional types:
+
+```bash
+npx sanity schema extract --enforce-required-fields
+npx sanity typegen generate
+```
+
+> **Warning:** If you use draft previews, fields may still be `undefined` even with required validation, since drafts can be in an invalid state.
+
+### Type Utilities
+TypeGen provides utilities for working with complex types:
+
+```typescript
+import type { Get, FilterByType } from 'sanity'
+import type { Page, PageBuilder } from './sanity.types'
+
+// Extract deeply nested type (up to 20 levels)
+type HeroSection = Get
+
+// Filter specific types from unions using _type discriminator
+type HeroBlock = FilterByType
+```
+
+### Unique Query Names
+All queries must have unique variable names. Duplicate names across files will cause TypeGen to silently overwrite types. Use descriptive, scoped names:
+
+```typescript
+// Unique names
+const POSTS_INDEX_QUERY = defineQuery(`*[_type == "post"]{ title }`)
+const POST_DETAIL_QUERY = defineQuery(`*[_type == "post" && slug.current == $slug][0]`)
+
+// Duplicate names will conflict
+const QUERY = defineQuery(`*[_type == "post"]`) // file-a.ts
+const QUERY = defineQuery(`*[_type == "author"]`) // file-b.ts — overwrites!
+```
+
+### Supported Query Formats
+Queries must be assigned to a variable using `groq` or `defineQuery`:
+
+```typescript
+// Works — groq template tag
+const query = groq`*[_type == "post"]`
+
+// Works — defineQuery
+const query = defineQuery(`*[_type == "post"]`)
+
+// Won't work — inline query
+await client.fetch(groq`*[_type == "post"]`)
+```
+
+### Supported File Types
+TypeGen parses queries from: `.ts`, `.tsx`, `.js`, `.jsx`, `.astro`, `.svelte`, `.vue`
+
+### tsconfig Requirements
+Ensure `sanity.types.ts` is included in your `tsconfig.json`'s `include` array. If your config restricts includes (e.g., `["src/**/*"]`) and the types file is at the project root, TypeScript won't pick up the generated types:
+
+```json
+{
+ "include": ["src/**/*", "sanity.types.ts"]
+}
+```
+
+### Skipping Individual Queries
+Add `@sanity-typegen-ignore` in a comment before a query to skip type generation:
+
+```typescript
+// @sanity-typegen-ignore
+const debugQuery = groq`*[_type == "debug"]`
+```
diff --git a/.agents/skills/sanity-best-practices/references/visual-editing.md b/.agents/skills/sanity-best-practices/references/visual-editing.md
new file mode 100644
index 0000000..1f7a967
--- /dev/null
+++ b/.agents/skills/sanity-best-practices/references/visual-editing.md
@@ -0,0 +1,263 @@
+---
+title: "Sanity Visual Editing Rules"
+description: Comprehensive guide for Sanity Visual Editing, including Presentation Tool, Stega (Content Source Maps), and Overlays.
+---
+
+# Sanity Visual Editing Rules
+
+## 1. Concepts
+
+### Presentation Tool
+The Studio plugin (`sanity/presentation`) that renders your front-end application inside an iframe in the Studio. It enables the "Edit" overlay and bidirectional navigation.
+
+### Content Source Maps (Stega)
+Invisible characters embedded in strings that tell the Presentation Tool which field in which document the content comes from.
+- **Mechanism:** Sanity encodes document ID, field path, and dataset info into string values.
+- **Result:** Click-to-edit functionality in the preview.
+
+### Loaders
+Framework-agnostic or specific libraries that handle:
+1. Fetching data (production vs. preview).
+2. Subscribing to real-time updates (Live Content API).
+3. Encoding Stega strings (if not handled by the Content Lake automatically).
+
+## 2. The Golden Rule of Stega (Clean Data)
+
+When Visual Editing is enabled, string fields will contain invisible characters. You **MUST** clean them before using the value for logic.
+
+| Scenario | Clean? | Why |
+|----------|--------|-----|
+| Comparing strings (`if (x === 'y')`) | ✅ Yes | Stega breaks equality |
+| Using as object keys | ✅ Yes | Keys won't match |
+| Using as HTML IDs | ✅ Yes | Invalid characters |
+| Passing to third-party libraries | ✅ Yes | May validate input |
+| Rendering text (`{title}
`) | ❌ No | Breaks click-to-edit |
+| Passing to `` | ❌ No | Handles internally |
+| Passing to image helpers | ❌ No | Handles internally |
+
+```typescript
+import { stegaClean } from "@sanity/client/stega";
+
+export function Layout({ align }: { align: string }) {
+ // Good: Clean before comparison
+ const cleanAlign = stegaClean(align);
+ return
+}
+```
+
+## 3. Token Handling (Security)
+
+Store your read token in a dedicated file that throws if missing:
+
+```typescript
+// src/sanity/lib/token.ts
+export const token = process.env.SANITY_API_READ_TOKEN
+
+if (!token) {
+ throw new Error('Missing SANITY_API_READ_TOKEN')
+}
+```
+
+**Never** expose tokens in client bundles. Pass to `defineLive` for server/browser use only when Draft Mode is enabled.
+
+## 4. Setup: Presentation Tool
+
+**File:** `sanity.config.ts`
+
+```typescript
+import { defineConfig } from 'sanity'
+import { presentationTool } from 'sanity/presentation'
+import { resolve } from '@/sanity/presentation/resolve'
+
+export default defineConfig({
+ // ...
+ plugins: [
+ presentationTool({
+ resolve, // Document locations (see below)
+ previewUrl: {
+ previewMode: {
+ enable: '/api/draft-mode/enable',
+ },
+ },
+ }),
+ ],
+})
+```
+
+### Document Locations
+
+Show where documents appear in the front-end — enables quick navigation between Structure and Presentation tools.
+
+```typescript
+// src/sanity/presentation/resolve.ts
+import { defineLocations, PresentationPluginOptions } from 'sanity/presentation'
+
+export const resolve: PresentationPluginOptions['resolve'] = {
+ locations: {
+ post: defineLocations({
+ select: { title: 'title', slug: 'slug.current' },
+ resolve: (doc) => ({
+ locations: [
+ { title: doc?.title || 'Untitled', href: `/posts/${doc?.slug}` },
+ { title: 'Posts index', href: `/posts` },
+ ],
+ }),
+ }),
+ // Add more document types as needed
+ },
+}
+```
+
+## 5. Visual Editing Overlays
+
+Render `` in Draft Mode for click-to-edit overlays.
+
+**Next.js (App Router):**
+```typescript
+// layout.tsx
+import { VisualEditing } from 'next-sanity/visual-editing'
+import { draftMode } from 'next/headers'
+import { DisableDraftMode } from '@/components/disable-draft-mode'
+
+export default async function RootLayout({ children }) {
+ return (
+
+
+ {children}
+ {(await draftMode()).isEnabled && (
+ <>
+
+
+ >
+ )}
+
+
+ )
+}
+```
+
+### Disable Draft Mode Button
+
+Useful for content authors to exit preview and see published content:
+
+```typescript
+// src/components/disable-draft-mode.tsx
+'use client'
+import { useDraftModeEnvironment } from 'next-sanity/hooks'
+
+export function DisableDraftMode() {
+ const environment = useDraftModeEnvironment()
+ // Only show outside of Presentation Tool
+ if (environment !== 'live' && environment !== 'unknown') return null
+
+ return (
+
+ Disable Draft Mode
+
+ )
+}
+```
+
+**Remix/Svelte:** See framework-specific rules for `useLiveMode` and `enableVisualEditing` patterns.
+
+## 6. SEO & Metadata (Critical)
+
+**NEVER** allow Stega strings in `` tags (Title, Description, Canonical URLs). It destroys SEO rankings and looks broken in search results.
+
+- **Next.js:** Set `stega: false` in `generateMetadata`.
+- **General:** Explicitly clean fields used in `` or ``.
+
+```typescript
+// Next.js Example — disable stega at fetch level
+export async function generateMetadata({ params }) {
+ const { data } = await sanityFetch({
+ query: SEO_QUERY,
+ stega: false // Critical
+ })
+ return { title: data.title }
+}
+```
+
+**Alternative:** If you can't disable stega at the fetch level, clean explicitly:
+
+```typescript
+import { stegaClean } from "@sanity/client/stega";
+
+export async function generateMetadata({ params }) {
+ const { data } = await sanityFetch({ query: PAGE_QUERY })
+ return {
+ title: stegaClean(data.title),
+ description: stegaClean(data.description),
+ openGraph: { url: stegaClean(data.canonicalUrl) }
+ }
+}
+```
+
+## 7. Drag-and-Drop Reordering (Advanced)
+
+For arrays (e.g., "Related Posts"), enable drag-and-drop in the preview using `data-sanity` attributes and `useOptimistic`:
+
+```typescript
+import { createDataAttribute } from 'next-sanity'
+import { useOptimistic } from 'next-sanity/hooks'
+
+// Add data-sanity to array container
+
+ {items.map((item) => (
+ -
+ {item.title}
+
+ ))}
+
+```
+
+**Key requirements:**
+- Query must include `_key` for array items
+- Use `useOptimistic` hook for instant UI updates during mutations
+
+## 8. Optimistic Updates for Faster Editing
+
+By default, editing a field in the Presentation Tool triggers a full page re-render. For pages with many components, this can feel sluggish. **Presentation queries** solve this by fetching only the specific block being edited.
+
+### The Concept
+
+Instead of:
+1. User edits a field -> Full page query re-runs -> All components re-render
+
+You get:
+1. User edits a field -> Block-specific query runs -> Only that component re-renders
+
+### How It Works
+
+1. **Create a targeted query** that fetches just the block data using `_key`:
+
+```groq
+*[_id == $documentId][0]{
+ "heroBlock": pageBuilder[_key == $blockKey && _type == "hero"][0]{
+ title, subtitle, image
+ }
+}
+```
+
+2. **Use a presentation query hook** in your component (e.g., `usePresentationQuery` in Next.js)
+
+3. **Fall back to initial props** when not in presentation mode
+
+This pattern works for both Page Builder blocks (`pageBuilder[]`) and Portable Text blocks (`body[]`).
+
+**See framework-specific rules for implementation:**
+- Next.js: `nextjs.md` (Section 9)
+- Page Builder: `page-builder.md` (Section 5)
+- Portable Text: `portable-text.md` (Section 7)
+
+## 9. Framework Specifics
+
+| Framework | Loader Package | Key Components |
+| :--- | :--- | :--- |
+| **Next.js** | `next-sanity` | ``, `defineLive`, `usePresentationQuery` |
+| **Remix** | `@sanity/react-loader` | `createQueryStore`, `useLiveMode`, `enableVisualEditing` |
+| **Svelte** | `@sanity/svelte-loader` | `createRequestHandler`, `useLiveMode`, `enableVisualEditing` |
+| **Nuxt** | `@nuxtjs/sanity` | Automatic via module config (`visualEditing: {}`) |
+| **Astro** | `@sanity/astro` | `sanity({ useCdn: false, stega: true })` |
diff --git a/.agents/skills/seo-aeo-best-practices/SKILL.md b/.agents/skills/seo-aeo-best-practices/SKILL.md
new file mode 100644
index 0000000..ade2bb5
--- /dev/null
+++ b/.agents/skills/seo-aeo-best-practices/SKILL.md
@@ -0,0 +1,37 @@
+---
+name: seo-aeo-best-practices
+description: SEO and AEO best practices for metadata, Open Graph, sitemaps, robots.txt, hreflang, JSON-LD structured data, EEAT, and content optimized for search engines and AI answer surfaces. Use this skill when implementing page SEO, technical SEO, schema markup, international SEO, AI-overview readiness, or improving content for Google, ChatGPT, Perplexity, and similar assistants.
+---
+
+# SEO & AEO Best Practices
+
+Principles for optimizing content for both traditional search engines (SEO) and AI-powered answer engines (AEO). Includes Google's EEAT guidelines and structured data implementation.
+
+## When to Apply
+
+Reference these guidelines when:
+- Implementing metadata and Open Graph tags
+- Creating sitemaps and robots.txt
+- Adding JSON-LD structured data
+- Optimizing content for featured snippets
+- Preparing content for AI assistants (ChatGPT, Perplexity, etc.)
+- Evaluating content quality using EEAT principles
+
+## Core Concepts
+
+### SEO (Search Engine Optimization)
+Optimizing content to rank well in traditional search results (Google, Bing).
+
+### AEO (Answer Engine Optimization)
+Optimizing content to be selected as authoritative answers by AI systems.
+
+### EEAT (Experience, Expertise, Authoritativeness, Trustworthiness)
+Google's framework for evaluating content quality.
+
+## References
+
+Start with the one reference that matches the task, such as technical SEO, structured data, EEAT, or AI-answer readiness. See `references/` for detailed guidance:
+- `references/eeat-principles.md` — EEAT implementation and author schema
+- `references/structured-data.md` — JSON-LD patterns (Article, FAQ, Breadcrumb, Product)
+- `references/technical-seo.md` — Technical SEO checklist (metadata, sitemaps, hreflang, robots.txt)
+- `references/aeo-considerations.md` — AI/AEO considerations (AI Overviews, crawler management)
diff --git a/.agents/skills/seo-aeo-best-practices/references/aeo-considerations.md b/.agents/skills/seo-aeo-best-practices/references/aeo-considerations.md
new file mode 100644
index 0000000..227d79e
--- /dev/null
+++ b/.agents/skills/seo-aeo-best-practices/references/aeo-considerations.md
@@ -0,0 +1,159 @@
+# AI/AEO Considerations
+
+Answer Engine Optimization (AEO) prepares content to be selected as authoritative answers by AI systems like ChatGPT, Perplexity, Google AI Overviews, and Bing Copilot.
+
+## How AI Selects Answers
+
+AI systems evaluate content based on:
+
+1. **Clarity:** Is the answer direct and easy to extract?
+2. **Authority:** Is the source trustworthy?
+3. **Comprehensiveness:** Does it fully address the question?
+4. **Recency:** Is the information up to date?
+5. **Structure:** Can the AI parse and understand it?
+
+## Content Structure for AI
+
+### Direct Answers First
+Lead with the answer, then explain.
+
+**Bad:**
+> The history of JavaScript dates back to 1995 when Brendan Eich... [500 words later] ...JavaScript runs in the browser.
+
+**Good:**
+> JavaScript is a programming language that runs in web browsers. It was created in 1995 by Brendan Eich...
+
+### Clear Headings
+Use descriptive H2/H3 headings that match user questions.
+
+**Bad:** "Overview" → "Details" → "More Information"
+**Good:** "What is X?" → "How does X work?" → "When should you use X?"
+
+### Lists and Tables
+AI extracts structured information more easily than prose.
+
+```markdown
+## Benefits of Structured Content
+
+- **Reusability:** Use content across channels
+- **Flexibility:** Change presentation without changing content
+- **Scalability:** Manage large content volumes
+```
+
+### FAQ Format
+Question-answer pairs are ideal for AI extraction.
+
+```typescript
+// Schema for AI-friendly FAQs
+defineType({
+ name: 'faq',
+ type: 'document',
+ fields: [
+ defineField({ name: 'question', type: 'string' }),
+ defineField({ name: 'answer', type: 'text' }),
+ defineField({ name: 'category', type: 'reference', to: [{ type: 'faqCategory' }] }),
+ ]
+})
+```
+
+## Technical Implementation
+
+### Structured Data (Critical)
+JSON-LD helps AI understand content type and relationships.
+
+```typescript
+// FAQ structured data
+const faqSchema = {
+ "@context": "https://schema.org",
+ "@type": "FAQPage",
+ mainEntity: faqs.map(faq => ({
+ "@type": "Question",
+ name: faq.question,
+ acceptedAnswer: {
+ "@type": "Answer",
+ text: faq.answer
+ }
+ }))
+}
+```
+
+### Canonical Content
+Ensure AI finds your authoritative version, not copies.
+
+- Set canonical URLs
+- Avoid duplicate content across pages
+- Use `rel="canonical"` for syndicated content
+
+### Freshness Signals
+AI systems prefer current information.
+
+- Display publish and update dates prominently
+- Update content regularly with substantive changes (superficial updates like changing dates without meaningful edits can be counterproductive)
+- Use `dateModified` in structured data
+
+## Content Quality Signals
+
+### Author Credentials
+AI systems increasingly check author authority.
+
+- Display author name and credentials
+- Link to author profiles
+- Include author structured data
+
+### Citations and Sources
+Linking to authoritative sources increases trust.
+
+- Cite primary sources
+- Link to studies, documentation, official sources
+- Avoid circular citations (sites citing each other)
+
+### Comprehensive Coverage
+AI prefers content that fully answers questions.
+
+- Cover related questions users might have
+- Include definitions for technical terms
+- Address common misconceptions
+
+## Google AI Overviews
+
+Google's AI Overviews (formerly SGE) now appear in many search results. To optimize:
+
+- **Be the cited source:** AI Overviews cite specific pages. Concise, authoritative answers increase citation likelihood.
+- **Structure for extraction:** Use clear headings, direct answers, and lists that AI can easily parse.
+- **Cover follow-up questions:** AI Overviews often address related queries. Anticipate and answer them on the same page or link to dedicated pages.
+- **Monitor in Search Console:** Google Search Console provides data on AI Overview impressions and clicks.
+
+## AI Crawler Management
+
+Make conscious decisions about which AI systems can crawl your content:
+
+- **robots.txt directives:** Use `User-agent: GPTBot`, `ClaudeBot`, `PerplexityBot`, `Google-Extended` to control access.
+- **Allowing crawlers** increases chances of being cited as a source in AI responses.
+- **Blocking crawlers** prevents content from being used in AI training (but may reduce AI citations).
+- Review your policy regularly — this is one of the most actively evolving areas of SEO.
+
+## Measuring AEO Success
+
+### Monitor AI Mentions
+Track when AI assistants cite your content:
+- Use Google Search Console's AI Overview data for impression and click tracking
+- Monitor referral traffic from AI platforms (Perplexity, ChatGPT, Bing Copilot)
+- Search for your brand + "according to" in AI assistants
+- Consider third-party AEO tracking tools for comprehensive monitoring
+
+### Track Zero-Click Queries
+If AI answers questions directly, traditional rankings matter less.
+
+### Featured Snippet Capture
+Featured snippets often become AI answers. Track which you own.
+
+## AEO vs SEO Balance
+
+AEO and SEO largely align—quality content serves both. Key differences:
+
+| Aspect | SEO Focus | AEO Focus |
+|--------|-----------|-----------|
+| Goal | Rank on page 1 | Be THE answer |
+| Format | Varies | Direct, structured |
+| Length | Often longer | Concise + comprehensive |
+| Links | Link building | Source citations |
diff --git a/.agents/skills/seo-aeo-best-practices/references/eeat-principles.md b/.agents/skills/seo-aeo-best-practices/references/eeat-principles.md
new file mode 100644
index 0000000..899a120
--- /dev/null
+++ b/.agents/skills/seo-aeo-best-practices/references/eeat-principles.md
@@ -0,0 +1,127 @@
+# EEAT Principles
+
+Google's EEAT framework (Experience, Expertise, Authoritativeness, Trustworthiness) guides how content quality is evaluated. This applies to both SEO rankings and AI answer selection.
+
+## The Four Pillars
+
+### Experience
+First-hand or life experience with the topic.
+
+**Signals:**
+- Personal anecdotes and case studies
+- "I tested this" content
+- Real-world results and screenshots
+- User-generated reviews
+
+**Implementation:**
+- Include author bios with relevant experience
+- Add "About the Author" sections
+- Feature customer testimonials
+- Show real examples, not just theory
+
+### Expertise
+Knowledge and skill in the subject area.
+
+**Signals:**
+- Credentials and qualifications
+- Depth of content coverage
+- Technical accuracy
+- Citations to authoritative sources
+
+**Implementation:**
+- Display author credentials
+- Link to primary sources
+- Cover topics comprehensively
+- Keep content technically accurate and updated
+
+### Authoritativeness
+Recognition as a go-to source in the field.
+
+**Signals:**
+- Backlinks from respected sites
+- Mentions in industry publications
+- Social proof and follower counts
+- Brand recognition
+
+**Implementation:**
+- Build thought leadership content
+- Contribute to industry publications
+- Maintain consistent publishing
+- Develop recognizable brand voice
+
+### Trustworthiness
+Accuracy, transparency, and legitimacy.
+
+**Signals:**
+- Clear authorship and contact info
+- Accurate, fact-checked content
+- Secure website (HTTPS)
+- Privacy policy and terms
+
+**Implementation:**
+- Display clear author attribution
+- Include publication and update dates
+- Provide contact information
+- Use HTTPS and maintain security
+
+## Sanity Implementation
+
+```typescript
+// Author schema with EEAT signals
+defineType({
+ name: 'author',
+ type: 'document',
+ fields: [
+ defineField({ name: 'name', type: 'string' }),
+ defineField({ name: 'role', type: 'string' }),
+ defineField({ name: 'bio', type: 'text' }),
+ defineField({ name: 'credentials', type: 'array', of: [{ type: 'string' }] }),
+ defineField({ name: 'image', type: 'image' }),
+ // sameAs: used for schema.org Person structured data output
+ defineField({ name: 'sameAs', type: 'array', of: [{ type: 'url' }],
+ description: 'Canonical profile URLs (LinkedIn, Twitter, etc.) for schema.org Person'
+ }),
+ // socialLinks: used for display purposes (platform icons, labels)
+ defineField({
+ name: 'socialLinks',
+ type: 'array',
+ of: [{ type: 'object', fields: [
+ defineField({ name: 'platform', type: 'string' }),
+ defineField({ name: 'url', type: 'url' })
+ ]}],
+ description: 'Social links for display in the UI. Use sameAs for structured data output.'
+ }),
+ ]
+})
+
+// Content with EEAT metadata
+defineType({
+ name: 'post',
+ fields: [
+ defineField({ name: 'author', type: 'reference', to: [{ type: 'author' }] }),
+ defineField({ name: 'publishedAt', type: 'datetime' }),
+ defineField({ name: 'updatedAt', type: 'datetime' }),
+ defineField({
+ name: 'reviewedBy',
+ type: 'reference',
+ to: [{ type: 'author' }],
+ description: 'Expert reviewer for fact-checking'
+ }),
+ defineField({
+ name: 'sources',
+ type: 'array',
+ of: [{ type: 'url' }],
+ description: 'Citations and references'
+ }),
+ ]
+})
+```
+
+## YMYL Considerations
+
+"Your Money or Your Life" topics (health, finance, legal, safety) require extra EEAT rigor:
+
+- Medical content reviewed by healthcare professionals
+- Financial advice from certified experts
+- Legal content reviewed by attorneys
+- Clear disclaimers where appropriate
diff --git a/.agents/skills/seo-aeo-best-practices/references/structured-data.md b/.agents/skills/seo-aeo-best-practices/references/structured-data.md
new file mode 100644
index 0000000..84292dc
--- /dev/null
+++ b/.agents/skills/seo-aeo-best-practices/references/structured-data.md
@@ -0,0 +1,183 @@
+# Structured Data (JSON-LD)
+
+Structured data helps search engines and AI understand your content. JSON-LD is the recommended format.
+
+## Why Structured Data Matters
+
+- **Rich snippets:** Enhanced search result appearance
+- **Knowledge panels:** Featured information boxes
+- **AI training:** Better content understanding
+- **Voice search:** Answer selection for voice queries
+
+## Common Schema Types
+
+### Article / Blog Post
+
+```typescript
+import { Article, WithContext } from 'schema-dts'
+
+const articleSchema: WithContext = {
+ "@context": "https://schema.org",
+ "@type": "Article",
+ headline: post.title,
+ description: post.excerpt,
+ image: post.image?.url,
+ datePublished: post.publishedAt,
+ dateModified: post.updatedAt,
+ author: {
+ "@type": "Person",
+ name: post.author.name,
+ url: post.author.url
+ },
+ publisher: {
+ "@type": "Organization",
+ name: "Your Company",
+ logo: {
+ "@type": "ImageObject",
+ url: "https://example.com/logo.png"
+ }
+ }
+}
+```
+
+### FAQ Page
+
+```typescript
+import { FAQPage, WithContext } from 'schema-dts'
+
+const faqSchema: WithContext = {
+ "@context": "https://schema.org",
+ "@type": "FAQPage",
+ mainEntity: faqs.map(faq => ({
+ "@type": "Question",
+ name: faq.question,
+ acceptedAnswer: {
+ "@type": "Answer",
+ text: faq.answer // Plain text, use pt::text() in GROQ
+ }
+ }))
+}
+```
+
+### Organization
+
+```typescript
+import { Organization, WithContext } from 'schema-dts'
+
+const orgSchema: WithContext = {
+ "@context": "https://schema.org",
+ "@type": "Organization",
+ name: "Your Company",
+ url: "https://example.com",
+ logo: "https://example.com/logo.png",
+ sameAs: [
+ "https://twitter.com/company",
+ "https://linkedin.com/company/company"
+ ],
+ contactPoint: {
+ "@type": "ContactPoint",
+ telephone: "+1-555-555-5555",
+ contactType: "customer service"
+ }
+}
+```
+
+### Product
+
+```typescript
+import { Product, WithContext } from 'schema-dts'
+
+const productSchema: WithContext = {
+ "@context": "https://schema.org",
+ "@type": "Product",
+ name: product.name,
+ description: product.description,
+ image: product.images,
+ offers: {
+ "@type": "Offer",
+ price: product.price,
+ priceCurrency: "USD",
+ availability: "https://schema.org/InStock"
+ },
+ aggregateRating: product.rating ? {
+ "@type": "AggregateRating",
+ ratingValue: product.rating.average,
+ reviewCount: product.rating.count
+ } : undefined
+}
+```
+
+### Breadcrumb
+
+```typescript
+import { BreadcrumbList, WithContext } from 'schema-dts'
+
+const breadcrumbSchema: WithContext = {
+ "@context": "https://schema.org",
+ "@type": "BreadcrumbList",
+ itemListElement: breadcrumbs.map((crumb, index) => ({
+ "@type": "ListItem",
+ position: index + 1, // schema.org positions are 1-based
+ name: crumb.title,
+ item: `https://example.com${crumb.path}`
+ }))
+}
+```
+
+## Combining Multiple Schemas (@graph)
+
+Real-world pages often need multiple schema types. Use `@graph` to combine them. The `@context` is defined once at the top level — omit it from individual schema generators when used inside `@graph`:
+
+```typescript
+const pageSchema = {
+ "@context": "https://schema.org",
+ "@graph": [
+ generateArticleSchema(post), // No @context needed here
+ generateBreadcrumbSchema(breadcrumbs),
+ generateOrganizationSchema(),
+ ]
+}
+```
+
+## Implementation in Next.js
+
+```typescript
+// Component to render JSON-LD
+// Ensure data comes from trusted sources (your CMS).
+// If data could contain user-generated content, strip HTML tags
+// and escape special characters before passing to JSON.stringify.
+function JsonLd({ data }: { data: WithContext }) {
+ return (
+
+ )
+}
+
+// Usage in page
+export default function PostPage({ post }) {
+ return (
+ <>
+
+ ...
+ >
+ )
+}
+```
+
+## GROQ for Plain Text
+
+Structured data often needs plain text, not rich text:
+
+```groq
+*[_type == "faq"]{
+ question,
+ "answer": pt::text(answerRichText) // Convert Portable Text to plain string
+}
+```
+
+## Testing Tools
+
+- [Google Rich Results Test](https://search.google.com/test/rich-results)
+- [Schema.org Validator](https://validator.schema.org/)
diff --git a/.agents/skills/seo-aeo-best-practices/references/technical-seo.md b/.agents/skills/seo-aeo-best-practices/references/technical-seo.md
new file mode 100644
index 0000000..a0ba6e6
--- /dev/null
+++ b/.agents/skills/seo-aeo-best-practices/references/technical-seo.md
@@ -0,0 +1,188 @@
+# Technical SEO Checklist
+
+Essential technical SEO elements for modern web applications.
+
+## Table of Contents
+
+- Metadata
+- Sitemaps
+- Canonical URLs
+- Redirects
+- Performance
+- Robots.txt
+- International SEO
+
+## Metadata
+
+### Title Tags
+- Unique per page
+- 50-60 characters
+- Primary keyword near the beginning
+- Brand name at the end (optional)
+
+### Meta Descriptions
+- Unique per page
+- 150-160 characters
+- Include call-to-action
+- Contain relevant keywords
+
+### Open Graph
+```html
+
+
+
+
+
+```
+
+### Sanity + Next.js Implementation
+
+```typescript
+export async function generateMetadata({ params }): Promise {
+ const { data } = await sanityFetch({
+ query: PAGE_QUERY,
+ stega: false, // Critical: no stega in metadata
+ })
+
+ return {
+ title: data.seo?.title || data.title,
+ description: data.seo?.description,
+ openGraph: {
+ images: data.seo?.image ? [{
+ url: urlFor(data.seo.image).width(1200).height(630).url(),
+ width: 1200,
+ height: 630,
+ }] : [],
+ },
+ robots: data.seo?.noIndex ? 'noindex' : undefined,
+ }
+}
+```
+
+## Sitemaps
+
+Dynamic sitemap from CMS content:
+
+```typescript
+// app/sitemap.ts
+import { MetadataRoute } from 'next'
+
+export default async function sitemap(): Promise {
+ const pages = await client.fetch(`
+ *[_type in ["page", "post"] && defined(slug.current) && seo.noIndex != true]{
+ "url": select(
+ _type == "page" => "/" + slug.current,
+ _type == "post" => "/blog/" + slug.current
+ ),
+ _updatedAt
+ }
+ `)
+
+ return pages.map(page => ({
+ url: `https://example.com${page.url}`,
+ lastModified: new Date(page._updatedAt),
+ // Note: changeFrequency and priority are largely ignored by Google
+ // but may be used by other search engines
+ }))
+}
+```
+
+## Canonical URLs
+
+Prevent duplicate content issues:
+
+```typescript
+export async function generateMetadata({ params }): Promise {
+ return {
+ alternates: {
+ canonical: `https://example.com/${params.slug}`,
+ },
+ }
+}
+```
+
+## Redirects
+
+CMS-managed redirects:
+
+```typescript
+// next.config.ts
+async redirects() {
+ const redirects = await client.fetch(`
+ *[_type == "redirect" && isEnabled == true]{
+ source,
+ destination,
+ permanent
+ }
+ `)
+ return redirects
+}
+```
+
+## Performance
+
+[Core Web Vitals](https://web.dev/articles/defining-core-web-vitals-thresholds) impact rankings:
+
+- **LCP (Largest Contentful Paint):** < 2.5s
+- **INP (Interaction to Next Paint):** < 200ms
+- **CLS (Cumulative Layout Shift):** < 0.1
+
+### Image Optimization (Next.js example)
+- Use `next/image` with Sanity URL builder
+- Serve WebP/AVIF formats
+- Implement LQIP blur placeholders
+- Set explicit dimensions
+
+### Font Loading (Next.js example)
+```typescript
+// Prevent layout shift
+import { Inter } from 'next/font/google'
+const inter = Inter({ subsets: ['latin'], display: 'swap' })
+```
+
+## Robots.txt
+
+```
+# public/robots.txt
+User-agent: *
+Allow: /
+Disallow: /api/
+Disallow: /studio/
+
+# AI crawlers — allow or block based on your content strategy
+# Uncomment to block specific AI crawlers:
+# User-agent: GPTBot
+# Disallow: /
+# User-agent: ClaudeBot
+# Disallow: /
+# User-agent: PerplexityBot
+# Disallow: /
+# User-agent: Google-Extended
+# Disallow: /
+
+Sitemap: https://example.com/sitemap.xml
+```
+
+**AI crawler considerations:** Decide whether AI training crawlers should access your content. Blocking `Google-Extended` prevents AI training use while still allowing Google Search indexing. Review your policy regularly as this landscape evolves.
+
+## International SEO (hreflang)
+
+For multi-language sites, implement hreflang tags to indicate language/region variants:
+
+```typescript
+export async function generateMetadata({ params }: { params: Promise<{ lang: string; slug: string }> }): Promise {
+ const { lang, slug } = await params
+ return {
+ alternates: {
+ canonical: `https://example.com/${lang}/${slug}`,
+ languages: {
+ 'en': `https://example.com/en/${slug}`,
+ 'de': `https://example.com/de/${slug}`,
+ 'x-default': `https://example.com/en/${slug}`,
+ },
+ },
+ }
+}
+```
+
+Include all language variants in sitemaps with `hreflang` annotations for proper indexing.
diff --git a/skills-lock.json b/skills-lock.json
new file mode 100644
index 0000000..e29658b
--- /dev/null
+++ b/skills-lock.json
@@ -0,0 +1,41 @@
+{
+ "version": 1,
+ "skills": {
+ "content-experimentation-best-practices": {
+ "source": "sanity-io/agent-toolkit",
+ "sourceType": "github",
+ "skillPath": "skills/content-experimentation-best-practices/SKILL.md",
+ "computedHash": "97c7d16a8a93362feb82ae569b5cf84e02845a2fb17d9a8cd528b96d76f304d3"
+ },
+ "content-modeling-best-practices": {
+ "source": "sanity-io/agent-toolkit",
+ "sourceType": "github",
+ "skillPath": "skills/content-modeling-best-practices/SKILL.md",
+ "computedHash": "9f0c62274cf8ccf41dd32fc2f2629f4c9a8e5a920826f30c2eb226d7e796b031"
+ },
+ "portable-text-conversion": {
+ "source": "sanity-io/agent-toolkit",
+ "sourceType": "github",
+ "skillPath": "skills/portable-text-conversion/SKILL.md",
+ "computedHash": "7c6ca2fc2ecb2d802deefeb6385ec121197d3fc6eff0da8f32eaeee74ba432ac"
+ },
+ "portable-text-serialization": {
+ "source": "sanity-io/agent-toolkit",
+ "sourceType": "github",
+ "skillPath": "skills/portable-text-serialization/SKILL.md",
+ "computedHash": "a4cec92378298c8ef502bf9698112f054d86fc03e814257fa07d6aaac12b9376"
+ },
+ "sanity-best-practices": {
+ "source": "sanity-io/agent-toolkit",
+ "sourceType": "github",
+ "skillPath": "skills/sanity-best-practices/SKILL.md",
+ "computedHash": "cc5b4bd65ddd554c2ba2faf14f11858e59b48f6e600c578551cc58cc81b3e084"
+ },
+ "seo-aeo-best-practices": {
+ "source": "sanity-io/agent-toolkit",
+ "sourceType": "github",
+ "skillPath": "skills/seo-aeo-best-practices/SKILL.md",
+ "computedHash": "edb99db85683ff99678fcb1bebc3a5653c3ea927eea9ee2534f19be86c7b8b81"
+ }
+ }
+}