Home
Contact Terms Privacy Catalog About

How to Use Blogger as a Headless CMS


Blogger as a Headless CMS: Architecture and Implementation

Blogger's JSON API enables headless content delivery without server infrastructure. The platform stores posts and pages. Client applications fetch structured data via public endpoints. This eliminates hosting costs while retaining a managed editorial interface.

Core Mechanism

Blogger exposes every blog as a JSON resource. Append ?alt=json to any blog URL or use the dedicated API endpoint. The response contains posts, pages, labels, and metadata in a parseable format. No authentication required for public blogs.

The API returns up to 500 posts per request with pagination via nextPageToken. Published and draft states separate at the endpoint level. Labels function as taxonomies. Custom permalinks map to stable post.id values.

API Endpoint Structure

All requests target https://www.blogger.com/feeds/{blogId}/posts/default with query parameters controlling output format and scope.

Parameter Function Example
alt=json Returns JSON instead of Atom XML ?alt=json
max-results Posts per page (max 500) &max-results=100
start-index Offset for pagination &start-index=101
published-min Filter by date (ISO 8601) &published-min=2026-01-01
category Filter by label &category=API
q Full-text search &q=headless
path Single post by URL path &path=/2026/06/post-slug.html

Retrieve the blogId from Blogger settings or extract it from the default feed URL. The numeric ID remains constant across domain changes.

Fetching Posts: Implementation Patterns

Basic Fetch with JavaScript

const BLOG_ID = '1234567890123456789';
const API_URL = `https://www.blogger.com/feeds/${BLOG_ID}/posts/default?alt=json&max-results=500`;

async function fetchPosts() {
  const response = await fetch(API_URL);
  const data = await response.json();
  
  return data.feed.entry.map(entry => ({
    id: entry.id.$t.split('post-')[1],
    title: entry.title.$t,
    content: entry.content.$t,
    published: entry.published.$t,
    updated: entry.updated.$t,
    labels: entry.category?.map(c => c.term) || [],
    url: entry.link.find(l => l.rel === 'alternate')?.href,
    images: extractImages(entry.content.$t)
  }));
}

function extractImages(html) {
  const parser = new DOMParser();
  const doc = parser.parseFromString(html, 'text/html');
  return [...doc.querySelectorAll('img')].map(img => ({
    src: img.src,
    alt: img.alt
  }));
}

Server-Side Fetch with Node.js

// Netlify/Vercel function
exports.handler = async () => {
  const BLOG_ID = process.env.BLOGGER_ID;
  
  const response = await fetch(
    `https://www.blogger.com/feeds/${BLOG_ID}/posts/default?alt=json&max-results=500`,
    { headers: { 'Accept': 'application/json' } }
  );
  
  const data = await response.json();
  
  const posts = data.feed.entry?.map(normalizePost) || [];
  
  return {
    statusCode: 200,
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ posts, total: posts.length })
  };
};

function normalizePost(entry) {
  return {
    slug: entry.link
      .find(l => l.rel === 'alternate')
      ?.href
      .split('/')
      .pop()
      .replace('.html', ''),
    title: entry.title.$t,
    html: entry.content.$t,
    excerpt: entry.summary?.$t || '',
    published: entry.published.$t,
    modified: entry.updated.$t,
    categories: entry.category?.map(c => c.term) || [],
    author: {
      name: entry.author[0].name.$t,
      uri: entry.author[0].uri?.$t
    }
  };
}

Single Post Retrieval by Slug

The API lacks direct slug lookup. Implement path-based resolution using the path parameter or client-side filtering.

async function getPostBySlug(slug) {
  // Method 1: Direct path query
  const pathQuery = `https://www.blogger.com/feeds/${BLOG_ID}/posts/default` +
    `?alt=json&path=/2026/06/${slug}.html`;
  
  // Method 2: Client filter (requires all posts)
  const allPosts = await fetchPosts();
  return allPosts.find(p => p.slug === slug);
}

Method 1 requires exact path knowledge. Method 2 consumes more bandwidth but enables flexible matching. Cache the full post list to reduce API calls.

