Home
Contact Terms Privacy Catalog About

How to Use Blogger as a Headless CMS

Blogger as a Headless CMS

Blogger serves JSON feeds natively through its /feeds/posts/default endpoint. Any client can query this structured data without server-side rendering. The platform becomes a content backend when you decouple its storage from its frontend presentation.

Feed Architecture and Data Structure

Blogger exposes three feed formats. The default Atom feed contains full post content in HTML. The alt=json parameter returns JSON. The alt=json-in-script variant enables cross-origin requests without CORS configuration.

Each feed entry contains id, published, updated, title, content, author, link, and category nodes. The content node holds the full HTML body. Labels map to the category array for taxonomy filtering.

Image assets within posts reference Blogger's CDN directly. These URLs remain stable unless the image is reuploaded.

Feed Endpoint Construction

Construct requests using your blog's base URL followed by the feed path. Replace BLOGSPOT_DOMAIN with your actual subdomain.

// Atom XML (default)
https://BLOGSPOT_DOMAIN.blogspot.com/feeds/posts/default

// JSON
https://BLOGSPOT_DOMAIN.blogspot.com/feeds/posts/default?alt=json

// JSON with callback for JSONP
https://BLOGSPOT_DOMAIN.blogspot.com/feeds/posts/default?alt=json-in-script&callback=handleData

// Single post by path
https://BLOGSPOT_DOMAIN.blogspot.com/feeds/posts/default/BLOG_POST_PATH?alt=json

// Filtered by label
https://BLOGSPOT_DOMAIN.blogspot.com/feeds/posts/default/-/LABEL_NAME?alt=json

// Paginated results
https://BLOGSPOT_DOMAIN.blogspot.com/feeds/posts/default?alt=json&max-results=10&start-index=11

The max-results parameter accepts values up to 500. The start-index parameter enables pagination. The updated-min and updated-max parameters filter by date range in ISO 8601 format.

Client-Side Fetch Implementation

Modern browsers fetch JSON directly. Legacy implementations require JSONP due to missing CORS headers on Blogger feeds.

// Native fetch with JSON
async function fetchPosts(blogDomain, options = {}) {
  const params = new URLSearchParams({
    alt: 'json',
    'max-results': options.limit || 10,
    ...options.startIndex && { 'start-index': options.startIndex },
    ...options.label && { category: options.label }
  });
  
  const response = await fetch(
    `https://${blogDomain}.blogspot.com/feeds/posts/default?${params}`
  );
  
  if (!response.ok) {
    throw new Error(`Feed error: ${response.status}`);
  }
  
  const data = await response.json();
  return normalizeFeed(data);
}

// Normalize Blogger's nested structure
function normalizeFeed(feedData) {
  const entries = feedData.feed.entry || [];
  
  return entries.map(entry => ({
    id: entry.id.$t,
    title: entry.title.$t,
    published: entry.published.$t,
    updated: entry.updated.$t,
    content: entry.content?.$t || '',
    summary: entry.summary?.$t || '',
    labels: entry.category?.map(c => c.term) || [],
    author: {
      name: entry.author?.[0]?.name?.$t,
      uri: entry.author?.[0]?.uri?.$t
    },
    links: entry.link?.reduce((acc, l) => {
      if (l.rel) acc[l.rel] = l.href;
      return acc;
    }, {}),
    images: extractImages(entry.content?.$t || '')
  }));
}

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

Static Site Integration Patterns

Build-time fetching preloads content into static generators. Runtime fetching defers loading to the client. Hybrid approaches cache feeds and revalidate on intervals.

Build-Time with Node.js

// scripts/fetch-content.js
const fs = require('fs');
const path = require('path');

async function fetchAllPosts(domain) {
  const allPosts = [];
  let startIndex = 1;
  const maxResults = 500;
  
  while (true) {
    const params = new URLSearchParams({
      alt: 'json',
      'max-results': maxResults,
      'start-index': startIndex
    });
    
    const res = await fetch(`https://${domain}.blogspot.com/feeds/posts/default?${params}`);
    const data = await res.json();
    const entries = data.feed.entry || [];
    
    allPosts.push(...entries);
    
    if (entries.length < maxResults) break;
    startIndex += maxResults;
    
    // Rate limit protection
    await new Promise(r => setTimeout(r, 500));
  }
  
  return allPosts;
}

// Execute and write to build directory
fetchAllPosts('yourblog')
  .then(posts => {
    const output = path.resolve('./src/data/posts.json');
    fs.mkdirSync(path.dirname(output), { recursive: true });
    fs.writeFileSync(output, JSON.stringify(posts, null, 2));
    console.log(`Cached ${posts.length} posts`);
  })
  .catch(console.error);

Vite Integration

