HTMX

Integrate HTMX with Strapi for dynamic, server-driven web experiences without complex JavaScript. Build interactive interfaces with hypermedia-driven updates powered by your Strapi CMS content

HTMX logo

What Is HTMX?

HTMX is a 14KB JavaScript library that extends HTML with attributes for AJAX requests, WebSocket communication, and DOM manipulation. Instead of writing JavaScript to fetch data and update the UI, you add attributes like hx-get, hx-post, and hx-target directly to your HTML elements.

HTMX embraces hypermedia-driven architecture: servers respond with HTML fragments instead of JSON, representing a return to server-centric patterns with modern progressive enhancement.

Your backend renders HTML with full access to business logic and data, while HTMX handles dynamic updates through declarative attributes. No virtual DOM, no complex state management, no build tools required.

Why Integrate HTMX with Strapi

Combining HTMX with Strapi creates a streamlined full-stack architecture that prioritizes simplicity without sacrificing functionality. Here's what makes this pairing effective:

  • Eliminate Frontend Build Complexity: HTMX is a 14KB JavaScript library requiring no transpilation or bundling—just include it via CDN and start adding attributes to your HTML.
  • Leverage Server-Side Rendering: Extend Strapi's REST API with custom controllers that return HTML fragments instead of JSON, giving templates direct access to content and business logic.
  • Reduce JavaScript Payload: HTMX's ~14KB footprint replaces 200KB+ SPA bundles—a 93% reduction that directly improves Core Web Vitals and load times.
  • Maintain Development Flexibility: Start with server-rendered pages and progressively enhance interactions where needed, without locking into specific architectures.

How to Integrate HTMX with Strapi

This step-by-step guide walks you through setting up a Strapi backend with custom HTML endpoints and connecting it to an HTMX-powered frontend.

Set Up Your Strapi Backend

Start by initializing a new Strapi project using the quickstart template:

npx create-strapi@latest my-project --quickstart

This creates a Strapi instance with SQLite for development. From here, start the server:

npm run develop

The admin panel becomes available at http://localhost:1337/admin. Create your first administrator account when prompted.

Create Content Types

Open the Content-Type Builder in your admin panel and create a new Collection Type called "Article" with these fields:

  • Text field: title
  • Rich text field: content
  • Date field: publishedAt

Strapi automatically generates REST API endpoints:

  • GET /api/articles: Retrieves all articles
  • GET /api/articles/:documentId: Retrieves a single article
  • POST /api/articles: Creates a new article
  • PUT /api/articles/:documentId: Updates an article
  • DELETE /api/articles/:documentId: Deletes an article

Note: Strapi v5 uses string-based documentId instead of numeric IDs, representing a major breaking change from v4.

Configure API Access

If you've worked with APIs before, you know the frustration of getting 403 errors because permissions aren't configured. Here's how to avoid that:

Set permissions for public access through Settings, then select the Public role and enable find and findOne permissions for your Articles content type.

For authenticated requests, generate an API token through Settings, then API Tokens. The token displays only once, so store it securely for future use.

Configure CORS to allow your frontend origin. Edit ./config/middlewares.js:

module.exports = [
  'strapi::logger',
  'strapi::errors',
  'strapi::security',
  {
    name: 'strapi::cors',
    config: {
      origin: ['http://localhost:3000', 'http://localhost:5173'],
      methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
      headers: ['Content-Type', 'Authorization', 'Origin', 'Accept'],
      credentials: true,
    },
  },
  'strapi::poweredBy',
  'strapi::query',
  'strapi::body',
  'strapi::session',
  'strapi::favicon',
  'strapi::public',
];

Build Custom Controllers for HTML Responses

This is where HTMX integration differs from typical SPA architecture. While Strapi's default REST endpoints return JSON, the recommended pattern for HTMX is to have the server return HTML fragments that HTMX inserts directly into the DOM. Rather than fetching JSON and rendering it client-side, create custom controllers that return pre-rendered HTML responses.

Create src/api/article/controllers/article.js:

'use strict';

const { createCoreController } = require('@strapi/strapi').factories;

module.exports = createCoreController('api::article.article', ({ strapi }) => ({
  async findHtml(ctx) {
    // Use Document Service API instead of deprecated Entity Service
    const entities = await strapi
      .documents('api::article.article')
      .findMany({
        populate: '*',          // same semantics as before
        sort: 'createdAt:desc', // recommended sort syntax in v5
      });

    const html = entities
      .map((article) => `
        <article id="article-${article.documentId}" class="article-card">
          <h3>${article.title}</h3>
          <p>${article.content.substring(0, 150)}...</p>
          <div class="actions">
            <button 
              hx-get="/articles/${article.documentId}/html"
              hx-target="#article-${article.documentId}"
              hx-swap="outerHTML">
              View Full
            </button>
            <button 
              hx-delete="/api/articles/${article.documentId}"
              hx-target="#article-${article.documentId}"
              hx-swap="outerHTML"
              hx-confirm="Delete this article?">
              Delete
            </button>
          </div>
        </article>
      `)
      .join('');

    ctx.type = 'text/html';
    ctx.body = html;
  },
}));