Label-Based Taxonomy System

Blogger labels function as categories and tags. Query filtered feeds for taxonomy-driven navigation.

async function fetchByLabel(label, maxResults = 50) {
  const url = new URL(
    `https://www.blogger.com/feeds/${BLOG_ID}/posts/default`
  );
  url.searchParams.set('alt', 'json');
  url.searchParams.set('max-results', maxResults);
  url.searchParams.set('category', label);
  
  const response = await fetch(url);
  const data = await response.json();
  
  return data.feed.entry || [];
}

// Usage: fetchByLabel('api-tutorial')

Multiple labels create AND conditions. OR logic requires client-side merging of separate requests.

Content Transformation Pipeline

Raw HTML from Blogger requires processing before consumption by modern frameworks.

Santization and Optimization

import DOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';

function transformContent(html, options = {}) {
  const dom = new JSDOM(html);
  const doc = dom.window.document;
  
  // Remove Blogger-specific attributes
  doc.querySelectorAll('[id^="post-body-"]').forEach(el => {
    el.removeAttribute('id');
  });
  
  // Convert internal links to relative paths
  doc.querySelectorAll('a[href*="doxlayer.blogspot.com"]').forEach(a => {
    a.href = a.href.replace('https://doxlayer.blogspot.com', '');
  });
  
  // Lazy-load images
  doc.querySelectorAll('img').forEach(img => {
    img.loading = 'lazy';
    img.decoding = 'async';
    
    // Responsive srcset if original available
    if (img.src.includes('bp.blogspot.com')) {
      const base = img.src.replace(/s\d+/, 's{w}');
      img.srcset = [320, 640, 960, 1280]
        .map(w => `${base.replace('{w}', w)} ${w}w`)
        .join(', ');
    }
  });
  
  return {
    html: DOMPurify.sanitize(doc.body.innerHTML),
    text: doc.body.textContent.trim(),
    wordCount: doc.body.textContent.split(/\s+/).length,
    readingTime: Math.ceil(doc.body.textContent.split(/\s+/).length / 200)
  };
}

Static Site Generation Integration

Eleventy (11ty) Data File

// _data/blogger.js
const Cache = require('@11ty/eleventy-fetch');

module.exports = async function() {
  const BLOG_ID = process.env.BLOGGER_ID;
  
  const data = await Cache(
    `https://www.blogger.com/feeds/${BLOG_ID}/posts/default?alt=json&max-results=500`,
    {
      duration: '1h',
      type: 'json',
      fetchOptions: {
        headers: { 'Accept': 'application/json' }
      }
    }
  );
  
  return data.feed.entry.map(entry => ({
    title: entry.title.$t,
    slug: entry.title.$t
      .toLowerCase()
      .replace(/[^\w\s-]/g, '')
      .replace(/\s+/g, '-'),
    date: new Date(entry.published.$t),
    content: entry.content.$t,
    excerpt: entry.summary?.$t?.substring(0, 200) + '...',
    tags: entry.category?.map(c => c.term) || []
  }));
};

Astro Integration

// src/lib/blogger.js
export async function getBloggerPosts() {
  const response = await fetch(
    `https://www.blogger.com/feeds/${import.meta.env.BLOGGER_ID}/posts/default?alt=json&max-results=500`
  );
  
  const data = await response.json();
  
  return data.feed.entry?.map(entry => ({
    id: entry.id.$t,
    title: entry.title.$t,
    body: entry.content.$t,
    pubDate: new Date(entry.published.$t),
    updated: new Date(entry.updated.$t),
    categories: entry.category?.map(c => c.term) || [],
    // Generate Astro-compatible slug
    slug: entry.link
      .find(l => l.rel === 'alternate')
      ?.href
      .match(/\/([^/]+)\.html$/)[1]
  })) || [];
}

// src/pages/blog/[slug].astro
---
export async function getStaticPaths() {
  const posts = await getBloggerPosts();
  
  return posts.map(post => ({
    params: { slug: post.slug },
    props: { post }
  }));
}