// vite.config.js
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [{
    name: 'blogger-feed',
    async buildStart() {
      const posts = await fetchAllPosts('yourblog');
      this.emitFile({
        type: 'asset',
        fileName: 'data/posts.json',
        source: JSON.stringify(posts)
      });
    }
  }]
});

React Component for Live Fetching

// components/BloggerFeed.jsx
import { useState, useEffect } from 'react';

const CACHE_KEY = 'blogger_posts_cache';
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes

export function useBloggerPosts(blogDomain, options = {}) {
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const cacheKey = `${CACHE_KEY}_${blogDomain}_${options.label || 'all'}`;
    const cached = localStorage.getItem(cacheKey);
    
    if (cached) {
      const { data, timestamp } = JSON.parse(cached);
      if (Date.now() - timestamp < CACHE_TTL) {
        setPosts(data);
        setLoading(false);
        return;
      }
    }

    fetchPosts(blogDomain, options)
      .then(data => {
        setPosts(data);
        localStorage.setItem(cacheKey, JSON.stringify({
          data,
          timestamp: Date.now()
        }));
      })
      .catch(setError)
      .finally(() => setLoading(false));
  }, [blogDomain, options.label]);

  return { posts, loading, error };
}

// Usage in component
export function PostList({ blogDomain }) {
  const { posts, loading, error } = useBloggerPosts(blogDomain, { 
    limit: 10,
    label: 'featured' 
  });

  if (loading) return <div className="skeleton-grid">...</div>;
  if (error) return <div className="error">Failed to load content</div>;

  return (
    <div className="post-grid">
      {posts.map(post => (
        <article key={post.id} className="glass-card">
          <h2>{post.title}</h2>
          <time dateTime={post.published}>
            {new Date(post.published).toLocaleDateString()}
          </time>
          <div 
            className="content"
            dangerouslySetInnerHTML={{ __html: sanitizeContent(post.content) }}
          />
        </article>
      ))}
    </div>
  );
}

Content Sanitization Requirements

Blogger feed content contains raw HTML. Client-side rendering requires sanitization to prevent XSS through malicious post content or compromised accounts.

// utils/sanitize.js
import DOMPurify from 'dompurify';

const ALLOWED_TAGS = [
  'p', 'br', 'strong', 'em', 'u', 'h1', 'h2', 'h3', 
  'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'a', 'img',
  'blockquote', 'code', 'pre', 'table', 'thead',
  'tbody', 'tr', 'td', 'th', 'div', 'span'
];

const ALLOWED_ATTR = [
  'href', 'src', 'alt', 'title', 'class', 'id',
  'width', 'height', 'target', 'rel'
];

export function sanitizeContent(dirtyHtml) {
  return DOMPurify.sanitize(dirtyHtml, {
    ALLOWED_TAGS,
    ALLOWED_ATTR,
    FORBID_ATTR: ['style'], // Strip inline styles
    ADD_ATTR: ['target'],   // Ensure external links open safely
    hook: {
      afterSanitizeAttributes: (node) => {
        // Force external links to open in new tab with security attributes
        if (node.tagName === 'A' && node.hasAttribute('href')) {
          const href = node.getAttribute('href');
          if (href.startsWith('http') && !href.includes(window.location.hostname)) {
            node.setAttribute('target', '_blank');
            node.setAttribute('rel', 'noopener noreferrer');
          }
        }
        // Lazy load images
        if (node.tagName === 'IMG') {
          node.setAttribute('loading', 'lazy');
        }
      }
    }
  });
}

Image Optimization Pipeline

Blogger serves images through Google's CDN with resize parameters. Append query parameters to request specific dimensions without server processing.

// Transform Blogger image URLs for responsive delivery
function optimizeImageUrl(originalUrl, options = {}) {
  const url = new URL(originalUrl);
  
  // Only modify Google User Content URLs
  if (!url.hostname.includes('googleusercontent.com')) {
    return originalUrl;
  }

  const params = new URLSearchParams(url.search);
  
  // Size constraints
  if (options.width) params.set('w', options.width);
  if (options.height) params.set('h', options.height);
  
  // Quality: 10-100
  if (options.quality) params.set('q', options.quality);
  
  // Format: auto webp conversion
  if (options.format) params.set('fm', options.format);
  
  // Crop mode: smart, center, top, etc.
  if (options.fit) params.set('fit', options.fit);

  // Common presets
  const presets = {
    thumbnail: { width: 320, height: 240, quality: 80, fit: 'crop' },
    card: { width: 800, quality: 85, fit: 'max' },
    hero: { width: 1600, quality: 90, fit: 'max' },
    full: { quality: 95, fit: 'max' }
  };

  const preset = presets[options.preset] || {};
  const finalParams = new URLSearchParams({
    ...preset,
    ...Object.fromEntries(params)
  });

  return `${url.origin}${url.pathname}?${finalParams.toString()}`;
}