Implement the Frontend

Now create an HTML file that includes HTMX via CDN:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>HTMX and Strapi Integration</title>
    <script src="https://unpkg.com/htmx.org@1.9.10"></script>
    <style>
        .article-card {
            border: 1px solid #ddd;
            padding: 1rem;
            margin: 1rem 0;
        }
        .htmx-indicator {
            display: none;
        }
        .htmx-request .htmx-indicator {
            display: inline;
        }
    </style>
</head>
<body>
    <h1>HTMX and Strapi Integration</h1>

    <!-- Create Article Form -->
    <h2>Create New Article</h2>
    <form 
        hx-post="http://localhost:1337/api/articles/create"
        hx-target="#form-response"
        hx-swap="innerHTML">

        <input type="text" name="title" placeholder="Article Title" required>
        <textarea name="content" rows="5" placeholder="Article Content" required></textarea>
        <button type="submit">Create Article</button>
    </form>
    <div id="form-response"></div>

    <!-- Articles List -->
    <h2>Articles</h2>
    <div 
        id="articles-container"
        hx-get="http://localhost:1337/api/articles/html"
        hx-trigger="load"
        hx-indicator="#loading">
        <!-- Articles load here -->
    </div>

    <div id="loading" class="htmx-indicator">Loading...</div>

    <!-- Global error handling -->
    <script>
        document.body.addEventListener('htmx:responseError', function(evt) {
            console.error('HTMX Error:', evt.detail.error);
        });
    </script>
</body>
</html>

The hx-get attribute issues GET requests to fetch content from specified endpoints. When combined with hx-trigger="load", the request fires when the element loads into the DOM.

The hx-indicator attribute references an element that displays a loading state during the request: HTMX automatically adds the htmx-request class to indicator elements, allowing CSS to show or hide loading spinners based on request status.

Add Authentication

When you need to protect endpoints, avoid injecting API tokens into all HTMX requests via global event listeners; instead, follow standard web‑security practices such as server‑side session management, HttpOnly cookies, CSRF protection, and fine‑grained authorization.

&lt;script&gt;
document.body.addEventListener('htmx:configRequest', (event) => {
    event.detail.headers['Authorization'] = 'Bearer YOUR_API_TOKEN';
});
&lt;/script&gt;

This listener intercepts all HTMX requests and adds the authorization header.

Optimize Query Performance

Most teams start by fetching everything and only optimize when it becomes a performance issue. Strapi's query parameters let you prevent over-fetching from the start. In your custom controllers, accept query parameters for filtering and field selection:

Field Selection: Use the fields parameter to return only the attributes your application needs:

GET /api/articles?fields[0]=title&fields[1]=content

Selective Population: Use the populate parameter with field selection to include only necessary relations:

GET /api/articles?populate[author][fields][0]=name&populate[author][fields][1]=email

Filtering: Apply server-side filters to reduce data transfer with operators like $eq, $contains, $gte, and $lte:

GET /api/articles?filters[status][$eq]=published&filters[publishedAt][$notNull]=true

Pagination: Implement page-based or offset-based pagination to limit response sizes:

GET /api/articles?pagination[page]=1&pagination[pageSize]=10

In your custom controllers, leverage these parameters to construct efficient queries:

module.exports = createCoreController("api::article.article", ({ strapi }) => ({
  async find(ctx) {
    await this.validateQuery(ctx);
    const sanitizedQuery = await this.sanitizeQuery(ctx);
    const entities = await strapi.documents("api::article.article").findMany({
      filters: sanitizedQuery.filters,
      fields: sanitizedQuery.fields,
      populate: sanitizedQuery.populate,
      sort: sanitizedQuery.sort,
      pagination: sanitizedQuery.pagination,
      status: sanitizedQuery.status,
      locale: sanitizedQuery.locale,
    });

    // Sanitize output before returning
    const sanitizedResults = await this.sanitizeOutput(entities, ctx);

    // Optionally wrap with transformResponse if you want meta
    ctx.body = sanitizedResults;
  },
}));

This approach ensures you fetch and transmit only the data required by your HTMX endpoints, significantly reducing bandwidth usage and improving response times.

You can optimize with these strategies:

async findHtml(ctx) {
  const entities = await strapi.documents("api::article.article").findMany({
      fields: ["title", "content", "publishedAt"],
      populate: {
        author: {
          fields: ["name"],
        },
      },
      filters: {
        publishedAt: {
          $notNull: true,
        },
      },
      sort: "publishedAt:desc",
      limit: 10,
      start: 0,
  });


  const html = entities.map(article => `
    <article id="article-${article.documentId}" class="article-card">
      <h3>${article.title}</h3>
      <p>${article.content.substring(0, 150)}...</p>
      <div class="actions">
        <button 
          hx-get="/api/articles/${article.documentId}/html"
          hx-target="#article-${article.documentId}"
          hx-swap="outerHTML">
          View Full
        </button>
      </div>
    </article>
  `).join('');

  ctx.type = 'text/html';
  ctx.body = html;
}

Project Example: Dynamic Blog with Inline Editing

This example demonstrates a blog where authenticated users can edit articles inline without page navigation. The pattern shows how HTMX and Strapi work together for content management interfaces.

Backend Setup

Create custom controller methods in Strapi to handle dynamic HTMX requests. Custom controllers extend the core controller using createCoreController and can be configured to return HTML fragments for HTMX integration. Register the custom routes in your routes configuration file, specifying the handler for HTMX requests that return HTML content rather than JSON responses.

// src/api/article/controllers/article.js
'use strict';

const { createCoreController } = require("@strapi/strapi").factories;

module.exports = createCoreController("api::article.article", ({ strapi }) => ({
  async updateInline(ctx) {
    const { documentId } = ctx.params;
    const { title, content } = ctx.request.body;

    await this.validateQuery(ctx);
    const sanitizedInput = await this.sanitizeInput({ title, content }, ctx);

    try {
      const entity = await strapi.documents("api::article.article").update({
        documentId,
        data: sanitizedInput,
      });

      // Check if this is an HTMX request
      const isHtmxRequest = ctx.request.headers["hx-request"] === "true";

      if (isHtmxRequest) {
        // Return HTML fragment for HTMX to swap into DOM
        ctx.type = "text/html";
        ctx.body = `
          <article id="article-${documentId}" class="article-view">
            <h3>${entity.title}</h3>
            <p>${entity.content}</p>
            <button 
              hx-get="/api/articles/${documentId}/edit-form"
              hx-target="#article-${documentId}"
              hx-swap="outerHTML">
              Edit
            </button>
          </article>
        `;
      } else {
        const sanitizedOutput = await this.sanitizeOutput(entity, ctx);
        return this.transformResponse(sanitizedOutput);
      }
    } catch (error) {
      ctx.throw(500, error);
    }
  },
}));

Adding Real-Time Updates

Extend the pattern with polling for collaborative editing awareness:

<div 
    id="articles"
    hx-get="http://localhost:1337/api/articles/html"
    hx-trigger="load, every 30s"
    hx-swap="innerHTML">
</div>

The every 30s modifier refreshes content automatically. For more immediate updates, integrate webhooks that trigger Server-Sent Events when content changes.

Strapi Open Office Hours

If you have any questions about Strapi 5 or just would like to stop by and say hi, you can join us at Strapi's Discord Open Office Hours, Monday through Friday, from 12:30 pm to 1:30 pm CST: Strapi Discord Open Office Hours. For more details, visit the Strapi documentation and HTMX documentation.

Frequently Answered Questions

HTMX works with REST endpoints that return HTML. While Strapi supports GraphQL for non-HTMX consumers, build HTMX integrations with REST endpoints and custom controllers that return HTML fragments. If you need GraphQL for mobile apps or third-party integrations requiring JSON, maintain both APIs in parallel: use GraphQL for JSON consumers that expect structured data, and REST with HTML responses for HTMX-driven interfaces.

Use hx-post with hx-encoding="multipart/form-data" on your form element. Create a custom Strapi controller that processes the upload and returns HTML showing the uploaded file. Strapi's media library plugin handles storage providers (local, S3, Cloudflare R2) while your controller wraps the response in HTML fragments.

Yes. Strapi's draft and publish system works at the API level. Query for draft content in your custom controllers using filters like { publishedAt: { $null: true } } and return appropriate HTML. Add HTMX attributes to preview buttons that fetch draft versions, letting editors switch between published and draft views without page navigation.

HTMX eliminates the JSON serialization/deserialization cycle and reduces JavaScript bundle size by approximately 93% (14KB vs 200KB+). Initial page loads are faster since the server sends complete HTML. However, every interaction requires a server round-trip, so highly interactive applications with complex client-side state might feel slower. The pattern excels for content-heavy sites where reads vastly outnumber writes. Use database connection pooling and caching strategies to optimize server response times.

Absolutely. Start by creating custom controllers alongside your existing REST endpoints. Add HTMX to specific pages that benefit from progressive enhancement: inline editing forms, live search, infinite scroll, while keeping other areas unchanged. The Strapi admin continues working normally since it's a separate application. Migrate features incrementally based on which patterns benefit most from server-driven rendering versus client-side state management.