How to Use the WordPress REST API with a Static HTML Front-End

  • Canvas Team
  • 9 min read
How to Use the WordPress REST API with a Static HTML Front-End
9 min read
Share:

Key Takeaways

  • The WordPress REST API exposes posts, pages, media, categories, and custom post types as JSON at /wp-json/wp/v2/ with no plugin required.
  • A static HTML front-end fetches that JSON via fetch() at runtime, keeping your markup, styling, and interactivity completely independent of WordPress.
  • Authentication is only required for protected or write endpoints — public content is readable without any credentials.
  • CORS must be explicitly enabled on your WordPress host when the HTML front-end and the WordPress install live on different origins.
  • This architecture gives you CDN-hosted front-ends, full design freedom, and faster page loads without abandoning the WordPress editing workflow.

What Headless WordPress Actually Means for HTML Developers

“Headless WordPress” simply means using WordPress purely as a content back-end — a headless CMS — while discarding its templating layer (PHP themes, the_loop(), etc.) entirely. The WordPress REST API is the bridge: it exposes your content as structured JSON that any HTTP client can consume.

This is distinct from a full migration away from WordPress. If you have explored migrating from WordPress to a static Bootstrap 5 site, you know that approach generates HTML at build time and eliminates the CMS entirely. The headless approach keeps WordPress alive on a server but removes it from the request path of the visitor — they only ever receive your static HTML and the JSON it fetches.

The practical result: your editorial team logs into WordPress as normal, while your front-end team works in pure HTML, CSS, and JavaScript with no PHP dependency whatsoever.

Computer screen displaying code and data.
Photo by Daniil Komov on Unsplash

Key REST API Endpoints You Will Actually Use

The base namespace for the core REST API is /wp-json/wp/v2/. The most useful endpoints for a content-driven static site are:

  • GET /wp-json/wp/v2/posts — returns an array of post objects with rendered HTML in content.rendered and excerpt.rendered.
  • GET /wp-json/wp/v2/posts/{id} — single post by numeric ID.
  • GET /wp-json/wp/v2/posts?slug=my-post-slug — single post by slug (returns an array of one).
  • GET /wp-json/wp/v2/pages — same structure as posts but for static pages.
  • GET /wp-json/wp/v2/media/{id} — image metadata including sourceurl and mediadetails.sizes.
  • GET /wp-json/wp/v2/categories and /tags — taxonomy terms for filtering.
  • GET /wp-json/wp/v2/posts?categories=5&per_page=10&page=2 — filtered, paginated queries.

Pagination metadata lives in the response headers: X-WP-Total gives the total post count and X-WP-TotalPages gives the page count. You must read these headers in JavaScript to build a correct pagination UI.

Fetching and Rendering Posts in Static HTML

The following pattern retrieves the ten most recent posts and injects them into a Bootstrap 5 card grid. Place this in any .html file hosted on a CDN, Netlify, GitHub Pages, or a plain web server.

<!-- Post grid container -->
<div id="post-grid" class="row g-4"></div>

<script>
const API_BASE = 'https://your-wordpress-site.com/wp-json/wp/v2';

async function loadPosts() {
  const grid = document.getElementById('post-grid');
  grid.innerHTML = '<p>Loading...</p>';

  try {
    const res = await fetch(${APIBASE}/posts?perpage=10&_embed);
    if (!res.ok) throw new Error(HTTP ${res.status});
    const posts = await res.json();

    grid.innerHTML = posts.map(post => {
      const thumb = post.embedded?.['wp:featuredmedia']?.[0]?.sourceurl ?? '';
      const imgTag = thumb
        ? <img src="${thumb}" class="card-img-top" alt="${post.title.rendered}" loading="lazy">
        : '';
      return `
        <div class="col-md-6 col-lg-4">
          <div class="card h-100 shadow-sm">
            ${imgTag}
            <div class="card-body">
              <h3 class="card-title fs-5">${post.title.rendered}</h3>
              <div class="card-text text-muted small">${post.excerpt.rendered}</div>
            </div>
            <div class="card-footer bg-transparent border-0">
              <a href="/post.html?id=${post.id}" class="btn btn-sm btn-outline-primary">
                Read more
              </a>
            </div>
          </div>
        </div>`;
    }).join('');
  } catch (err) {
    grid.innerHTML = <p class="text-danger">Failed to load posts: ${err.message}</p></p>;
  }
}

loadPosts();
</script>

The _embed query parameter asks WordPress to inline related objects — including featured media — into the response, saving you a second round-trip per post.

Http acronym written on square tiles.
Photo by Miguel Ángel Padriñán Alba on Unsplash

Building a Dynamic Single-Post Page

Because your front-end is static HTML, every post shares one post.html file. The correct post is identified by reading the URL query string on page load:

<!-- post.html -->
<article id="post-content" class="container py-5"></article>

<script>
const API_BASE = 'https://your-wordpress-site.com/wp-json/wp/v2';

async function loadSinglePost() {
  const params = new URLSearchParams(window.location.search);
  const id = params.get('id');
  if (!id) return;

  const res = await fetch(${APIBASE}/posts/${id}?embed);
  const post = await res.json();

  document.title = post.title.rendered;

  document.getElementById('post-content').innerHTML = `
    <h1>${post.title.rendered}</h1>
    <p class="text-muted">${new Date(post.date).toLocaleDateString()}</p>
    <div class="entry-content">${post.content.rendered}</div>
  `;
}

