</> htmx
🚧 htmx 4.0 is under construction. Read changes →

Extension Migration Guide (htmx 2.x → 4.x)

This guide helps you migrate extensions from htmx 2.x to htmx 4.x.

Overview of Changes

htmx 4 replaces the callback-based extension API with an event-based hook system. Instead of implementing specific callback methods, extensions now register handlers for lifecycle events.

Callback Migration Map

init(api)

htmx 2.x:

init: function(api) {
    // Initialize extension
    return null;
}

htmx 4:

init: ((internalAPI) => {
    // Store API reference for later use
    api = internalAPI;
});

Notes:


getSelectors()

htmx 2.x:

getSelectors: function() {
    return ['.my-selector', '[my-attr]'];
}

htmx 4:

Migration approach:

htmx_after_init: ((elt) => {
    // Check if element has your attribute
    let value = api.attributeValue(elt, "hx-my-attr");
    if (value) {
        // Initialize for this element
    }
});

Real-world example:

// htmx 2.x - Custom extension
getSelectors: function() {
    return ['[my-custom-attr]', '[data-my-custom-attr]'];
},

onEvent: function(name, evt) {
    if (name === 'htmx:afterProcessNode') {
        initializeCustomBehavior(evt.target);
    }
}

// htmx 4 - No getSelectors needed
htmx_after_init: (elt) => {
    // Check for custom attribute
    if (api.attributeValue(elt, 'my-custom-attr')) {
        initializeCustomBehavior(elt);
    }
}

Notes:


onEvent(name, evt)

htmx 2.x:

onEvent: function(name, evt) {
    if (name === "htmx:beforeRequest") {
        // Handle event
    }
    return true; // Continue
}

htmx 4:

Migration approach:

htmx_before_request: (elt, detail) => {
    // Handle before request
    return true; // or false to cancel
},

htmx_after_swap: (elt, detail) => {
    // Handle after swap
}

Real-world examples:

Debug extension (logs all events):

// htmx 2.x
onEvent: function(name, evt) {
    console.debug(name, evt);
}

// htmx 4 - Need to implement each hook individually
htmx_before_request: (elt, detail) => console.debug('htmx:before:request', detail),
htmx_after_request: (elt, detail) => console.debug('htmx:after:request', detail),
// ... etc for each event you want to log

Response-targets extension (handles non-200 responses):

// htmx 2.x
onEvent: function(name, evt) {
    if (name === 'htmx:beforeSwap' && evt.detail.xhr.status !== 200) {
        var target = getRespCodeTarget(evt.detail.requestConfig.elt, evt.detail.xhr.status);
        if (target) {
            evt.detail.shouldSwap = true;
            evt.detail.target = target;
        }
    }
}

// htmx 4
htmx_before_swap: (elt, detail) => {
    if (detail.ctx.response.status !== 200) {
        var target = getRespCodeTarget(elt, detail.ctx.response.status);
        if (target) {
            detail.ctx.target = target;
        }
    }
}

Common event mappings:


transformResponse(text, xhr, elt)

htmx 2.x:

transformResponse: function(text, xhr, elt) {
    // Transform response text
    return modifiedText;
}

htmx 4:

Migration approach:

htmx_after_request: ((elt, detail) => {
    // Skip if SSE (ctx.text not set for SSE responses)
    if (!detail.ctx.text) return;

    // Transform the response text
    detail.ctx.text = transformText(detail.ctx.text);
});

Real-world example (client-side-templates extension):

// htmx 2.x
transformResponse: function(text, xhr, elt) {
    var mustacheTemplate = htmx.closest(elt, '[mustache-template]');
    if (mustacheTemplate) {
        var data = JSON.parse(text);
        var templateId = mustacheTemplate.getAttribute('mustache-template');
        var template = htmx.find('#' + templateId);
        return Mustache.render(template.innerHTML, data);
    }
    return text;
}

// htmx 4
htmx_after_request: (elt, detail) => {
    // Skip if SSE (ctx.text not set for SSE responses)
    if (!detail.ctx.text) return;
    
    var mustacheTemplate = elt.closest('[mustache-template]');
    if (mustacheTemplate) {
        var data = JSON.parse(detail.ctx.text);
        var templateId = mustacheTemplate.getAttribute('mustache-template');
        var template = document.querySelector('#' + templateId);
        detail.ctx.text = Mustache.render(template.innerHTML, data);
    }
}

