Small and Speedy: Lightning fast search with Flask, htmx and Airtable (No React required).

A step-by-step tutorial on building high-performance search without relying on heavy Javascript libraries like React.

Daniel Easterman
Level Up Coding

--

Cheetah cub sprinting through arid grasslands.
Image generated with Midjourney

Let’s not beat around the bush — htmx is hot right now. And it’s evoking strong reactions. The Internet is of course a pretty polarising place and the response to htmx has been no exception.

The memes and humorous comebacks by htmx’s creator on X / Twitter have also contributed to the extra interest in the library. It seems like you either love or hate htmx, and I’m firmly in the love camp.

But don’t take my word for it. Let’s go beyond the social media hype and actually build something. In this tutorial we will use Flask, htmx and Airtable to create a fast, asynchronous search feature which will update our content with no page refresh.

In fact, we’re going to build the whole thing without writing a single line of JavaScript. Let’s get into it!

Click on the links below to jump between the 2 main sections of this tutorial:

  1. The Backend
  2. The Frontend

Assumptions:

Our first prerequisite is having an Airtable database already setup and ready-to-go, which I outlined in my earlier guide on Flask and Airtable. If you prefer not to use Airtable you can adapt the backend section to work with your database of choice.

I also assume here that you’ve added the htmx library to your Flask project. If not, you can follow the install instructions in the official docs here.

Some of my previous guides on Flask also provide lots of useful background which work well with this tutorial:

  1. Deploy your Flask app on Render’s production environment.
  2. Build your Flask frontend with Jinja templates and HTML.
  3. Create smooth content filtering with Flask and htmx

The Backend

The first step in building our Airtable-powered search feature is to construct a custom search formula. The logic for this formula will be encapsulated in a function called search_formula which we’ll add to the Flask project's app.py:


from flask import request
from pyairtable.formulas import FIND, FIELD, LOWER, STR_VALUE, OR

. . .

def search_formula():
query = request.args.get('search')
title_query = FIND(LOWER(STR_VALUE(query)), LOWER(FIELD('Title')))
location_query = FIND(LOWER(STR_VALUE(query)), LOWER(FIELD('Location')))
description_query = FIND(LOWER(STR_VALUE(query)), LOWER(FIELD('Description')))
full_query = OR(title_query, location_query, description_query)
return full_query

On the first line of the search_formula function, we use the request object from Flask to get the value of the search query parameter. (This is the value the user enters when they start typing a search query.)

Next we need to use the FIND , STR_VALUE , LOWER and FIELD functions from the pyairtable library to build-out our formula.

  • FIND takes in 2 parameters: 1.) what string to search for, and 2.) where to search for it — in this case which column or field in our Airtable database.
  • We will then use the LOWER and STR_VALUE functions to ensure that the search query is lowercase and wrap it in quotes so it is “understood” as a string. We need to apply LOWER to both the “what” and “where” parts of the FIND statement as the search is case sensitive.
  • Lastly to ensure that the user’s search query is “looking” in all our Airtable fields we use the OR function to combine all the queries in one final full_query.

Now all that’s left to do in the backend is create a separate function called run_search . This function is responsible for rendering the template using the search formula we created:

@app.route("/search/", methods=["GET"])
def run_search():
search_records = TABLE.all(formula=search_formula())
return render_template("portfolio.html", listings=search_records)

One thing to note in the code above is that we are only want to return the portfolio.html template partial. (In the other functions from previous tutorials we returned the full base.html template).

The Frontend

For our new search feature to work correctly we need to do a little reorganisation of our frontend templates.

First we will changebase.html so that it includes a new body.html after the hero:

<!DOCTYPE html>
<html>
{% include 'head.html' %}
<body>
{% include 'nav.html' %}
{% include 'hero.html' %}
{% include 'body.html' %}
{% include 'footer.html' %}
</body>
</html>

Now we will create a new body.html file in our project’s templates folder which includes a few sub-components such as the categories.html and portfolio.html template partials:

<h2 class="section_title">Recent Work</h2>

<div class="portfolio_wrapper">

{% with cat_url=cat_url %} {% include 'categories.html' %} {% endwith %}

{% include 'search.html' %}

<div class="htmx-indicator loading_spinner"></div>

{% include 'portfolio.html' %}

</div>

Note: we also have a new search.html component, so let’s go ahead and add the code for that using some special hx- attributes provided by htmx:

<form class="search_form" method="get">
<div class="input_wrapper">
<i class="fas fa-search search-icon"></i>
<input class="input_search" type="search"
name="search" placeholder="Search"
maxlength="50" id="id_q"
hx-get="/search"
hx-trigger="keyup changed delay:500ms, search"
hx-target="#listings"
hx-indicator=".loading_spinner">
</div>
</form>

In addition to the normal <form> and <input> html elements we would expect in a search form, now we have htmx’s hx-get attribute which specifies the asynchronous request to the search route we declared earlier in app.py (above).

The hx-trigger attribute with keyupmeans that a request will be triggered every time the user types on their keyboard with a standard 500 milisecond delay.

Lastly hx-target tells htmx to insert the new content generated by the search inside the listings id element, and hx-indicator shows the loading spinner while the search GET request is being processed.

Now that the main backend and frontend functionality is complete, we can add some styling for our search component. Create a new css file called search.css and it to our css folder:

.search_form {
margin-bottom: 4rem;
display: flex;
justify-content: center;
}

.input_wrapper { position: relative; }

form .input_search {
font-size: 1.26rem;
font-family: "Poppins", sans-serif;
height: auto;
padding: 12px 12px 12px 55px;
cursor: auto;
border: 2px solid rgba(144,146,148,0.2);
border-radius: 3px;
outline-width: 2px;
outline-color: #5D16A5;
width: 100%;
}

.input_wrapper svg {
color: #5D16A5;
left: 26px;
top: 21px;
position: absolute;
font-size: 18px;
}

/* Desktop */
@media screen and (min-width: 700px) {
form .input_search { width: 500px; }
}

Then we simply need to “register” css/search.css inside the css_bundle in app.py (after css/categories.css):

...

css_bundle = Bundle('css/globals.css',
'css/nav.css',
'css/hero.css',
'css/categories.css',
'css/search.css',
'css/portfolio.css',
'css/footer.css',
filters='cssmin', output='css/styles.css')
...

(The full pattern and explanation for organising your css like this can be found in my previous tutorial on Flask and CSS.)

We can now get an idea of what the final search feature will look like in action with the GIF below (Remember, the real thing will be a lot smoother than what a GIF can reproduce!)

That’s it! — Congratulations you’ve reached the end of the tutorial, thanks so much for reading!

If you find content like this useful, feel free to follow my Medium account. You can also click the mail icon next to the Follow button to get notified by email every time I publish a new article.

--

--

Technical Writer and Software Developer. Get new Python and Flask tutorials straight to your inbox here: https://bit.ly/dan-python