Skip to Content

htnx: a htmx-like experience in Next.js (seriously)

Okay, I did it; this probably broke every that htmx has, and it's hard to defend myself… So why did I do this? For fun! Guess what? Coding can be fun, and doing things can be entertaining.


This was created mainly as a joke, and I don't even know if it's stable and reliable enough to be used in production, so please don't do that. If you want to use htmx, use htmx, it's a great project, and I'm sure it's way more reliable than this.


Over the past months I've heard a lot about htmx and decided to look at it, and it feels magical. In a sea of complexity and maximalism, something as simple as HTML on... steroids? It sounds like a fantastic new world.

And after that, of course, I had to try it, and it's awesome and a really good experience, but while reading about it, me being a React kind of guy couldn't stop thinking about RSC, and how strangely enough in my mind those things could work really well together . So guess what?

Let's try this!

I never actually read about returning components from React Server Actions on Next.js, but it definitely is possible, as someone pointed out on Twitter some months prior , so what's then? What else do we need to do?


To be usable, those things cannot only return something; what do you do with that then? Append the document, console.log em?

We need to "swap!"

swap is a property described by the htmx docs as:

"htmx offers a few different ways to swap the HTML returned into the DOM. By default, the content replaces the innerHTML of the target element."

So, these are some of the possible values.

We can achieve this with conditionals because in React Land we don't believe in changing the DOM; the invisible framework hand does that, so here, with some conditionals and sweet JS syntax sugar, we can achieve these properties. Here's how it looks:

if (!swap) return createElement(element, { ...actionOn, ...props }, CHILDREN)
if (swap === "outerHTML")
    children || createElement(element, { ...actionOn, ...props }, CHILDREN)
return createElement(element, { ...actionOn, ...props }, children || CHILDREN)

Here I took a kind of artistic liberty and decided to make the default value of swap to be none so you can just call actions without having to worry about it.

That's cool and all, but then, when something gets called or invoked?

Enters "trigger!"

As the htmx docs say:

This beeing:

This can be simply achieved for us with a variable event object spread on the final createObject prop, like this:

const actionOn = {
  click: { onClick: action },
  mouseenter: { onMouseEnter: action },
  submit: { action } // For forms we use the default action
// ...
return createElement(element, { ...actionOn, ...props }, CHILDREN)

This is what will make your markup do the things you expect. But now, what if we need something else to change?

"target" is here!

This is where we take a bit of a different path, and instead of using a CSS selector, we use refs to target the element we want to change. This is more of a "React" way of doing things, and it's also more reliable, so here's how it looks:

if (target && swap) {
  const doIt = async (e: Event<T>) => {
    try {
      return target.current![swap] = renderToStaticMarkup(await action(e))
    } catch (error) {
      alert("Target element not found!")
  // ...

Just to point out, the "CSS selector" style can be achieved in React, but this one was too dirty even for me in this project.

Overall structure

After that, I think we can call it a usable htmx like prototype. Now let's just give it the final touches and make the experience a bit more enjoyable.


With the advent of React 18 and the new hooks to deal with Server Actions, we now have useTransition a super simple way to deal with loading states while we wait for the server to respond, so we can now have a loading indicator for our actions.

const[isPending, startTransition]=useTransition()
if(isPending && indicator)
  return createElement(element,{ disabled:true,...props }, indicator)
const actionFn = async (e: Event<T>) =>
  startTransition(async () => setChildren(await action(e)))

With these simple tweaks, we can now call actionFn(...) instead of action(...) and as simple as that, now we have a loading state, and we can show any arbitrary component we want; in this case, we expose the prop, so you can pass any component you want to show while the action is pending.


This is a bit of a tricky one; there were two routes to go with the API: we could have HTNX as an object and use it like framer-motion does, with HTMX.button and go on. but this here would cause too much code duplication.

So I decided to use a bit more of what React gives us from their API and use types as guardrails; that way, we have a unique component that is shaped by the decisions you make. Want a button? Just pass element={"button"} and the types will adapt to that. Here's a bit of how this typing looks:

type Swap = "outerHTML" | "innerHTML"
type TriggerBase = "click" | "mouseenter"
type Trigger<T> = T extends "form" ? "submit" | TriggerBase : TriggerBase
type Event<T> = T extends "form" ? FormData : SyntheticEvent
type Props<T extends keyof ReactHTML> = Omit<ComponentProps<T>, "action" | "target"> & {
  element: T
  swap?: Swap
  trigger?: Trigger<T>
  target?: RefObject<HTMLElement>
  indicator?: string | JSX.Element
  action?: (e: Event<T>, ...props: any) => Promise<JSX.Element>
export const HTNX = <T extends keyof ReactHTML>({ element, children: CHILDREN, trigger = "click", swap, target, action, indicator, ...props }: Props<T>) => {
  // ...

All of those are based mainly on ReactHTML and ComponentProps which provide great types for the HTML elements and can be extended to fit our needs and index each other.

Overall, we have a bit of trickery around the component, just to make everything fit in nicely, but that's the gist of it.


Now that we have everything in place, let's see how it looks in practice.

import { HTNX } from "htnx"
const Button = () => (
    Get a random color!
const Button = () => (
    <input name="pass"type="password" />
    <button type="submit">Send</button>

And just to scratch that itch on every React developer who hasn't seen how to use server action to return React components, here's how those look:

"use server"
export const random = async () => {
  const color = `#${[...Array(6)]
    .map(() => Math.floor(Math.random() * 16).toString(16))
  return (
      <span style={{ background: color }} />
export const save = async (data: FormData) => (
  <span className="[&_span]:font-black [&_span]:italic">
    Oh, what a coincidence! I use <span>{data.get("pass")?.toString()}</span> too...

Aren't those beautiful? I think they are.


Okay so... This was a lenthly one, but I think it was worth it. I had a lot of fun doing this, and I think it's a pretty good example of how you can use React to do some pretty cool stuff. Not everything needs to be production-ready, and not everything needs to be perfect. Sometimes you just need to have fun and do things.


I don't know if I mentioned it, but I deployed a live version of this demo, and I know I'm biased, but it's looking sick! Check it out!

I hope you enjoyed this one, and if you have any questions or suggestions, please let me know on Twitter @rafaelrcamargo or on the htnx repo.

Access to your Tailwind theme on the go!

Get notified!

I promise to send you updates with the best content.
I hate writing emails as much as you hate spam.


A dead-simple React 19 "framework" implementation from scratch



Think "docker stats" but with beautiful, real-time charts into your terminal. 📊



a htmx like experience in Next.js with RSC and quite a bit of questionable code.



Supercharge Your VTEX IO Workflow with a Lightning-Fast CLI Alternative. Enhance Developer Experience and Boost Productivity.



Uma stack sob-medida escrita em Rust para os desafios da rinha e somente isso. Incluindo load balancer, servidor HTTP e banco de dados.



Expose specific colors from your Tailwind CSS theme as CSS variables

CC BY-NC 4.0©rafaelrcamargo.