oneie
Version:
Build apps, websites, and AI agents in English. Zero-interaction setup for AI agents (Claude Code, Cursor, Windsurf). Download to your computer, run in the cloud, deploy to the edge. Open source and free forever.
996 lines (799 loc) • 28 kB
Markdown
title: Provider Agnostic Content
dimension: knowledge
category: provider-agnostic-content.md
tags: ai, architecture, backend, frontend
related_dimensions: things
scope: global
created: 2025-11-03
updated: 2025-11-03
version: 1.0.0
ai_context: |
This document is part of the knowledge dimension in the provider-agnostic-content.md category.
Location: one/knowledge/provider-agnostic-content.md
Purpose: Documents provider-agnostic content: backend independence
Related dimensions: things
For AI agents: Read this to understand provider agnostic content.
# Provider-Agnostic Content: Backend Independence
**Layer 4 of Astro Architecture** - Switch content backends with one environment variable
## The Breakthrough
Your Astro frontend works with ANY backend—Markdown files, REST API, Convex, WordPress, Supabase, or Notion—without changing a single line of component code.
```typescript
// Development: Use Markdown files (no backend needed)
const provider = new MarkdownProvider("src/content/teams");
// Production: Use Convex (real-time database)
const provider = new ConvexProvider(convexClient);
// Production Alternative: Use REST API
const provider = new ApiProvider("https://api.example.com");
// Hybrid: Try API first, fall back to Markdown
const provider = new HybridProvider(
new ApiProvider("https://api.example.com"),
new MarkdownProvider("src/content/teams")
);
// All use the SAME interface - your pages don't change!
const teams = await provider.list();
const team = await provider.get("engineering");
```
## The Pattern
```
┌─────────────────────────────────────────────┐
│ ASTRO PAGES (never change) │
│ await contentProvider.list() │
│ await contentProvider.get(id) │
│ await contentProvider.create(data) │
└──────────────────┬──────────────────────────┘
│
│ ContentProvider interface
│
┌──────────┴──────────┬──────────────┬──────────────┐
│ │ │ │
↓ ↓ ↓ ↓
MarkdownProvider ConvexProvider ApiProvider HybridProvider
│ │ │ │
│ │ │ └─────────┐
↓ ↓ ↓ │
src/content/ Convex DB REST API Primary + Fallback
(files, no DB) (real-time, auth) (any backend) (best of both)
```
**Key Insight:** Same interface, different implementations. Change backends with ONE environment variable.
## Implementation
### 1. Content Provider Interface (The Contract)
**This interface is the key to backend independence.**
```typescript
// src/lib/providers/ContentProvider.ts
export interface ContentProvider<T = any> {
// Read operations
list(params?: ListParams): Promise<ContentItem<T>[]>;
get(id: string): Promise<ContentItem<T> | null>;
// Write operations (optional - not all providers support)
create?(data: T): Promise<string>;
update?(id: string, data: Partial<T>): Promise<void>;
delete?(id: string): Promise<void>;
}
export interface ContentItem<T = any> {
id: string;
data: T; // Type-safe data
body?: string; // Markdown/HTML content
createdAt?: number;
updatedAt?: number;
}
export interface ListParams {
limit?: number;
offset?: number;
filter?: Record<string, any>;
sort?: string;
}
```
**Every provider must implement this interface. That's the entire contract.**
### 2. Markdown Provider (Development)
```typescript
// src/lib/providers/MarkdownProvider.ts
import { getCollection } from "astro:content";
import type { ContentProvider, ContentItem } from "./ContentProvider";
export class MarkdownProvider implements ContentProvider {
constructor(private collectionName: string) {}
async list(): Promise<ContentItem[]> {
const items = await getCollection(this.collectionName);
return items.map(item => ({
id: item.id,
data: item.data,
body: item.body,
}));
}
async get(id: string): Promise<ContentItem | null> {
const items = await getCollection(this.collectionName);
const item = items.find(i => i.id === id);
if (!item) return null;
const { Content } = await item.render();
return {
id: item.id,
data: item.data,
body: item.body,
};
}
}
```
### 3. Convex Provider (Real-Time Database)
```typescript
// src/lib/providers/ConvexProvider.ts
import type { ConvexClient } from "convex/browser";
import type { ContentProvider, ContentItem, ListParams } from "./ContentProvider";
import { api } from "@/convex/_generated/api";
export class ConvexProvider<T = any> implements ContentProvider<T> {
constructor(
private client: ConvexClient,
private collection: string
) {}
async list(params?: ListParams): Promise<ContentItem<T>[]> {
const items = await this.client.query(api.queries.content.list, {
collection: this.collection,
...params,
});
return items.map(item => ({
id: item._id,
data: item.data,
body: item.body,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
}));
}
async get(id: string): Promise<ContentItem<T> | null> {
const item = await this.client.query(api.queries.content.get, {
collection: this.collection,
id,
});
if (!item) return null;
return {
id: item._id,
data: item.data,
body: item.body,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
};
}
async create(data: T): Promise<string> {
return await this.client.mutation(api.mutations.content.create, {
collection: this.collection,
data,
});
}
async update(id: string, data: Partial<T>): Promise<void> {
await this.client.mutation(api.mutations.content.update, {
collection: this.collection,
id,
data,
});
}
async delete(id: string): Promise<void> {
await this.client.mutation(api.mutations.content.delete, {
collection: this.collection,
id,
});
}
}
```
### 4. API Provider (REST Backend)
```typescript
// src/lib/providers/ApiProvider.ts
import type { ContentProvider, ContentItem, ListParams } from "./ContentProvider";
export class ApiProvider<T = any> implements ContentProvider<T> {
constructor(
private baseUrl: string,
private apiKey?: string
) {}
private get headers() {
return {
'Content-Type': 'application/json',
...(this.apiKey && { 'Authorization': `Bearer ${this.apiKey}` }),
};
}
async list(params?: ListParams): Promise<ContentItem<T>[]> {
const queryString = params ? `?${new URLSearchParams(params as any)}` : '';
const response = await fetch(`${this.baseUrl}/api/content${queryString}`, {
headers: this.headers,
});
if (!response.ok) throw new Error("Failed to fetch content");
return response.json();
}
async get(id: string): Promise<ContentItem<T> | null> {
const response = await fetch(`${this.baseUrl}/api/content/${id}`, {
headers: this.headers,
});
if (response.status === 404) return null;
if (!response.ok) throw new Error("Failed to fetch content");
return response.json();
}
async create(data: T): Promise<string> {
const response = await fetch(`${this.baseUrl}/api/content`, {
method: 'POST',
headers: this.headers,
body: JSON.stringify(data),
});
if (!response.ok) throw new Error("Failed to create content");
const result = await response.json();
return result.id;
}
async update(id: string, data: Partial<T>): Promise<void> {
const response = await fetch(`${this.baseUrl}/api/content/${id}`, {
method: 'PATCH',
headers: this.headers,
body: JSON.stringify(data),
});
if (!response.ok) throw new Error("Failed to update content");
}
async delete(id: string): Promise<void> {
const response = await fetch(`${this.baseUrl}/api/content/${id}`, {
method: 'DELETE',
headers: this.headers,
});
if (!response.ok) throw new Error("Failed to delete content");
}
}
```
### 5. Hybrid Provider (Resilience)
```typescript
// src/lib/providers/HybridProvider.ts
import type { ContentProvider, ContentItem, ListParams } from "./ContentProvider";
/**
* Try primary provider first, fall back to secondary on failure.
*
* Use cases:
* - Production API with Markdown fallback (offline support)
* - Convex with REST API fallback (redundancy)
* - New backend migration (gradual cutover)
*/
export class HybridProvider<T = any> implements ContentProvider<T> {
constructor(
private primary: ContentProvider<T>,
private fallback: ContentProvider<T>
) {}
async list(params?: ListParams): Promise<ContentItem<T>[]> {
try {
return await this.primary.list(params);
} catch (error) {
console.warn("Primary provider failed, using fallback", error);
return this.fallback.list(params);
}
}
async get(id: string): Promise<ContentItem<T> | null> {
try {
return await this.primary.get(id);
} catch (error) {
console.warn("Primary provider failed, using fallback", error);
return this.fallback.get(id);
}
}
async create(data: T): Promise<string> {
try {
if (!this.primary.create) throw new Error("Primary doesn't support create");
return await this.primary.create(data);
} catch (error) {
if (!this.fallback.create) throw error;
console.warn("Primary create failed, using fallback", error);
return this.fallback.create(data);
}
}
async update(id: string, data: Partial<T>): Promise<void> {
try {
if (!this.primary.update) throw new Error("Primary doesn't support update");
await this.primary.update(id, data);
} catch (error) {
if (!this.fallback.update) throw error;
console.warn("Primary update failed, using fallback", error);
await this.fallback.update(id, data);
}
}
async delete(id: string): Promise<void> {
try {
if (!this.primary.delete) throw new Error("Primary doesn't support delete");
await this.primary.delete(id);
} catch (error) {
if (!this.fallback.delete) throw error;
console.warn("Primary delete failed, using fallback", error);
await this.fallback.delete(id);
}
}
}
```
### 6. Factory (Single Configuration Point)
**This is where you switch backends—change ONE environment variable.**
```typescript
// src/lib/providers/getContentProvider.ts
import { ConvexHttpClient } from "convex/browser";
import { MarkdownProvider } from "./MarkdownProvider";
import { ConvexProvider } from "./ConvexProvider";
import { ApiProvider } from "./ApiProvider";
import { HybridProvider } from "./HybridProvider";
import type { ContentProvider } from "./ContentProvider";
export function getContentProvider<T = any>(collection: string): ContentProvider<T> {
const mode = import.meta.env.CONTENT_SOURCE || "markdown";
const convexUrl = import.meta.env.PUBLIC_CONVEX_URL;
const apiUrl = import.meta.env.PUBLIC_API_URL;
const apiKey = import.meta.env.API_KEY;
switch (mode) {
case "convex":
const convexClient = new ConvexHttpClient(convexUrl);
return new ConvexProvider<T>(convexClient, collection);
case "api":
return new ApiProvider<T>(apiUrl, apiKey);
case "hybrid":
// Try Convex first, fall back to Markdown
const primaryClient = new ConvexHttpClient(convexUrl);
return new HybridProvider<T>(
new ConvexProvider<T>(primaryClient, collection),
new MarkdownProvider<T>(collection)
);
case "markdown":
default:
return new MarkdownProvider<T>(collection);
}
}
```
**That's it. Change `CONTENT_SOURCE` and your entire backend changes. Pages stay identical.**
## Usage in Astro Pages
### Same code for all providers!
```astro
// src/pages/teams/index.astro
import { getContentProvider } from "@/lib/providers/getContentProvider";
import TeamCard from "@/components/TeamCard.tsx";
// Get provider based on config
const provider = getContentProvider("teams");
// Query content - works with Markdown OR API
const teams = await provider.list();
<Layout title="Teams">
<div class="container py-12">
<h1 class="text-4xl font-bold mb-8">Teams</h1>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{teams.map(team => (
<TeamCard team={team.data} />
))}
</div>
</div>
</Layout>
```
### Detail page
```astro
// src/pages/teams/[id].astro
import { getContentProvider } from "@/lib/providers/getContentProvider";
import TeamDetail from "@/components/TeamDetail.tsx";
const { id } = Astro.params;
const provider = getContentProvider("teams");
const team = await provider.get(id!);
if (!team) {
return Astro.redirect("/teams");
}
<Layout title={team.data.name}>
<TeamDetail team={team.data} />
</Layout>
```
## Configuration (The Only Thing That Changes)
### Environment Variables
```bash
# .env.local - Development (Markdown only, no backend)
CONTENT_SOURCE=markdown
# .env.production - Production with Convex (real-time)
CONTENT_SOURCE=convex
PUBLIC_CONVEX_URL=https://your-deployment.convex.cloud
# .env.production - Production with REST API
CONTENT_SOURCE=api
PUBLIC_API_URL=https://api.example.com
API_KEY=your-secret-api-key
# .env.staging - Staging (tries Convex, falls back to Markdown)
CONTENT_SOURCE=hybrid
PUBLIC_CONVEX_URL=https://staging.convex.cloud
```
**That's the ONLY configuration change. Your pages, components, and logic remain 100% identical.**
### Or config file
```typescript
// src/config/content.ts
export const contentConfig = {
development: {
source: "markdown",
},
production: {
source: "api",
apiUrl: "https://api.example.com",
},
staging: {
source: "hybrid",
apiUrl: "https://staging-api.example.com",
fallback: "markdown",
},
};
```
## Real-World Scenarios
### Scenario 1: Development (No Backend Required)
```bash
# .env.local
CONTENT_SOURCE=markdown
```
**Result:**
- Uses local Markdown/YAML files
- No database, no API server needed
- Hot reload on file changes
- Perfect for prototyping and design
**Use when:** Building UI, testing layouts, working offline
### Scenario 2: Production with Convex (Real-Time)
```bash
# .env.production
CONTENT_SOURCE=convex
PUBLIC_CONVEX_URL=https://your-deployment.convex.cloud
```
**Result:**
- Real-time database with automatic subscriptions
- Built-in authentication and file storage
- Optimistic updates and offline support
- Edge deployment for global speed
**Use when:** Need real-time features, auth, full backend
### Scenario 3: Production with REST API
```bash
# .env.production
CONTENT_SOURCE=api
PUBLIC_API_URL=https://api.example.com
API_KEY=sk_prod_...
```
**Result:**
- Works with any REST backend (WordPress, Supabase, custom)
- Standard HTTP requests
- Flexible authentication
- Backend-agnostic
**Use when:** Existing API, custom backend, multi-platform support
### Scenario 4: Migration Strategy (Zero Downtime)
```bash
# .env.production
CONTENT_SOURCE=hybrid
PUBLIC_CONVEX_URL=https://new-backend.convex.cloud
```
**Result:**
- Try new backend (Convex) first
- Fall back to Markdown if unavailable
- Gradual migration without risk
- Content always available
**Use when:** Migrating backends, testing new infrastructure
### Scenario 5: Offline-First Application
```bash
# .env
CONTENT_SOURCE=hybrid
PUBLIC_API_URL=https://api.example.com
```
**Result:**
- Online: Fetch fresh content from API
- Offline: Use bundled Markdown files
- Seamless switching
- No error states for users
**Use when:** Mobile apps, unreliable networks, PWAs
## Adding More Providers (Extensibility)
**Want to support WordPress, Supabase, Notion, or Airtable? Just implement the interface.**
### Example: Supabase Provider
```typescript
// src/lib/providers/SupabaseProvider.ts
import { createClient, SupabaseClient } from '@supabase/supabase-js';
import type { ContentProvider, ContentItem, ListParams } from "./ContentProvider";
export class SupabaseProvider<T = any> implements ContentProvider<T> {
private supabase: SupabaseClient;
constructor(url: string, anonKey: string, private table: string) {
this.supabase = createClient(url, anonKey);
}
async list(params?: ListParams): Promise<ContentItem<T>[]> {
let query = this.supabase.from(this.table).select('*');
if (params?.limit) query = query.limit(params.limit);
if (params?.offset) query = query.range(params.offset, params.offset + (params.limit || 10));
if (params?.filter) {
Object.entries(params.filter).forEach(([key, value]) => {
query = query.eq(key, value);
});
}
const { data, error } = await query;
if (error) throw new Error(error.message);
return data.map(row => ({
id: row.id,
data: row as T,
createdAt: new Date(row.created_at).getTime(),
updatedAt: new Date(row.updated_at).getTime(),
}));
}
async get(id: string): Promise<ContentItem<T> | null> {
const { data, error } = await this.supabase
.from(this.table)
.select('*')
.eq('id', id)
.single();
if (error) return null;
return {
id: data.id,
data: data as T,
createdAt: new Date(data.created_at).getTime(),
updatedAt: new Date(data.updated_at).getTime(),
};
}
async create(data: T): Promise<string> {
const { data: result, error } = await this.supabase
.from(this.table)
.insert(data)
.select()
.single();
if (error) throw new Error(error.message);
return result.id;
}
async update(id: string, data: Partial<T>): Promise<void> {
const { error } = await this.supabase
.from(this.table)
.update(data)
.eq('id', id);
if (error) throw new Error(error.message);
}
async delete(id: string): Promise<void> {
const { error } = await this.supabase
.from(this.table)
.delete()
.eq('id', id);
if (error) throw new Error(error.message);
}
}
```
### Example: WordPress Provider
```typescript
// src/lib/providers/WordPressProvider.ts
import type { ContentProvider, ContentItem, ListParams } from "./ContentProvider";
export class WordPressProvider<T = any> implements ContentProvider<T> {
constructor(
private siteUrl: string,
private postType: string = 'posts'
) {}
async list(params?: ListParams): Promise<ContentItem<T>[]> {
const queryParams = new URLSearchParams({
per_page: String(params?.limit || 10),
offset: String(params?.offset || 0),
...(params?.filter || {})
});
const response = await fetch(
`${this.siteUrl}/wp-json/wp/v2/${this.postType}?${queryParams}`
);
if (!response.ok) throw new Error('Failed to fetch from WordPress');
const posts = await response.json();
return posts.map((post: any) => ({
id: String(post.id),
data: {
title: post.title.rendered,
content: post.content.rendered,
excerpt: post.excerpt.rendered,
...post
} as T,
body: post.content.rendered,
createdAt: new Date(post.date).getTime(),
updatedAt: new Date(post.modified).getTime(),
}));
}
async get(id: string): Promise<ContentItem<T> | null> {
const response = await fetch(
`${this.siteUrl}/wp-json/wp/v2/${this.postType}/${id}`
);
if (response.status === 404) return null;
if (!response.ok) throw new Error('Failed to fetch from WordPress');
const post = await response.json();
return {
id: String(post.id),
data: {
title: post.title.rendered,
content: post.content.rendered,
excerpt: post.excerpt.rendered,
...post
} as T,
body: post.content.rendered,
createdAt: new Date(post.date).getTime(),
updatedAt: new Date(post.modified).getTime(),
};
}
// WordPress requires authentication for write operations
// Implement create/update/delete if you have auth credentials
}
```
**Now add to factory:**
```typescript
// src/lib/providers/getContentProvider.ts
export function getContentProvider<T = any>(collection: string): ContentProvider<T> {
const mode = import.meta.env.CONTENT_SOURCE;
switch (mode) {
case "supabase":
return new SupabaseProvider<T>(
import.meta.env.PUBLIC_SUPABASE_URL,
import.meta.env.PUBLIC_SUPABASE_ANON_KEY,
collection
);
case "wordpress":
return new WordPressProvider<T>(
import.meta.env.PUBLIC_WP_SITE_URL,
collection
);
// ... other providers
}
}
```
**That's it. Your pages still work identically.**
## Code Generation
Even code generation works seamlessly!
```bash
# Generate with Markdown provider
npx oneie generate:service teams --source=markdown
# Generate with API provider
npx oneie generate:service teams --source=api
# Generate both
npx oneie generate:service teams --source=hybrid
```
All generate the same component code, page code, and Effect.ts services.
**Only the provider differs.**
## Complete Example: Blog
```astro
// src/pages/blog/index.astro
import { getContentProvider } from "@/lib/providers/getContentProvider";
import BlogCard from "@/components/BlogCard.tsx";
const provider = getContentProvider("blog");
const posts = await provider.list();
// Works with:
// - src/content/blog/*.md (Markdown)
// - https://api.example.com/api/blog (API)
// - Both (Hybrid)
// No code changes!
<Layout>
{posts.map(post => <BlogCard post={post.data} />)}
</Layout>
```
## The Magic
```typescript
// Developer's perspective:
const teams = await provider.list();
// Under the hood:
// - Markdown: Read files from disk
// - API: HTTP request to backend
// - Hybrid: Try API, fall back to Markdown
// But all the same code!
```
## Type Safety
Works with full type safety:
```typescript
// Content schema in src/content/config.ts
const teamsCollection = defineCollection({
schema: z.object({
name: z.string(),
members: z.array(z.string()),
}),
});
// Provider returns typed data
const teams = await provider.list();
teams[0].data.name // ← TypeScript: string ✓
teams[0].data.invalid // ← TypeScript: ERROR! ✗
```
## Development Workflow
```bash
# 1. Start dev with Markdown
CONTENT_SOURCE=markdown npm run dev
# 2. Create content
# echo "name: Engineering" > src/content/teams/eng.yaml
# 3. See it live
# Browser updates instantly
# 4. Build API backend
# node backend/server.js
# 5. Switch to API
# CONTENT_SOURCE=api npm run dev
# 6. Same pages work!
# No changes needed
```
## The Philosophy
**One interface. Many implementations.**
- Markdown for development
- API for production
- Hybrid for safety
- Database for anything
- Easy to add more
**Switch with one environment variable.**
## Benefits
| Before | After |
|--------|-------|
| Markdown → build locally | Markdown OR API, same code |
| API → build + deploy | Seamless switching |
| Migration → rewrite | Hybrid support during transition |
| Offline → not supported | Fallback to Markdown |
## Complete File Structure
```
src/
├── pages/ # Same code regardless of source
│ ├── teams/index.astro # Works with Markdown OR API
│ ├── teams/[id].astro # Works with Markdown OR API
│ └── blog/index.astro # Works with Markdown OR API
│
├── content/ # Optional: Markdown fallback
│ ├── config.ts
│ ├── teams/
│ │ ├── engineering.yaml
│ │ └── marketing.yaml
│ └── blog/
│ └── post-1.md
│
├── lib/
│ └── providers/ # Content source abstraction
│ ├── ContentProvider.ts # Interface
│ ├── MarkdownProvider.ts # Markdown implementation
│ ├── ApiProvider.ts # API implementation
│ ├── HybridProvider.ts # Fallback support
│ └── getContentProvider.ts # Factory
│
└── components/ # Same components, any source
├── TeamCard.tsx
└── BlogCard.tsx
```
## Summary: The Power of Abstraction
```
┌──────────────────────────────────────────────────────┐
│ ONE ENVIRONMENT VARIABLE │
│ CONTENT_SOURCE = markdown|convex|api|hybrid │
└────────────────────┬─────────────────────────────────┘
│
│ ContentProvider Interface
│
┌──────────────┼──────────────┬──────────────┐
│ │ │ │
↓ ↓ ↓ ↓
Markdown Convex REST API Supabase
Files (Real-time) (Any backend) (PostgreSQL)
│ │ │ │
│ │ │ │
↓ ↓ ↓ ↓
WordPress Notion Airtable Custom
(CMS) (Databases) (Spreadsheets) (DB)
All use SAME interface!
Your pages NEVER change!
```
## Key Benefits
| Benefit | Description |
|---------|-------------|
| **Backend Independence** | Switch from Markdown → Convex → WordPress → Supabase with ONE env var |
| **Zero Code Changes** | Pages, components, and logic remain 100% identical |
| **Development Speed** | Start with Markdown (no backend), deploy with Convex later |
| **Migration Safety** | Hybrid mode lets you test new backends without risk |
| **Offline Support** | Fallback to bundled Markdown when API unavailable |
| **Type Safety** | Generic types ensure data consistency across providers |
| **Easy Extension** | Add new providers by implementing one interface |
| **Production Ready** | Battle-tested pattern used by enterprise applications |
## The Philosophy
**One interface. Many implementations. Zero coupling.**
Traditional approach:
- Backend logic mixed into components ❌
- Different code for each data source ❌
- Refactoring required to switch backends ❌
- Tight coupling throughout codebase ❌
**Provider-agnostic approach:**
- Single ContentProvider interface ✅
- Same component code for all backends ✅
- Change backends with ONE environment variable ✅
- Perfect separation of concerns ✅
## What This Enables
**For startups:**
- Start free with Markdown files (no infrastructure)
- Scale to Convex when ready (real-time, auth, storage)
- No rewrite required
**For agencies:**
- Support multiple client backends (WordPress, Supabase, custom)
- One codebase, many deployments
- Rapid prototyping with Markdown
**For enterprises:**
- Gradual migrations without downtime
- Test new infrastructure safely (Hybrid mode)
- Backend redundancy and failover
**For developers:**
- Work offline with Markdown
- Deploy to any backend
- Freedom from vendor lock-in
**This is Layer 4 of the Astro architecture: Provider-agnostic content.**
Maximum flexibility. Minimum complexity. True backend independence.