Technical Overview
The Timeline Block system provides a complete content management solution for creating interactive timeline sections on the homepage. Built with Next.js 16, Sanity CMS, and React, it follows the established block architecture pattern while introducing server-side image upload capabilities.
Architecture Components
1. Sanity Schema
File: src/sanity/schemaTypes/homepage/timelineBlock.ts
export default defineType({
name: 'timelineBlock',
title: 'Timeline Section Block',
type: 'document',
icon: Clock,
fields: [
defineField({
name: 'name',
title: 'Block Name',
type: 'string',
validation: Rule => Rule.required()
}),
defineField({
name: 'entries',
title: 'Timeline Entries',
type: 'array',
of: [{
type: 'object',
fields: [
{ name: 'title', type: 'string' },
{ name: 'content', type: 'array', of: [{ type: 'block' }] }, // Portable Text
{
name: 'images',
type: 'array',
of: [{
type: 'image',
fields: [
{ name: 'alt', type: 'string' },
{ name: 'caption', type: 'string' }
]
}]
},
{ name: 'order', type: 'number' }
]
}]
})
]
})
2. TypeScript Types
File: src/types/homepage-config.ts
export interface TimelineImage {
asset: { _ref: string; _type: string; url?: string }
alt: string
caption?: string
}
export interface TimelineEntry {
title: string // Timeline marker
content: any[] // Portable Text blocks
images?: TimelineImage[]
order: number
}
export interface TimelineBlock extends BaseBlock {
_type: 'timelineBlock'
heading?: string
description?: string
entries: TimelineEntry[]
lineColor?: { hex: string }
}
export type HomepageBlock = BentoBlock | FeaturesBlock | DraggableCardBlock | TimelineBlock
3. Service Layer
File: src/lib/sanity/homepage.ts
GROQ Query Pattern
_type == "timelineBlock" => {
_id,
_type,
name,
heading,
description,
backgroundColor,
textColor,
lineColor,
order,
visible,
fullWidth,
entries[] {
title,
content,
images[] {
asset-> { _id, _type, url },
alt,
caption
},
order
}
}
CRUD Methods
class HomepageService {
static async getTimelineBlockById(blockId: string): Promise<TimelineBlock>
static async createTimelineBlock(blockData: any): Promise<TimelineBlock>
static async updateTimelineBlock(blockId: string, updates: any): Promise<TimelineBlock>
static async deleteTimelineBlock(blockId: string): Promise<void>
}
API Endpoints
Timeline Block Endpoints
POST /api/homepage/timeline-blocks
Purpose: Create new timeline block and add to homepage
Request Body:
{
"name": "Company History",
"heading": "Our Journey",
"description": "Timeline of milestones",
"entries": [
{
"title": "2024",
"content": [/* Portable Text blocks */],
"images": [
{
"asset": { "_ref": "image-abc123", "_type": "reference" },
"alt": "Office photo",
"caption": "New office opening"
}
],
"order": 0
}
],
"lineColor": { "hex": "#3b82f6" },
"backgroundColor": { "hex": "#ffffff" },
"textColor": { "hex": "#000000" },
"addToHomepage": true
}
Response:
{
"success": true,
"block": {
"_id": "timeline-abc123",
"_type": "timelineBlock",
"name": "Company History",
"entries": [...]
}
}
GET /api/homepage/timeline-blocks/[id]
Purpose: Fetch single timeline block by ID
Response: Same structure as POST response
PATCH /api/homepage/timeline-blocks/[id]
Purpose: Update existing timeline block
Request Body: Partial updates (same structure as POST)
DELETE /api/homepage/timeline-blocks/[id]
Purpose: Remove timeline block and clean up homepage config
Image Upload Endpoints
POST /api/upload/image
Purpose: Server-side image upload to Sanity CDN
Implementation:
export async function POST(request: NextRequest) {
const formData = await request.formData()
const file = formData.get('file') as File
// Validation
if (!file.type.startsWith('image/')) {
return NextResponse.json({ error: 'File must be an image' }, { status: 400 })
}
if (file.size > 5 * 1024 * 1024) {
return NextResponse.json({ error: 'File size must be less than 5MB' }, { status: 400 })
}
// Convert to Buffer
const buffer = Buffer.from(await file.arrayBuffer())
// Upload to Sanity (CRITICAL PATTERN)
const writeClient = client.withConfig({ useCdn: false })
const asset = await writeClient.assets.upload('image', buffer, {
filename: file.name,
contentType: file.type
})
return NextResponse.json({
success: true,
asset: { _ref: asset._id, _type: 'reference', url: asset.url }
})
}
Request: FormData with file field
Response:
{
"success": true,
"asset": {
"_ref": "image-abc123-1920x1080-jpg",
"_type": "reference",
"url": "https://cdn.sanity.io/images/..."
}
}
DELETE /api/upload/image/[assetId]
Purpose: Remove image asset from Sanity
Implementation:
export async function DELETE(request: NextRequest, { params }) {
const { assetId } = await params
const writeClient = client.withConfig({ useCdn: false })
await writeClient.delete(assetId)
return NextResponse.json({ success: true })
}
Sanity Client Patterns
Critical Pattern: Token Inheritance
ALWAYS use this pattern for write operations:
// ✅ CORRECT
const writeClient = client.withConfig({ useCdn: false })
NEVER override token:
// ❌ WRONG - causes "project user not found" error
const writeClient = client.withConfig({
useCdn: false,
token: process.env.SANITY_API_WRITE_TOKEN
})
Why It Works
The base Sanity client (src/lib/sanity.ts) is configured with token on line 14:
export const client = createClient({
projectId: '62anct3y',
dataset: 'production',
apiVersion: '2024-01-01',
token: process.env.SANITY_API_TOKEN, // Token configured here
useCdn: true,
})
When using client.withConfig({ useCdn: false }):
- Creates new client instance
- Inherits all properties from base client (including token)
- Only overrides
useCdnproperty - Maintains authentication context
This pattern is used throughout the codebase:
SanityService.uploadImage()(line 46)SanityService.createProduct()(line 190)- All CRUD operations
Component Architecture
TimelineEntryEditor Component
File: src/components/admin/TimelineEntryEditor.tsx
Key Features:
- Title and content inputs
- Multi-file image upload
- Image preview grid
- Alt text and caption editing
- Entry reordering controls
State Management:
const [uploadingImage, setUploadingImage] = useState(false)
const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files
setUploadingImage(true)
try {
const uploadPromises = Array.from(files).map(async (file) => {
// Validation
if (!file.type.startsWith('image/')) return null
if (file.size > 5 * 1024 * 1024) return null
// Upload via utility
const uploadedAsset = await uploadImageToSanity(file)
return {
asset: uploadedAsset.asset,
alt: file.name.replace(/\.[^/.]+$/, ''),
caption: ''
}
})
const uploadedImages = await Promise.all(uploadPromises)
handleChange('images', [...currentImages, ...uploadedImages])
toast.success(`${uploadedImages.length} image(s) uploaded`)
} catch (error) {
toast.error('Failed to upload images')
} finally {
setUploadingImage(false)
}
}
TimelineBlockEditor Component
File: src/components/admin/TimelineBlockEditor.tsx
Responsibilities:
- Block metadata management
- Entry array management (add/remove/reorder)
- Color customization
- Save/Cancel actions
- Form validation
Entry Management:
const handleAddEntry = () => {
const newEntry: Partial<TimelineEntry> = {
title: '',
content: [],
images: [],
order: entries.length
}
setEntries([...entries, newEntry])
}
const handleUpdateEntry = (index: number, updatedEntry: Partial<TimelineEntry>) => {
const newEntries = [...entries]
newEntries[index] = { ...newEntries[index], ...updatedEntry }
setEntries(newEntries)
}
const handleRemoveEntry = (index: number) => {
setEntries(entries.filter((_, i) => i !== index))
}
UnifiedBlockList Integration
File: src/components/admin/UnifiedBlockList.tsx
Added Support For:
// Icon mapping
const getBlockIcon = (block: HomepageBlock) => {
if (block._type === 'timelineBlock') return <Clock className="h-4 w-4" />
// ... other block types
}
// Description helper
const getBlockDescription = (block: HomepageBlock) => {
if (block._type === 'timelineBlock') {
const entriesCount = block.entries?.length || 0
return `Timeline Section - ${entriesCount} entries`
}
// ... other block types
}
// CRUD endpoints
const handleSave = async (block: HomepageBlock) => {
let endpoint = ''
if (block._type === 'timelineBlock') {
endpoint = `/api/homepage/timeline-blocks/${block._id}`
}
// ... other block types
}
Portable Text Handling
Converting Plain Text to Portable Text
const handleContentChange = (text: string) => {
const portableText = [{
_type: 'block',
_key: `block-${Date.now()}`,
style: 'normal',
children: [{
_type: 'span',
_key: `span-${Date.now()}`,
text: text,
marks: []
}]
}]
handleChange('content', portableText)
}
Extracting Text from Portable Text
const getTextFromPortableText = (): string => {
if (!entry.content || entry.content.length === 0) return ''
return entry.content
.map((block: any) => {
if (block._type === 'block' && block.children) {
return block.children
.map((child: any) => child.text || '')
.join('')
}
return ''
})
.join('\n\n')
}
Performance Considerations
Image Optimization
- CDN Delivery: All images served via Sanity CDN
- Lazy Loading: Images load as user scrolls
- Caching: Sanity CDN automatically caches images
- Size Limits: 5MB max prevents excessive file sizes
Query Optimization
- Selective Fields: GROQ queries only fetch needed fields
- Asset Resolution:
asset->resolves asset references in single query - Ordering: Entries sorted client-side by order field
Component Optimization
- Suspense Boundaries: Lazy load heavy components
- Memoization: Use
React.memofor repeated renders - Debouncing: Debounce search/filter operations
Security Patterns
Server-Side Upload
Why Server-Side:
- Browser clients don't have write permissions
- Keeps API tokens secure (server-side only)
- Centralizes validation logic
- Better error handling
File Validation
// Type validation
if (!file.type.startsWith('image/')) {
throw new Error('File must be an image')
}
// Size validation
if (file.size > 5 * 1024 * 1024) {
throw new Error('File size must be less than 5MB')
}
Content Sanitization
- Portable Text is inherently safe (structured format)
- No raw HTML injection possible
- Sanity validates content structure
Testing Strategies
Unit Tests
Test individual functions:
describe('getTextFromPortableText', () => {
it('should extract text from blocks', () => {
const portableText = [
{
_type: 'block',
children: [{ _type: 'span', text: 'Hello World' }]
}
]
expect(getTextFromPortableText(portableText)).toBe('Hello World')
})
})
Integration Tests
Test API endpoints:
# Create timeline block
curl -X POST http://localhost:3000/api/homepage/timeline-blocks \
-H "Content-Type: application/json" \
-d '{"name":"Test Timeline","entries":[]}'
# Upload image
curl -X POST http://localhost:3000/api/upload/image \
-F "file=@test-image.jpg"
E2E Tests
Test complete workflows:
- Navigate to
/admin/ui-config/homepage - Click "Create Timeline Block"
- Add entry with image upload
- Save block
- Verify block appears in list
- Edit block
- Delete block
Troubleshooting
Common Issues
Permission Errors
Error: Insufficient permissions; permission "create" required
Cause: Client-side upload attempted
Solution: Always use /api/upload/image endpoint
Authentication Errors
Error: project user not found for user ID...
Cause: Token override in API route
Solution: Use pattern client.withConfig({ useCdn: false })
Portable Text Issues
Error: Content not saving or displaying incorrectly
Cause: Invalid Portable Text structure
Solution: Use conversion functions in TimelineEntryEditor
Migration Guide
From Hardcoded Timeline
If migrating from hardcoded timeline component:
- Extract Data: Copy timeline data from component
- Create Block: Use admin UI to create new timeline block
- Upload Images: Use image upload feature for all images
- Verify: Check output matches original
- Remove: Delete hardcoded component
Database Migration
No database migration needed - Sanity handles schema evolution automatically.
Related Documentation
- TIMELINE-BLOCK-ADMIN-UI-IMPLEMENTATION.md - Complete technical memory
- FUMADOCS-LAYOUT-FIX.md - Admin UI patterns
- FEATURES-BLOCK-IMPLEMENTATION.md - Similar block system
Changelog
Version 2.2.0 (2025-11-04)
- Initial implementation of Timeline Block system
- Server-side image upload endpoints
- Complete admin UI components
- About page Company Timeline component
- Fixed Sanity client authentication pattern