npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@formbox/htmx

v0.7.0

Published

Server-rendered HTMX HTML renderer for Formbox FHIR Questionnaires

Downloads

1,246

Readme

@formbox/htmx

Server-rendered HTML renderer for Formbox FHIR Questionnaires.

This package renders FHIR Questionnaire controls as plain HTML that can be posted with normal browser form submission or HTMX. Consumers do not import React, hydrate React, or manage MobX.

The main API is built around a short-lived renderer instance:

import { QuestionnaireRenderer, loadDefaultTemplates } from "@formbox/htmx";

const route = "/questionnaire";
const templates = await loadDefaultTemplates();
const renderer = new QuestionnaireRenderer({
  token: "encounter-questionnaire",
  templates,
  questionnaire,
  fhirVersion: "r5",
  questionnaireResponse: draftResponse,
  action: route,
});

try {
  const result =
    request.method === "POST"
      ? await renderer.process(await request.formData())
      : { submitted: false as const };

  const html = await renderer.render();
  const response = renderer.getQuestionnaireResponse();

  if (result.submitted && result.valid) {
    await saveResponse(response);
  }
} finally {
  renderer.dispose();
}

renderer.render() returns a complete questionnaire form by default. It is async because renderer-owned ValueSet expansions can require terminology requests before the final HTML is usable. Your application owns the route, layout, authentication, CSRF handling, draft persistence, and response storage. Pass the route as action so the renderer can generate the form attributes.

Generated controls use normal bracket-style field names such as fb[answer][patient-name][value], plus fb[count][...], fb[action], and fb[page] for repeat and pagination state. The core renderer passes raw questionnaire paths into the theme layer; @formbox/htmx owns this submitted field encoding and parses the submitted payload in renderer.process().

Basic Integration

import { QuestionnaireRenderer, loadDefaultTemplates } from "@formbox/htmx";

const templates = await loadDefaultTemplates();

async function renderQuestionnaire(request: Request): Promise<Response> {
  const route = "/questionnaire";
  const draftResponse = await loadDraftResponse();
  const renderer = new QuestionnaireRenderer({
    token: "encounter-questionnaire",
    templates,
    questionnaire,
    fhirVersion: "r5",
    questionnaireResponse: draftResponse,
    action: route,
  });

  try {
    const result =
      request.method === "POST"
        ? await renderer.process(await request.formData())
        : { submitted: false as const };

    const form = await renderer.render();

    if (result.submitted && result.valid) {
      await saveResponse(renderer.getQuestionnaireResponse());
    }

    return html(layout(form));
  } finally {
    renderer.dispose();
  }
}

HTMX Integration

The renderer does not own your page shell. If a POST should update more than the form itself, such as a status label or a rendered QuestionnaireResponse preview beside the form, target an application-owned wrapper and return that same wrapper for HTMX requests.

For dynamic questionnaires, prefer morphdom swaps so focus and nearby DOM state survive server-rendered updates better than plain outerHTML replacement:

import {
  compileTemplates,
  loadDefaultTemplates,
  loadTemplates,
} from "@formbox/htmx";

const templates = {
  ...(await loadDefaultTemplates()),
  ...(await loadTemplates("./questionnaire-templates")),
  ...compileTemplates({
    Form: `
      <form{{{attrs attributes}}} hx-target="#questionnaire-app" hx-swap="morphdom">
        {{{hiddenFields}}}
        {{{shortTextStyle}}}
        {{{titleHtml}}}
        {{{descriptionHtml}}}
        {{{languageSelector}}}
        {{{errors}}}
        {{{before}}}
        {{{children}}}
        {{{after}}}
        {{{signature}}}
        {{{paginationHtml}}}
        {{{submitButton}}}
      </form>
    `,
  }),
};

async function handler(request: Request): Promise<Response> {
  const rendered = await renderQuestionnaire(request);
  const body =
    request.headers.get("hx-request") === "true"
      ? questionnaireApp(rendered)
      : layout(questionnaireApp(rendered));

  return html(body);
}

function questionnaireApp(rendered: {
  form: string;
  response: unknown;
}): string {
  return `
    <div id="questionnaire-app">
      ${rendered.form}
      <pre>${JSON.stringify(rendered.response, null, 2)}</pre>
    </div>
  `;
}

Include the HTMX morphdom extension in the application shell:

<script src="https://unpkg.com/[email protected]/dist/morphdom-umd.min.js"></script>
<script src="https://unpkg.com/[email protected]/morphdom-swap.js"></script>
<body hx-ext="morphdom-swap">
  <div id="questionnaire-app">...</div>
</body>

Use the default form swap when the form is the only dynamic region. Use an application wrapper when the result of renderer.process(formData) affects nearby UI outside the form.

Lifecycle

Create one renderer instance for one request/render cycle:

  1. new QuestionnaireRenderer(options) creates a request-local renderer.
  2. await renderer.process(formData) applies submitted form state and returns submit result.
  3. await renderer.render() returns form HTML for the current renderer state.
  4. renderer.getQuestionnaireResponse() reads the current FHIR response.
  5. renderer.dispose() releases the underlying renderer store.

Do not keep a renderer instance in a session or reuse it between requests. Persist the QuestionnaireResponse instead, then pass it to the next constructor call as questionnaireResponse.

API

new QuestionnaireRenderer(options)

Creates a request-local renderer instance.