Important Notes:


isInlineSwap(swapStyle)

htmx 2.x:

isInlineSwap: function(swapStyle) {
    return swapStyle === 'my-custom-swap';
}

htmx 4:

Important for OOB swaps:

In htmx 2.x, isInlineSwap was used to prevent automatic stripping of wrapper elements for custom outer swap styles. In htmx 4, OOB swaps automatically strip the wrapper element for non-outer swap styles (those not starting with “outer”).

If your custom swap style needs the wrapper element:

Option 1: Name your swap style starting with “outer” (e.g., outerMorph, outerCustom)

Option 2: Use detail.unstripped to access the original fragment:

htmx_handle_swap: (target, detail) => {
    if (detail.swapSpec.style === 'my-outer-swap') {
        // For OOB swaps, use unstripped if available
        let frag = (detail.type === 'oob' && detail.unstripped) || detail.fragment;
        target.parentNode.replaceChild(frag.firstElementChild, target);
        return true;
    }
    return false;
}

Notes:


handleSwap(swapStyle, target, fragment, settleInfo)

htmx 2.x:

handleSwap: function(swapStyle, target, fragment, settleInfo) {
    if (swapStyle === 'my-swap') {
        target.appendChild(fragment);
        return true; // Handled
    }
    return false; // Not handled
}

htmx 4:

htmx_handle_swap: ((target, detail) => {
    let { swapSpec, fragment } = detail;
    if (swapSpec.style === "my-swap") {
        target.appendChild(fragment);
        return true; // Handled
    }
    return false; // Not handled
});

Real-world example (morphdom-swap extension):

// htmx 2.x
isInlineSwap: function(swapStyle) {
    return swapStyle === 'morphdom';
},

handleSwap: function(swapStyle, target, fragment) {
    if (swapStyle === 'morphdom') {
        if (fragment.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
            morphdom(target, fragment.firstElementChild || fragment.firstChild);
            return [target];
        } else {
            morphdom(target, fragment.outerHTML);
            return [target];
        }
    }
}

// htmx 4
htmx_handle_swap: (target, detail) => {
    if (detail.swapSpec.style === 'morphdom') {
        if (detail.fragment.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
            morphdom(target, detail.fragment.firstElementChild || detail.fragment.firstChild);
        } else {
            morphdom(target, detail.fragment.outerHTML);
        }
        return true;
    }
    return false;
}

Notes:


encodeParameters(xhr, parameters, elt)

htmx 2.x:

encodeParameters: function(xhr, parameters, elt) {
    // Encode parameters
    return JSON.stringify(parameters);
}

htmx 4:

Migration approach:

htmx_config_request: ((elt, detail) => {
    // Convert FormData to JSON
    let data = Object.fromEntries(detail.ctx.request.body);
    detail.ctx.request.body = JSON.stringify(data);
    detail.ctx.request.headers["Content-Type"] = "application/json";
});

Real-world example (json-enc extension):

// htmx 2.x
onEvent: function(name, evt) {
    if (name === 'htmx:configRequest') {
        evt.detail.headers['Content-Type'] = 'application/json';
    }
},

encodeParameters: function(xhr, parameters, elt) {
    const object = {};
    parameters.forEach(function(value, key) {
        if (Object.hasOwn(object, key)) {
            if (!Array.isArray(object[key])) {
                object[key] = [object[key]];
            }
            object[key].push(value);
        } else {
            object[key] = value;
        }
    });
    return JSON.stringify(object);
}

// htmx 4
htmx_config_request: (elt, detail) => {
    detail.ctx.request.headers['Content-Type'] = 'application/json';
    
    const object = {};
    detail.ctx.request.body.forEach(function(value, key) {
        if (Object.hasOwn(object, key)) {
            if (!Array.isArray(object[key])) {
                object[key] = [object[key]];
            }
            object[key].push(value);
        } else {
            object[key] = value;
        }
    });
    detail.ctx.request.body = JSON.stringify(object);
}

Notes:

Key Differences Summary

  1. Event-based hooks instead of single onEvent callback
  2. Underscores in hook names (not colons)
  3. Extension approval required via meta tag
  4. Detail object contains full context (detail.ctx)
  5. Internal API provided via init hook
  6. No getSelectors() - use element-level hooks instead
  7. Direct modification of request/response via detail.ctx

Additional Resources