const { post } = Astro.props;
---

<article set:html={post.body} />

Search Implementation

Blogger's built-in search parameter has limitations. Implement client-side search with pre-built indexes for better performance.

// Build search index at build time
function buildSearchIndex(posts) {
  const flexSearch = require('flexsearch');
  
  const index = new flexSearch.Document({
    document: {
      id: 'id',
      index: ['title', 'content', 'categories']
    },
    tokenize: 'forward'
  });
  
  posts.forEach(post => {
    index.add({
      id: post.id,
      title: post.title,
      content: post.textContent,
      categories: post.categories.join(' ')
    });
  });
  
  return index;
}

// Client search with debouncing
function searchPosts(query, indexData) {
  const index = flexSearch.Document.load(indexData);
  
  const results = index.search(query, {
    enrich: true,
    limit: 10
  });
  
  return results.flatMap(r => r.result);
}

Caching and Performance

Strategy Implementation Invalidation
Build-time fetch SSG frameworks pull at build Rebuild on webhook
Edge cache Cloudflare/Vercel Cache-Control API revalidation
Stale-while-revalidate Next.js revalidate Background refresh
Client cache IndexedDB with timestamp ETag comparison
// Next.js revalidation pattern
export async function getStaticProps() {
  const posts = await fetchBloggerPosts();
  
  return {
    props: { posts },
    revalidate: 60 // Regenerate every 60 seconds
  };
}

// Webhook handler for instant invalidation
export default async function handler(req, res) {
  if (req.headers['x-blogger-token'] !== process.env.WEBHOOK_SECRET) {
    return res.status(401).end();
  }
  
  await res.revalidate('/blog');
  res.status(200).json({ revalidated: true });
}

Image Handling

Blogger serves images through a resizer proxy. Manipulate dimensions via URL parameters.

function getOptimizedImageUrl(originalUrl, { width, height, crop } = {}) {
  const url = new URL(originalUrl);
  
  // Replace size parameter
  let size = `s${width}`;
  if (height) size += `-h${height}`;
  if (crop) size += '-c';
  
  url.pathname = url.pathname.replace(/s\d+[^/]*/, size);
  
  return url.toString();
}

// Generate srcset for responsive images
function generateSrcset(baseUrl) {
  const widths = [320, 640, 960, 1280, 1920];
  
  return widths
    .map(w => `${getOptimizedImageUrl(baseUrl, { width: w })} ${w}w`)
    .join(', ');
}

Limitations and Workarounds

API Constraints

Rate limits are undocumented but enforceable. Cache aggressively. The 500-post maximum requires pagination for large archives. No webhook support exists for real-time updates. Poll or rebuild on schedule.

Content Restrictions

Custom fields require hack solutions. Embed JSON in post content, use labels as key-value pairs, or maintain a separate mapping file. Blogger strips script tags and certain attributes from post HTML.

URL Structure Inflexibility

Permalink format locked to /YYYY/MM/slug.html. Configure reverse proxy or client-side routing to mask this structure. Redirect rules preserve SEO value during migration.

Security Considerations

Public blogs expose all published content. Draft posts remain inaccessible without authentication. For sensitive preview environments, implement a proxy with OAuth or use Blogger's private blog feature with service account access.

// OAuth2 for private blog access
const { google } = require('googleapis');

const auth = new google.auth.OAuth2(
  process.env.CLIENT_ID,
  process.env.CLIENT_SECRET,
  process.env.REDIRECT_URI
);

const blogger = google.blogger({
  version: 'v3',
  auth
});

// Requires Blogger API v3 with OAuth
const posts = await blogger.posts.list({
  blogId: BLOG_ID,
  status: ['live', 'draft']
});

Verification Methods

Confirm API availability with direct curl request:

curl -s "https://www.blogger.com/feeds/BLOG_ID/posts/default?alt=json&max-results=1" | jq '.feed.entry[0].title'

Validate JSON structure against expected schema. Monitor response times. Check updated.$t freshness to detect stale cache.

Tools You Might Like

Handpicked utilities everyone is using right now