const renderer = new QuestionnaireRenderer({
  token: "encounter-questionnaire",
  questionnaire,
  fhirVersion: "r5",
  action,
  questionnaireResponse,
  language,
  strings,
  terminologyServerUrl,
  launchContext,
  mode,
  customExtensions,
  templates,
});

questionnaireResponse is optional initial state. token is required and must be unique for each rendered form on the same page. templates is required; call await loadDefaultTemplates() for the package defaults, or merge those templates with application overrides.

renderer.process(formData)

Applies a submitted form payload to the renderer.

It handles:

  • scalar values and complex FHIR answer values
  • repeated question and group add/remove actions
  • pagination actions
  • enableWhen and expression recomputation through the renderer store
  • validation when the submitted action is submit

The generated fields include hidden state needed to preserve off-page and hidden answers across full-form posts.

It returns a promise:

type ProcessResult = { submitted: false } | { submitted: true; valid: boolean };

submitted is true only for the final submit action. valid is present only when submit validation ran.

renderer.render()

Returns a promise for rendered questionnaire HTML. The default Form template includes a surrounding <form>, hidden state, rendered controls, repeat/pagination action buttons, validation messages, and the default submit button.

With the default Form template, pass action so the renderer can generate the required form attributes:

const renderer = new QuestionnaireRenderer({
  token: "encounter-questionnaire",
  templates,
  questionnaire,
  fhirVersion: "r5",
  action: route,
});

const html = await renderer.render();

Use a custom Form template when the application needs to add shell markup or adjust attributes:

import { compileTemplates, loadDefaultTemplates } from "@formbox/htmx";

const templates = {
  ...(await loadDefaultTemplates()),
  ...compileTemplates({
    Form: `
      <form{{{attrs attributes}}}>
        ${csrf}
        {{{hiddenFields}}}
        {{{shortTextStyle}}}
        {{{titleHtml}}}
        {{{descriptionHtml}}}
        {{{languageSelector}}}
        {{{errors}}}
        {{{before}}}
        {{{children}}}
        {{{after}}}
        {{{signature}}}
        {{{paginationHtml}}}
        {{{submitButton}}}
      </form>
    `,
  }),
};

const renderer = new QuestionnaireRenderer({
  token: "encounter-questionnaire",
  questionnaire,
  fhirVersion: "r5",
  action: route,
  templates,
});

const html = await renderer.render();

Templates

The renderer consumes callback templates:

type Template<T> = (properties: T) => string;

For simple markup customization, use Handlebars strings and compile them into the same callback interface:

import { compileTemplates } from "@formbox/htmx";

const templates = compileTemplates({
  TextInput: `
    <input
      {{{fieldAttributes}}}
      {{{attr "id" id}}}
      {{{attr "type" type}}}
      {{{attr "value" value}}}
      {{#if disabled}}readonly{{/if}}
    >
  `,
});

Callbacks and Handlebars strings can be mixed:

const templates = compileTemplates({
  TextInput: `<input{{{fieldAttributes}}}{{{attr "id" id}}}>`,
  Form(properties) {
    return `<form${htmlAttributes(properties.attributes)}>${[
      properties.hiddenFields,
      properties.shortTextStyle,
      properties.titleHtml ?? "",
      properties.descriptionHtml ?? "",
      properties.languageSelector ?? "",
      properties.errors ?? "",
      properties.before ?? "",
      properties.children,
      properties.after ?? "",
      properties.signature ?? "",
      properties.paginationHtml ?? "",
      properties.submitButton,
    ].join("")}</form>`;
  },
});

Load the default templates explicitly:

import { loadDefaultTemplates } from "@formbox/htmx";

const templates = await loadDefaultTemplates();

If you prefer overrides as files, load a directory of *.html.hbs files and merge the result with the default templates:

import { loadDefaultTemplates, loadTemplates } from "@formbox/htmx";

const templates = {
  ...(await loadDefaultTemplates()),
  ...(await loadTemplates("./questionnaire-templates")),
};

File names map to template names:

questionnaire-templates/
  Form.html.hbs
  TextInput.html.hbs
  SelectInput.html.hbs

Unknown template file names throw, so misspellings fail early.

Available Handlebars helpers:

  • {{{attr "name" value}}} renders one escaped HTML attribute, including the leading space.
  • {{{attrs attributes}}} renders an object of escaped HTML attributes.
  • {{{fieldAttributes}}} renders data-fb-link-id, data-fb-field, name, and hx-include for the current field.

Use triple braces for renderer-provided HTML slots such as hiddenFields, children, paginationHtml, submitButton, label, errors, and customOptionForm.

Default templates and user templates use the same data shape. The package does not keep a separate JSX fallback path.

renderer.getQuestionnaireResponse()

Returns the current FHIR QuestionnaireResponse.

Disabled-by-enableWhen items are omitted from the response. Hidden-but-enabled values and protected read-only values are preserved through generated hidden fields where needed.

renderer.dispose()

Disposes the underlying renderer store. Call this in a finally block.

Bun Demo

Run the package demo with Bun:

cd packages/htmx
bun run demo

The demo server uses Bun.serve, returns an application-owned layout through the shared render helper, loads the morphdom HTMX extension from a CDN, and posts full form payloads back through await renderer.process(formData).

Notes

  • The server remains stateless. Recreate form state from a submitted QuestionnaireResponse plus FormData.
  • HTMX is only a browser transport. The server APIs use standard Request, FormData, HTML strings, and FHIR resources.
  • The package uses @formbox/renderer internally for store behavior, expressions, enableWhen, visibility, validation, and response generation.