loadSinglePost();
</script>

WordPress renders Gutenberg blocks and classic editor output as sanitised HTML inside content.rendered — you inject it directly with innerHTML. Add a scoped stylesheet to control the typography of that injected HTML so it matches your design system.

Handling CORS and Authentication

CORS is the most common deployment problem. When your HTML file is served from https://mysite.com and your WordPress install lives at https://cms.mysite.com, the browser enforces same-origin policy. WordPress does not set permissive CORS headers by default.

Add this to your theme’s functions.php or a lightweight plugin:

<?php
// Allow your static front-end origin
addaction( 'restapi_init', function() {
  removefilter( 'restpreserverequest', 'restsendcors_headers' );
  addfilter( 'restpreserverequest', function( $value ) {
    header( 'Access-Control-Allow-Origin: https://mysite.com' );
    header( 'Access-Control-Allow-Methods: GET, OPTIONS' );
    header( 'Access-Control-Allow-Credentials: true' );
    return $value;
  } );
}, 15 );

Replace https://mysite.com with your exact front-end origin. Never use * in production when credentials are involved.

Authentication is only needed for non-public endpoints (drafts, user data, write operations). For read-only public posts, no authentication is required. If you do need authenticated requests — for example, submitting comments or reading private content — use Application Passwords (built into WordPress 5.6+) with Basic Auth over HTTPS, or implement OAuth 2.0 via a plugin such as WP OAuth Server.

Performance and SEO Considerations

The primary trade-off of a runtime-fetched headless front-end is that content is not in the initial HTML payload. Search engine crawlers have improved JavaScript execution significantly, but rendering delays can still affect indexing, particularly for content that appears after multiple asynchronous fetches. For a deeper comparison of how each architecture affects rankings, read our post on WordPress vs static HTML for SEO.

Practical mitigations include:

  • Pre-render critical pages at build time using a Node.js script that fetches the API and writes static .html files — this gives you the SEO benefits of static HTML with the editorial convenience of WordPress.
  • Cache API responses in a service worker or in-memory to prevent repeated network requests on navigation.
  • Use ?fields=id,title,excerpt,slug,links on list endpoints to reduce payload size — the full content.rendered field on a post list can be enormous.
  • Set Cache-Control headers on the WordPress server so CDN edges can cache REST responses for minutes or hours depending on your update frequency.

Pairing This Architecture with a Bootstrap 5 HTML Template

The headless WordPress pattern works extremely well with a fully designed HTML template as the front-end shell. The Canvas HTML Template provides over 50 production-ready demo layouts built on Bootstrap 5, giving you structured page shells — headers, hero sections, card grids, footers — into which you inject your WordPress JSON content at runtime. There is no PHP involved; Canvas is pure HTML, CSS, and JavaScript.

For data-rich listing pages, Canvas’s card and grid components accept dynamic content naturally. For complex layouts like recipe or portfolio archives, you can adapt the patterns shown in posts like building a recipe site with the Canvas Recipes demo — simply replace the hard-coded card markup with the JavaScript rendering loop shown earlier in this article.

Canvas uses CSS custom properties (--cnvs-themecolor and related tokens) for theming, so you can apply a client’s brand colours without touching the component HTML, keeping your API integration and your design system cleanly separated.

Frequently Asked Questions

No. The REST API has been bundled with WordPress core since version 4.7, released in December 2016. It is active by default on any standard WordPress installation. The base URL is /wp-json/wp/v2/ relative to your WordPress root. Certain hosts disable it via security plugins — check that Disable REST API settings are not active if you receive 404s on the namespace.

Yes, but custom post types must be registered with 'showinrest' => true in their registerposttype() arguments. Once enabled, they appear at /wp-json/wp/v2/{restbase} where restbase defaults to the post type slug. Custom fields registered via registermeta() with 'showin_rest' => true appear in the meta property of each response object.

Read the X-WP-Total and X-WP-TotalPages headers from the fetch response using res.headers.get('X-WP-Total'). Use these values to calculate how many page buttons to render, then re-fetch with ?page=N when the user clicks. Because fetch() does expose response headers, this works without any server-side changes.

It depends on your configuration. The initial HTML file loads faster when served from a CDN with no PHP execution. However, the API fetch adds a waterfall delay before content is visible. If SEO or Time to First Contentful Paint is critical, pre-rendering your HTML at build time using a static site generator or a Node.js build script eliminates this gap while keeping the WordPress editing workflow intact.

HTTPS is not strictly required for the API itself to function, but browsers block mixed-content requests — meaning if your HTML front-end is served over HTTPS, any fetch() to an HTTP WordPress install will be blocked. In practice, any production WordPress installation should be running HTTPS, and most hosting providers include free Let’s Encrypt certificates. Application Passwords (used for authenticated requests) also require HTTPS to be considered secure.

Looking for a production-ready Bootstrap 5 HTML template? Browse Canvas Template demos and find the perfect starting point for your next project.

If you’re building with the Canvas HTML Template and want to ship production-ready Bootstrap 5 layouts faster, try Canvas Builder free — the visual builder that exports clean Canvas-ready markup in minutes.

Skip the setup — build it free

Spin up a complete Bootstrap 5 site, blog included, with Canvas Builder. No coding, no cost.

Share:
Canvas Team
Canvas Team

Tutorials and tips for building beautiful Bootstrap 5 websites with the Canvas HTML Template and Canvas Builder.

More from the Canvas Blog