API Documentation
Fetch your blog content via API and render it with your own components. Works with Next.js, Remix, Astro, or any frontend.
Go to your API Keys dashboard and generate a new key.
# .env.local
VIBEBLOGGER_API_KEY=vb_live_your_key_hereconst response = await fetch('https://vibeblogger.io/api/v1/posts', {
headers: {
'Authorization': `Bearer ${process.env.VIBEBLOGGER_API_KEY}`
}
});
const { posts } = await response.json();All API requests require authentication via API key. Include it in the request headers:
// Option 1: Authorization header (recommended)
Authorization: Bearer vb_live_your_key_here
// Option 2: X-API-Key header
X-API-Key: vb_live_your_key_here/api/v1/postsList all published blog posts.
| Parameter | Type | Description |
|---|---|---|
| page | number | Page number (default: 1) |
| limit | number | Posts per page (default: 10, max: 100) |
| category | string | Filter by category |
| tag | string | Filter by tag |
{
"posts": [
{
"_id": "...",
"title": "My Blog Post",
"slug": "my-blog-post",
"description": "A short description...",
"featuredImage": "https://...",
"status": "published",
"publishedAt": "2024-01-15T...",
"readTime": 5,
"tags": ["tech", "ai"],
"author": {
"name": "John Doe",
"imageUrl": "https://..."
},
"components": [
{ "type": "rich_text", "content": "..." },
{ "type": "image", "url": "...", "alt": "..." }
]
}
],
"pagination": {
"page": 1,
"limit": 10,
"total": 25,
"pages": 3,
"hasMore": true
}
}/api/v1/posts/:slugGet a single blog post by its slug.
{
"post": {
"_id": "...",
"title": "My Blog Post",
"slug": "my-blog-post",
"description": "A short description...",
"featuredImage": "https://...",
"components": [...],
// ... full post data
}
}API requests are rate limited to 1,000 requests per hour per API key.
Rate limit headers are included in every response:
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 999
X-RateLimit-Reset: 1705123456789Blog posts are made up of components. Each component has a type and type-specific fields.
rich_textRich text content — requires markdown parsing
Fields: content (Markdown string)
imageImage with optional caption
Fields: url, alt, caption, width, height
calloutHighlighted box (info/success/warning/error)
Fields: variant, title, content
quoteBlockquote with attribution
Fields: content, author, citation
ctaCall-to-action button
Fields: text, link, style
videoEmbedded video
Fields: videoUrl, thumbnail, videoTitle
tableData table
Fields: headers, rows, tableCaption
bar_chartBar chart visualization
Fields: data.labels, data.datasets
line_chartLine chart visualization
Fields: data.labels, data.datasets
pie_chartPie chart visualization
Fields: data.labels, data.values
comparison_tableFeature comparison table
Fields: data.items, data.features
pros_consPros and cons list
Fields: data.pros, data.cons
timelineTimeline of events
Fields: data.events
flowchartProcess flowchart
Fields: data.nodes, data.edges
step_by_stepNumbered steps guide
Fields: data.steps
code_blockSyntax-highlighted code
Fields: content, data.language
Copy this component renderer into your project to render all blog component types:
Important: The rich_text component contains Markdown, not HTML. You need to parse it using a library like react-markdown.
Install: npm install react-markdown
// components/BlogRenderer.tsx
import React from 'react';
import ReactMarkdown from 'react-markdown'; // npm install react-markdown
interface BlogComponent {
_id: string;
type: string;
order: number;
content?: string;
url?: string;
src?: string;
alt?: string;
caption?: string;
variant?: 'info' | 'success' | 'warning' | 'error';
title?: string;
author?: string;
citation?: string;
text?: string;
link?: string;
style?: 'primary' | 'secondary' | 'outline';
videoUrl?: string;
videoTitle?: string;
headers?: string[];
rows?: string[][];
tableCaption?: string;
data?: any;
}
// Main renderer - renders a single component
export function BlogComponentRenderer({ component }: { component: BlogComponent }) {
switch (component.type) {
case 'rich_text':
return (
<div className="prose prose-lg max-w-none">
<ReactMarkdown>{component.content || ''}</ReactMarkdown>
</div>
);
case 'image':
return (
<figure className="my-8">
<img
src={component.url || component.src}
alt={component.alt || ''}
className="w-full rounded-lg"
/>
{component.caption && (
<figcaption className="text-center text-sm text-gray-500 mt-2">
{component.caption}
</figcaption>
)}
</figure>
);
case 'callout':
const variantStyles = {
info: 'bg-blue-50 border-blue-200 text-blue-800',
success: 'bg-green-50 border-green-200 text-green-800',
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
error: 'bg-red-50 border-red-200 text-red-800',
};
return (
<div className={`p-4 rounded-lg border ${variantStyles[component.variant || 'info']} my-6`}>
{component.title && <strong className="block mb-1">{component.title}</strong>}
<ReactMarkdown>{component.content || ''}</ReactMarkdown>
</div>
);
case 'quote':
return (
<blockquote className="border-l-4 border-gray-300 pl-4 my-6 italic">
<ReactMarkdown>{component.content || ''}</ReactMarkdown>
{component.author && (
<cite className="block mt-2 text-sm text-gray-600 not-italic">
— {component.author}
{component.citation && <span>, {component.citation}</span>}
</cite>
)}
</blockquote>
);
case 'cta':
const buttonStyles = {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-600 text-white hover:bg-gray-700',
outline: 'border-2 border-blue-600 text-blue-600 hover:bg-blue-50',
};
return (
<div className="my-8 text-center">
<a
href={component.link}
className={`inline-block px-6 py-3 rounded-lg font-semibold transition-colors ${buttonStyles[component.style || 'primary']}`}
>
{component.text}
</a>
</div>
);
case 'video':
const getEmbedUrl = (url: string) => {
if (url.includes('youtube.com') || url.includes('youtu.be')) {
const videoId = url.match(/(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=))([^&?]+)/)?.[1];
return `https://www.youtube.com/embed/${videoId}`;
}
if (url.includes('vimeo.com')) {
const videoId = url.match(/vimeo\.com\/(?:video\/)?(\d+)/)?.[1];
return `https://player.vimeo.com/video/${videoId}`;
}
return url;
};
return (
<div className="my-8 aspect-video">
<iframe
src={getEmbedUrl(component.videoUrl || '')}
title={component.videoTitle || 'Video'}
className="w-full h-full rounded-lg"
allowFullScreen
/>
</div>
);
case 'table':
return (
<div className="my-8 overflow-x-auto">
<table className="w-full border-collapse">
{component.headers && (
<thead>
<tr className="bg-gray-100">
{component.headers.map((header, i) => (
<th key={i} className="border border-gray-300 px-4 py-2 text-left font-semibold">
{header}
</th>
))}
</tr>
</thead>
)}
<tbody>
{component.rows?.map((row, i) => (
<tr key={i} className={i % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
{row.map((cell, j) => (
<td key={j} className="border border-gray-300 px-4 py-2">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
{component.tableCaption && (
<p className="text-center text-sm text-gray-500 mt-2">{component.tableCaption}</p>
)}
</div>
);
case 'code_block':
return (
<pre className="my-6 p-4 bg-gray-900 text-gray-100 rounded-lg overflow-x-auto">
<code>{component.content}</code>
</pre>
);
case 'pros_cons':
return (
<div className="my-8 grid md:grid-cols-2 gap-4">
<div className="p-4 bg-green-50 rounded-lg">
<h4 className="font-semibold text-green-800 mb-2">Pros</h4>
<ul className="space-y-1">
{component.data?.pros?.map((pro: string, i: number) => (
<li key={i} className="text-green-700">{pro}</li>
))}
</ul>
</div>
<div className="p-4 bg-red-50 rounded-lg">
<h4 className="font-semibold text-red-800 mb-2">Cons</h4>
<ul className="space-y-1">
{component.data?.cons?.map((con: string, i: number) => (
<li key={i} className="text-red-700">{con}</li>
))}
</ul>
</div>
</div>
);
case 'step_by_step':
return (
<div className="my-8 space-y-4">
{component.data?.steps?.map((step: { title: string; content: string }, i: number) => (
<div key={i} className="flex gap-4">
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-blue-600 text-white flex items-center justify-center font-semibold">
{i + 1}
</div>
<div>
<h4 className="font-semibold">{step.title}</h4>
<p className="text-gray-600">{step.content}</p>
</div>
</div>
))}
</div>
);
case 'timeline':
return (
<div className="my-8 border-l-2 border-gray-300 pl-4 space-y-6">
{component.data?.events?.map((event: { date: string; title: string; content: string }, i: number) => (
<div key={i} className="relative">
<div className="absolute -left-6 w-3 h-3 bg-blue-600 rounded-full" />
<time className="text-sm text-gray-500">{event.date}</time>
<h4 className="font-semibold">{event.title}</h4>
<p className="text-gray-600">{event.content}</p>
</div>
))}
</div>
);
default:
console.warn(`Unknown component type: ${component.type}`);
return null;
}
}
// Render all components for a post
export function BlogContent({ components }: { components: BlogComponent[] }) {
return (
<div className="blog-content">
{components
.sort((a, b) => a.order - b.order)
.map((component) => (
<BlogComponentRenderer key={component._id} component={component} />
))}
</div>
);
}Here's a complete Next.js App Router integration:
const API_URL = 'https://vibeblogger.io/api/v1';
export async function getBlogPosts(page = 1, limit = 10) {
const res = await fetch(
`${API_URL}/posts?page=${page}&limit=${limit}`,
{
headers: {
'Authorization': `Bearer ${process.env.VIBEBLOGGER_API_KEY}`
},
next: { revalidate: 60 } // Revalidate every 60 seconds
}
);
if (!res.ok) {
throw new Error('Failed to fetch posts');
}
return res.json();
}
export async function getBlogPost(slug: string) {
const res = await fetch(
`${API_URL}/posts/${slug}`,
{
headers: {
'Authorization': `Bearer ${process.env.VIBEBLOGGER_API_KEY}`
},
next: { revalidate: 60 }
}
);
if (!res.ok) {
if (res.status === 404) return null;
throw new Error('Failed to fetch post');
}
return res.json();
}import Link from 'next/link';
import { getBlogPosts } from '@/lib/blog';
export default async function BlogPage() {
const { posts, pagination } = await getBlogPosts();
return (
<main className="max-w-4xl mx-auto px-4 py-12">
<h1 className="text-4xl font-bold mb-8">Blog</h1>
<div className="grid gap-8">
{posts.map((post) => (
<article key={post._id} className="border-b pb-8">
<Link href={`/blog/${post.slug}`}>
{post.featuredImage && (
<img
src={post.featuredImage}
alt={post.title}
className="w-full h-48 object-cover rounded-lg mb-4"
/>
)}
<h2 className="text-2xl font-semibold hover:text-blue-600">
{post.title}
</h2>
<p className="text-gray-600 mt-2">{post.description}</p>
<div className="flex gap-2 mt-3">
{post.tags?.map((tag) => (
<span key={tag} className="px-2 py-1 bg-gray-100 text-sm rounded">
{tag}
</span>
))}
</div>
</Link>
</article>
))}
</div>
{pagination.hasMore && (
<Link
href={`/blog?page=${pagination.page + 1}`}
className="mt-8 inline-block text-blue-600 hover:underline"
>
Load more posts
</Link>
)}
</main>
);
}import { notFound } from 'next/navigation';
import { getBlogPost } from '@/lib/blog';
import { BlogContent } from '@/components/BlogRenderer';
export default async function PostPage({
params
}: {
params: { slug: string }
}) {
const data = await getBlogPost(params.slug);
if (!data) {
notFound();
}
const { post } = data;
return (
<article className="max-w-3xl mx-auto px-4 py-12">
<header className="mb-8">
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<p className="text-xl text-gray-600">{post.description}</p>
{post.author && (
<div className="flex items-center gap-3 mt-6">
{post.author.imageUrl && (
<img
src={post.author.imageUrl}
alt={post.author.name}
className="w-10 h-10 rounded-full"
/>
)}
<span className="font-medium">{post.author.name}</span>
</div>
)}
</header>
{post.featuredImage && (
<img
src={post.featuredImage}
alt={post.title}
className="w-full rounded-lg mb-8"
/>
)}
<BlogContent components={post.components} />
</article>
);
}If you have questions or run into issues, reach out at support@vibeblogger.io