</> 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. No hx-ext — extensions load by including the script (config whitelist is optional)
  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