@invisiblecities/sanity-edge-fetcher
Version:
Lightweight, Edge Runtime-compatible Sanity client for Next.js and Vercel Edge Functions
399 lines (293 loc) • 11.7 kB
Markdown
# 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.