// Usage
const thumbnail = optimizeImageUrl(imgSrc, { preset: 'thumbnail' });
const responsive = optimizeImageUrl(imgSrc, { 
  width: 800, 
  quality: 85,
  format: 'webp' 
});

Search Implementation

Blogger's native search returns HTML pages, not structured data. Implement client-side search by indexing fetched posts.

// utils/search.js
export function createSearchIndex(posts) {
  return posts.map(post => ({
    ...post,
    // Flatten searchable text
    searchText: [
      post.title,
      post.content.replace(/<[^>]+>/g, ' '),
      post.labels.join(' ')
    ].join(' ').toLowerCase()
  }));
}

export function searchPosts(index, query) {
  const terms = query.toLowerCase().split(/\s+/).filter(t => t.length > 2);
  
  return index
    .map(post => {
      const matches = terms.filter(term => 
        post.searchText.includes(term)
      ).length;
      return { post, score: matches / terms.length };
    })
    .filter(r => r.score > 0)
    .sort((a, b) => b.score - a.score)
    .map(r => r.post);
}

// Web Worker for large indexes
// search-worker.js
self.addEventListener('message', ({ data: { posts, query } }) => {
  const results = searchPosts(posts, query);
  self.postMessage({ results });
});

Common Failure Points and Resolution

CORS Errors on Direct Fetch

Blogger feeds omit CORS headers for alt=json on some regions. Solutions include proxying through a worker or using JSONP.

// JSONP fallback for CORS-blocked environments
function jsonpFetch(url) {
  return new Promise((resolve, reject) => {
    const script = document.createElement('script');
    const callbackName = `jsonp_${Date.now()}`;
    
    window[callbackName] = (data) => {
      delete window[callbackName];
      document.head.removeChild(script);
      resolve(data);
    };
    
    script.src = `${url}&callback=${callbackName}`;
    script.onerror = reject;
    document.head.appendChild(script);
    
    setTimeout(reject, 10000); // Timeout
  });
}

Feed Rate Limiting

Unauthenticated requests face IP-based rate limits. Symptoms include 503 errors or empty responses. Mitigate with caching, request batching, and exponential backoff.

async function fetchWithRetry(url, options = {}, retries = 3) {
  const delay = Math.pow(2, 3 - retries) * 1000;
  
  try {
    const res = await fetch(url, options);
    if (res.status === 503 && retries > 0) {
      await new Promise(r => setTimeout(r, delay));
      return fetchWithRetry(url, options, retries - 1);
    }
    return res;
  } catch (err) {
    if (retries > 0) {
      await new Promise(r => setTimeout(r, delay));
      return fetchWithRetry(url, options, retries - 1);
    }
    throw err;
  }
}

Content Encoding Issues

Blogger returns feeds with XML-encoded entities within JSON strings. Decode before rendering.

function decodeEntities(str) {
  const textarea = document.createElement('textarea');
  textarea.innerHTML = str;
  return textarea.value;
}

// Apply during normalization
function normalizeEntry(entry) {
  return {
    title: decodeEntities(entry.title.$t),
    content: decodeEntities(entry.content.$t)
  };
}

Verification Methods

Confirm feed availability by requesting the base endpoint with curl. Validate JSON structure against expected schema.

# Verify feed accessibility
curl -I "https://yourblog.blogspot.com/feeds/posts/default?alt=json"

# Expected: HTTP/2 200 with Content-Type: application/json; charset=UTF-8

# Full content test
curl -s "https://yourblog.blogspot.com/feeds/posts/default?alt=json&max-results=1" | jq '.feed.entry[0].title'

# Validate image optimization
curl -I "https://BLOGGER_IMAGE_URL=w800-h600-p-k-no-nu"

Monitor feed health through periodic HEAD requests. Alert when status deviates from 200 or response time exceeds thresholds.

Limitations and Constraints

Blogger imposes a 500 post maximum per feed request. Pagination handles larger archives but increases total fetch time. The platform offers no webhook for content changes. Polling intervals must balance freshness against rate limits.

Custom domains require feed URL adjustment to the custom domain rather than the blogspot subdomain. Mixed content rules block HTTP resources on HTTPS pages.

Comment data requires separate API access through the /feeds/comments/default endpoint. No native draft preview exists for headless consumers.

Architecture Comparison

Pattern Build-Time Runtime Hybrid
Freshness On deploy only Immediate Configurable TTL
SEO Pre-rendered HTML Requires SSR/ISR Best of both
Complexity Low Medium High
Best For Static sites, blogs Apps, dashboards Large publications

Tools You Might Like

Handpicked utilities everyone is using right now