I recently built Sqlite Opus, a small web-based SQLite browser that plugs into Flask. The UI is a single-page dashboard where you can click around to explore the current database. At first, I used fetch calls with Flask partials, but later switched to HTMX to reduce JavaScript while keeping the same interactive feel.
What is HTMX? A tiny JS lib: you put attributes like hx-get, hx-post, hx-target on your HTML, and it does the AJAX + DOM swap for you. Server returns HTML chunks; no fetch() + JSON + manual DOM updates. Nice for stuff like dashboards and admin UIs where the server already renders the page—you just want “click this, replace that” without a fat SPA.
In this post, I’ll walk through how I use HTMX in this project.
Where HTMX fits in this project
I use HTMX for three main flows:
- Load table info — Click a table name → load schema, columns, and indexes without a full page reload.
- Execute query — Submit the query form → replace the results area with the new table (or error message).
- Pagination — Click a page number → request the same query for another page and swap the results area.
How HTMX is used
1. Load table (schema, columns, indexes)
Where: Sidebar “Tables” list in index.html. Each table name is a clickable element.
Behavior: On click, HTMX sends a GET request to load that table’s metadata. The server returns one HTML response that contains three fragments. HTMX swaps each fragment into the matching element by id, so all three panels update from a single request.
Markup (concept):
<div class="table-item"
data-table-name="{{ table }}"
title="Click to view schema"
hx-get="{{ url_for(blueprint_name ~ '.get_table_info_partial', table_name=table) }}"
hx-trigger="click"
hx-target="body"
hx-swap="none">
<i class="fas fa-table"></i> {{ table }}
</div>
-
hx-get— URL:GET /api/table/<table_name>/ -
hx-trigger="click"— Request is sent when the user clicks the table name. -
hx-target="body"andhx-swap="none"— The main response body is not swapped anywhere; only 3 fragments are applied.
Server: The route get_table_info_partial renders partials/table_info.html, which outputs three divs with hx-swap-oob="true" and these ids:
table-columns-containertable-indexes-containertable-schema-container
So one response updates all three panels. JavaScript listens for htmx:beforeRequest and htmx:afterSettle to show a loading state, switch to the “Table Schema” tab, and prefill the query editor with SELECT * FROM <table>; for convenience.
2. Execute query
Where: Query editor form in index.html (textarea + Execute button).
Behavior: Submitting the form sends a POST with the SQL and pagination parameters. The server runs the query and returns the query results partial (the results table plus the pagination bar). HTMX replaces the contents of the results area with that HTML.
Markup (concept):
<form id="query-form"
hx-post="{{ url_for(blueprint_name ~ '.execute_query') }}"
hx-target="#query-results-area"
hx-swap="innerHTML">
<textarea id="query-editor" name="query" ...></textarea>
<input type="hidden" name="page" value="1">
<input type="hidden" name="per_page" value="20">
<button type="submit" id="execute-btn" ...>Execute Query</button>
<button type="button" id="clear-btn" ...>Clear</button>
</form>
-
hx-post— URL:POST /api/query/ -
hx-target="#query-results-area"— Response replaces the inner HTML of the results container. -
hx-swap="innerHTML"— The new results table and pagination bar are inserted inside that container.
Server: The execute_query route reads query, page, and per_page from the form data. It runs the query (with pagination for SELECTs), then renders partials/query_results.html and returns that HTML.
Error handling: If the user submits an empty query, we don’t send the request: a htmx:beforeRequest handler checks the form and the textarea, calls preventDefault(), and shows a “Please enter a query” message in the top status banner. For other errors, an htmx:responseError handler shows the error in the same banner.
3. Pagination
Where: Pagination bar inside partials/query_results.html. It is rendered only when there is more than one page.
Behavior: Each page number, plus Prev and Next, is a submit button inside a form. Submitting sends the same query and per_page with a new page value. The server runs the query for that page and returns the same query_results.html partial; HTMX swaps it into the same #query-results-area container.
Markup (concept):
<form hx-post="{{ url_for(blueprint_name ~ '.execute_query') }}"
hx-target="#query-results-area"
hx-swap="innerHTML">
<input type="hidden" name="query" value="{{ current_query }}">
<input type="hidden" name="per_page" value="{{ pagination.per_page }}">
<!-- Prev -->
<button type="submit" name="page" value="{{ pagination.page - 1 }}" ...>Prev</button>
<!-- Page numbers -->
<button type="submit" name="page" value="{{ i }}" ...>{{ i }}</button>
<!-- Next -->
<button type="submit" name="page" value="{{ pagination.page + 1 }}" ...>Next</button>
</form>
-
current_query— Passed from the route into the template so the pagination form always re-sends the last executed query. -
name="page"— Each button submits a differentpagevalue; the server uses it forLIMIT/OFFSET(or equivalent).
Server: The same execute_query route handles both the initial run and pagination. When the request comes from this form, query and per_page are in the form data, and page is the value of the clicked button. The route runs the query for that page and returns the updated query_results.html partial.
You can see detail implement in the repo: https://github.com/hungle00/sqlite-opus

Top comments (0)