npm package: @invisiblecities/sanity-edge-fetcher
Description: Lightweight, Edge Runtime-compatible Sanity client for Next.js and Vercel Edge Functions
Author: Invisible Cities Agency
Latest version: 1.0.6

# Sanity Edge Fetcher

A lightweight, Edge Runtime-compatible Sanity client for Next.js and Vercel Edge Functions.

## Why Use This Instead of @sanity/client or next-sanity?

The official Sanity clients (`@sanity/client` and `next-sanity`'s `sanityFetch`) have several limitations on Vercel's Edge Runtime:

- **Bundle Size**: Official client adds ~50KB to your bundle, this adds only **~3KB** (core) or **~7KB** (with full caching and stega)
- **Hidden Node.js Dependencies**: `sanityFetch` appears edge-compatible but actually smuggles Node.js-specific code that can cause runtime failures on Vercel Edge Functions
- **Forced Dynamic Rendering**: Using the official client often forces pages into dynamic rendering mode, breaking static generation
- **No True Edge Support**: Despite claims, the official client isn't truly edge-compatible and relies on polyfills that increase bundle size and reduce performance

### Bundle Size Comparison

| Package | Size | Gzipped | Runtime |
|---------|------|---------|---------|
| @sanity/client | ~150KB | ~50KB | ~50KB |
| next-sanity (sanityFetch) | ~160KB | ~52KB | ~52KB |
| **edge-fetcher (core)** | **8.5KB** | **2.8KB** | **~3KB** |
| **edge-fetcher (with cache)** | **16KB** | **5.4KB** | **~6KB** |
| **edge-fetcher (full)** | **27KB** | **9.1KB** | **~7.3KB** |

**Result**: 87% smaller than official clients, with better edge compatibility.

## Features

- ✅ **True Edge Runtime compatible** - No Node.js dependencies, no polyfills, no hidden incompatibilities
- ✅ **Tiny bundle size** - ~3KB core, ~7KB with full features (85% smaller than official)
- ✅ **Visual editing support** - Stega encoding for Sanity Studio presentation mode (v1.0.2+)
- ✅ **Vercel-optimized** - Works perfectly with Vercel Edge Functions, Middleware, and Edge Config
- ✅ **Static generation compatible** - No forced dynamic rendering, preserves ISR and SSG
- ✅ **TypeScript first** - Full type safety with generics
- ✅ **Built-in rate limiting** - Prevents 429 errors
- ✅ **Multi-layer caching** - Memory, Redis (Vercel KV/Upstash), and Next.js cache
- ✅ **Optional enhancements** - Retry, real-time updates via SSE/WebSockets

## Installation

```bash
# Core functionality only
npm install # (already in your project)

# For retry support (optional)
npm install p-retry
```

## Quick Start

### Using Pre-configured Fetchers (Recommended)

```typescript
import { fetchers } from '@/lib/sanity/edge-fetcher';

// Use pre-configured fetchers for common cases
const posts = await fetchers.cached<Post[]>('*[_type == "post"][0..10]');
const settings = await fetchers.static<Settings>('*[_type == "siteSettings"][0]');
const preview = await fetchers.authenticated<Post>('*[_type == "post"][0]');

// Page data with caching
const page = await fetchers.page<PageData>(
  '*[_type == "page" && slug.current == $slug][0]',
  { slug: 'about' }
);
```

### Direct Usage

```typescript
import { edgeSanityFetch } from '@/lib/sanity/edge-fetcher';

// Basic query
const posts = await edgeSanityFetch<Post[]>({
  dataset: 'production',
  query: '*[_type == "post"][0..10]',
  useCdn: true
});

// With parameters
const post = await edgeSanityFetch<Post>({
  dataset: 'production',
  query: '*[_type == "post" && slug.current == $slug][0]',
  params: { slug: 'my-post' }
});
```

## Enhanced Features

### Multi-Layer Caching

The edge-fetcher includes a sophisticated multi-layer caching system:

```typescript
import { cachedSanityFetch } from '@/lib/sanity/edge-fetcher';

// Fetch with automatic caching
const posts = await cachedSanityFetch<Post[]>({
  dataset: 'production',
  query: '*[_type == "post"][0..10]',
  cache: {
    ttl: 300,        // 5 minutes
    useRedis: true,  // Use Upstash if configured
    useNextCache: true // Use Next.js cache
  }
});

// Create a cached fetcher with defaults
const fetcher = createCachedFetcher('production', {
  ttl: 60,
  prefix: 'blog:'
});

// Check cache status
const status = getCacheStatus();
console.log('Cache layers:', status);
// { memory: { available: true, size: 5 }, 
//   redis: { available: true, configured: true },
//   nextCache: { available: true } }

// Warm cache on startup
await warmSanityCache([
  { dataset: 'production', query: '*[_type == "post"][0..10]', ttl: 3600 },
  { dataset: 'production', query: '*[_type == "author"]', ttl: 7200 }
]);

// Clear cache when content updates
await clearSanityCache({ dataset: 'production' });
```

#### Cache Layers (in order):
1. **In-memory LRU** (~1ms) - Ultra-fast, limited size
2. **Upstash Redis** (~10-30ms) - Distributed, persistent
3. **Next.js Cache** - ISR and static generation

#### Required Environment Variables for Redis:
```bash
# Option 1: Vercel KV (Upstash)
KV_REST_API_URL=https://your-instance.upstash.io
KV_REST_API_TOKEN=your-token
KV_REST_API_READ_ONLY_TOKEN=your-read-token # Optional

# Option 2: Direct Upstash
UPSTASH_REDIS_REST_URL=https://your-instance.upstash.io
UPSTASH_REDIS_REST_TOKEN=your-token
```

### Automatic Retry

If `p-retry` is installed, use the enhanced fetcher:

```typescript
import { edgeSanityFetchWithRetry } from '@/lib/sanity/edge-fetcher';

const posts = await edgeSanityFetchWithRetry<Post[]>(
  {
    dataset: 'production',
    query: '*[_type == "post"]'
  },
  {
    retries: 3,
    minTimeout: 100,
    maxTimeout: 2000
  }
);
```

### Cached Fetcher

Leverage Next.js caching:

```typescript
import { createCachedSanityFetcher } from '@/lib/sanity/edge-fetcher';

const fetcher = createCachedSanityFetcher('production', 60); // 60s cache
const posts = await fetcher<Post[]>('*[_type == "post"]');
```

### Batch Fetching

Fetch multiple queries in parallel:

```typescript
import { batchSanityFetch } from '@/lib/sanity/edge-fetcher';

const data = await batchSanityFetch({
  posts: { query: '*[_type == "post"][0..10]' },
  authors: { query: '*[_type == "author"]' },
  categories: { query: '*[_type == "category"]' }
}, 'production');

// data.posts, data.authors, data.categories
```

## Draft Mode Support

The fetcher automatically handles Sanity's perspective API for draft/preview mode:

```typescript
// When useAuth is true, the fetcher sets perspective: 'previewDrafts'
const authenticatedFetcher = createEdgeSanityFetcher('production', true);

// In your page/component
import { draftMode } from 'next/headers';

export async function MyPage() {
  const { isEnabled } = await draftMode();
  
  // Choose fetcher based on draft mode
  const fetcher = isEnabled ? fetchers.authenticated : fetchers.basic;
  const content = await fetcher<PageContent>(query);
  
  // With previewDrafts perspective, queries for 'myDoc' 
  // will return 'drafts.myDoc' if it exists
}
```

### How It Works

1. **Without Authentication**: Queries use default perspective (published only)
2. **With Authentication + Token**: Queries use `perspective: 'previewDrafts'`
3. **Draft Resolution**: When querying `_id == "myDoc"` with `previewDrafts`:
   - Returns draft version (`drafts.myDoc`) if it exists
   - Falls back to published version if no draft exists
   - This is handled automatically by Sanity's API

### Required Environment Variable

```bash
SANITY_VIEWER_TOKEN=your-token-with-viewer-role
```

## Real-time Updates

### Server-Sent Events (Vercel)

1. Copy `examples/vercel-sse.ts` to `app/api/sanity-updates/route.ts`
2. Use the client helper:

```typescript
import { createSanityEventSource } from '@/lib/sanity/edge-fetcher';

const eventSource = createSanityEventSource('*[_type == "post"]', 'production', {
  onMessage: (data) => {
    if (data.type === 'update') {
      console.log('Documents updated:', data.documents);
      // Update your UI
    }
  },
  onError: (error) => {
    console.error('SSE error:', error);
  }
});

// Cleanup when done
eventSource.close();
```

### WebSockets (Cloudflare)

1. Deploy `examples/cloudflare-websocket.ts` as a Cloudflare Worker
2. Connect from client:

```typescript
const ws = new WebSocket('wss://your-worker.workers.dev/ws');

ws.addEventListener('message', (event) => {
  const data = JSON.parse(event.data);
  if (data.type === 'update') {
    // Handle updates
  }
});
```

## Configuration

All configuration is centralized in `config.ts`:

```typescript
import { config, fetchers, createCustomFetcher } from '@/lib/sanity/edge-fetcher';

// Access configuration
console.log(config.cache.ttl.default);  // 60 seconds
console.log(config.sanity.projectId);   // From env vars

// Use pre-configured fetchers
const posts = await fetchers.cached('*[_type == "post"]');
const settings = await fetchers.static('*[_type == "siteSettings"][0]');

// Create custom fetcher
const myFetcher = createCustomFetcher({
  ttl: 300,
  prefix: 'custom:',
  useCache: true,
  useRetry: true
});
```

### Available Fetchers

| Fetcher | Use Case | Cache TTL | Features |
|---------|----------|-----------|----------|
| `basic` | Testing, one-off queries | None | No cache, no retry |
| `authenticated` | Draft preview | None | Auth token included |
| `cached` | Most queries | 60s | Multi-layer cache |
| `static` | Global settings | 24h | Long cache, Redis |
| `dynamic` | User data | 30s | Short cache, no Next.js |
| `page` | Full pages | 1h | Page-optimized cache |
| `section` | Page sections | 60s | Component cache |

## Environment Variables

Required:
- `NEXT_PUBLIC_SANITY_PROJECT_ID` - Your Sanity project ID
- `NEXT_PUBLIC_SANITY_API_VERSION` - API version (e.g., '2025-02-10')

Optional:
- `SANITY_VIEWER_TOKEN` - For authenticated requests (draft preview)
- `NEXT_PUBLIC_SANITY_DATASET` - Dataset name (default: 'production')

For Redis caching (optional):
- `KV_REST_API_URL` or `UPSTASH_REDIS_REST_URL`
- `KV_REST_API_TOKEN` or `UPSTASH_REDIS_REST_TOKEN`
- `KV_REST_API_READ_ONLY_TOKEN` (optional, for read-only operations)

## API Reference

### Core Functions

#### `edgeSanityFetch<T>(options)`

Main fetching function.

**Options:**
- `dataset: string` - Sanity dataset name
- `query: string` - GROQ query
- `params?: object` - Query parameters
- `useCdn?: boolean` - Use Sanity CDN (default: false)
- `useAuth?: boolean` - Include auth token (default: false)

**Returns:** `Promise<T>` - Query result

#### `createEdgeSanityFetcher(dataset, useAuth?)`

Creates a reusable fetcher for a specific dataset.

### Enhanced Functions

#### `edgeSanityFetchWithRetry<T>(options, retryOptions?)`

Fetch with automatic retry (requires p-retry).

#### `createCachedSanityFetcher(dataset, revalidate?, tags?)`

Creates a cached fetcher using Next.js cache.

#### `batchSanityFetch<T>(queries, dataset, options?)`

Fetch multiple queries in parallel.

#### `createSanityEventSource(query, dataset?, options?)`

Create SSE connection for real-time updates.

## Trade-offs vs Official Client

| Feature | Edge Fetcher | Official Client |
|---------|-------------|-----------------|
| Bundle Size | ~2KB | ~50KB |
| Edge Runtime | ✅ | ❌ |
| Static Generation | ✅ | ⚠️ (with config) |
| Auto Retry | ⚠️ (with p-retry) | ✅ |
| Response Cache | ⚠️ (via Next.js) | ✅ |
| Real-time | ⚠️ (via SSE/WS) | ✅ |
| Mutations | ❌ | ✅ |
| Assets | ❌ | ✅ |

## Migration from Official Client

```typescript
// Before (official client)
import { client } from '@/lib/sanity/client';
const posts = await client.fetch('*[_type == "post"]');

// After (edge fetcher)
import { edgeSanityFetch } from '@/lib/sanity/edge-fetcher';
const posts = await edgeSanityFetch({
  dataset: 'production',
  query: '*[_type == "post"]'
});
```

## License

MIT © Invisible Cities Agency

## Contributing

Feel free to submit issues and PRs. This is a focused utility, so features should maintain Edge Runtime compatibility.