SuperGeekery: A blog probably of interest only to nerds by John F Morton.

A blog prob­a­bly of inter­est only to nerds by John F Mor­ton.

Unlocking Real-Time Reactive Search in Craft CMS with Datastar

Vintage illustration depicting a futuristic search interface

The narration of this post was created with Bespoken plugin for Craft CMS.

This post will show you how the search page on this site works and how you can build the same thing your­self in Craft CMS

Please note that the nar­rat­ed ver­sion of this post does not con­tain a spo­ken ver­sion of the code sam­ples, but you can view those on the orig­i­nal blog post.

What makes the search page inter­est­ing enough for a long blog post? The short answer is that it func­tions like the search you’d expect to be built with a big fron­tend frame­work like React, Vue, or Svelte. Instead, it’s built with a tiny 10.7kb javascript library called Datas­tar. The Datas­tar plu­g­in for Craft CMS makes using Datas­tar even eas­i­er. If you’re a Craft devel­op­er, it’s a no-brain­er addi­tion to your toolk­it. (This post has been updat­ed to cov­er Datas­tar v1.)

The search on this site is a reac­tive search,” mean­ing that as you type, the search results are updat­ed on the page with­out a page load. 

Also, each search is book­mark­able. For exam­ple, this link will show you entries on this site for craft cms.” The page also ful­ly sup­ports using your browser’s back and for­ward but­tons. See this for your­self by click­ing this link to see blog posts men­tion­ing font sans”. Then use your delete key to remove the word sans” from the search field. You should see many more results show up. Then click the back but­ton in your brows­er, and you’ll go back through your brows­er his­to­ry to see font sans” results again. It works the way you’d expect it to work. 

To do that takes some work from you as the devel­op­er. I’ll show you how Datas­tar makes this easy. 

Datastar? 

Let’s start with a basic ques­tion: What is Datas­tar?” Let’s look at the Datas­tar get­ting start­ed page.

Datas­tar offers back­end-dri­ven reac­tiv­i­ty like htmx, and fron­tend-dri­ven reac­tiv­i­ty like Alpine.js, in a light­weight frame­work that doesn’t require any npm pack­ages or oth­er depen­den­cies.

In oth­er words, you don’t need an over­sized frame­work to build a reac­tive app. (At the end of this post, I’ll touch on how Alpine, a small fron­tend frame­work, dif­fers from Datas­tar.)

Why is it called Datas­tar?” We will use data attrib­ut­es on HTML ele­ments through­out the code below. Data attrib­ut­es in HTML are cus­tom attrib­ut­es you attach to HTML ele­ments using the data- pre­fix. These attrib­ut­es store addi­tion­al infor­ma­tion direct­ly in the markup with­out clut­ter­ing class­es or IDs. In oth­er words, we’re not intro­duc­ing non-stan­dard HTML to our code when using Datas­tar. 

So, the word data” makes sense. What about the star” part of the name? The aster­isk is a wild­card char­ac­ter. If you type ls *.txt on your com­mand line, you’ll list all files end­ing with .txt. An aster­isk char­ac­ter is some­times called a star”. In oth­er words, data-* defines the scope of the Datas­tar lan­guage, hence, the name Datas­tar.

Using Datastar with Craft CMS

We will use the free Datas­tar plu­g­in in the Craft CMS plu­g­in store. If you’re not a Craft devel­op­er, you will like­ly get a feel of how Datas­tar, the JavaScript frame­work itself, works in this post. It might make you inter­est­ed in pick­ing up Craft CMS as well. (Come on in. The water’s fine.)

Datastar key concepts

I want to review two con­cepts that are impor­tant in under­stand­ing Datas­tar. Sig­nals and the con­cept of patch­ing ele­ments.

What’s a signal? 

Sig­nals are the data we’re work­ing with on the page. I ini­tial­ly thought of sig­nals as vari­ables, which is some­what true. How­ev­er, since we’re div­ing into Datas­tar, which uses sig­nals, let’s explore the con­cept of a sig­nal a lit­tle more.

A sig­nal is an object or func­tion that holds a val­ue. That sounds sort of like a vari­able, right? The dif­fer­ence is that a sig­nal has some addi­tion­al behav­ior. When the signal’s val­ue changes, any func­tions or com­po­nents that depend on it are noti­fied so they can update them­selves auto­mat­i­cal­ly. So a sig­nal is a vari­able with extra reac­tive abil­i­ties. 

Patching elements with updated data

Let’s also touch briefly on how you con­trol where sig­nals are dis­played on the page you’re build­ing. Datas­tar, the JavaScript library, has a sec­tion on patch­ing” and the Datas­tar Craft Plu­g­in has a sec­tion on the patchelement Twig tag. Both pieces of doc­u­men­ta­tion are worth read­ing. In basic terms, you cre­ate an ele­ment with a unique ID that will be the des­ti­na­tion for the data returned in Datas­tar’s round-trip to your serv­er. In the exam­ples below, you’ll find that I have cre­at­ed an ele­ment with the ID of searchResults in the search tem­plate. This is the des­ti­na­tion of the data, a blob of HTML, that will be returned from the serv­er. This con­cept should become clear­er as we work our way through the post.

Thank you

Before we go fur­ther, I want to thank Ben Cro­ker for review­ing and improv­ing the code we’ll be going over. Ben is the cre­ator of the Craft plu­g­in for Datas­tar and one of the main­tain­ers of the Datas­tar project itself. His input has been invalu­able. Thanks, Ben!  

Datastar Pro

You can sup­port Datas­tar by opt­ing into their Pro pro­gram. If you’re a pro user of Datas­tar have access to addi­tion­al fea­tures in Datas­tar, like the data-query-string attribute. This attribute syncs the query string params to the sig­nal val­ue on page load and syncs the sig­nal val­ues when they change. Hav­ing access to that attribute would sim­pli­fy the process we’re going through here. Pro users also sup­port con­tin­ued devel­op­ment of Datas­tar.

What we’re going to build

I’ll walk you through five iter­a­tions that pro­gres­sive­ly enhance the user expe­ri­ence of the search. By the end, you’ll bet­ter under­stand Datastar’s capa­bil­i­ties and how it can ele­vate your Craft CMS projects. 

About the source code

You can down­load all of the tem­plates in this post from my Github repo: https://​github​.com/​j​o​h​n​f​m​o​r​t​o​n​/​c​r​a​f​t​-​c​m​s​-​d​a​t​a​s​t​a​r​-​e​x​a​mples. 

For each step we go through, you’ll be ref­er­enc­ing two files. I’ve tried to orga­nize it log­i­cal­ly, but let’s spell it out.

For step 1, the first file you’ll look at is called datastar-search-v1.twig. This is the file that con­tains the search input field. It has a com­pan­ion, _datastar/search-results-v1.twig , which we’ll load through an AJAX request.

I’ve stripped out near­ly all styling to make the source code eas­i­er to read. You’ll also be able to inter­act with ver­sions of these files I have host­ed on my blog.

Step 1: Basic real-time search

The first iter­a­tion sets up a sim­ple search input that dynam­i­cal­ly fil­ters results as the user types. This imple­men­ta­tion will:

  • Use Datas­tar to fetch fil­tered data with­out a full-page reload.
  • Pro­vide imme­di­ate feed­back to users as they type.
  • Use Datas­tar’s built-in debounce func­tion­al­i­ty to reduce the load on your serv­er.
  • Elim­i­nate the need for com­plex front-end log­ic to han­dle search requests.

This approach sig­nif­i­cant­ly improves the user expe­ri­ence com­pared to a tra­di­tion­al search that requires form sub­mis­sion, where the page reloads with search results. Note that we’re not imple­ment­ing the data­base search until we reach step 4, so we can focus on the search func­tion­al­i­ty’s UX.

Reference files from the repo:

  1. datastar-search-v1.twig
  2. _datastar/search-results-v1.twig

Open https://​supergeek​ery​.com/​d​a​t​a​s​t​a​r​-​s​e​a​r​ch-v1 in a new win­dow and try the fol­low­ing:

  1. Select the input field with your cur­sor or use the tab key to focus it.
  2. Type in a word, like cat”.
  3. Take note of the con­tent in the pre ele­ment with the data-json-sig­nals attribute. This attribute is used as debug­ging dis­play of the Datas­tar data on the search page itself.
  4. Also note the debug­ger area and the search results area. This con­tent is gen­er­at­ed in the search results Twig tem­plate that is passed back to the search tem­plate for dis­play by updat­ing spe­cif­ic ele­ments. 

The code

First open at the datastar-search-v1.twig file.

You’ll see a div ele­ment with an id of con­tain­er”. This ele­ment is the par­ent of all the reac­tive ele­ments we will man­age with Datas­tar. Look at the data-signals attribute on this con­tain­er ele­ment. 

data-signals="{ phrase: '', counter: 42 }"

This is where we’re defin­ing the data for our reac­tive search. You’ll see this attribute is a JSON encod­ed Twig array. We’re defin­ing two sig­nals (aka, vari­ables”) and giv­ing them some default val­ues. The sig­nal phrase” is an emp­ty string and the sig­nal counter” is 42

Next, look at the input” field. There are two data-* attrib­ut­es.

data-bind-phrase : The data-bind-* attribute binds the val­ue of this input field to the sig­nal we defined in the par­ent con­tain­er. If this was data-bind-counter, this input field would have dis­played 42” instead of an emp­ty string.

data-on-input: This attribute is adding an event lis­ten­er to this input field. We’re lis­ten­ing for the input event. This event is auto­mat­i­cal­ly fired when a user types a char­ac­ter into the input field. Although it wouldn’t make a lot of sense with our UX, we could have added a click event lis­ten­er with data-on-click here.

Many data attrib­ut­es may con­tain data when using Datas­tar. In our input event lis­ten­er, we’ll use a Twig func­tion called the Datas­tar Craft plu­g­in.

data-on-input="{{ datastar.get('_datastar/search-results-v1.twig') }}"

Our input event will trig­ger a GET AJAX request on the serv­er and load the search results in the Twig file. Before we leave this Twig tem­plate, let’s talk about updat­ing the input event with debounc­ing. Debounc­ing will pre­vent every key­stroke from issu­ing an AJAX request. Now you can type cat” with­out search­ing for each indi­vid­ual let­ter if you type the word quick­ly enough. Datas­tar makes adding debounc­ing very easy by adding the word debounce” to our code. We’ll use a debounced input event lis­ten­er from now on.

data-on-input__debounce.200ms="{{ datastar.get('_datastar/search-results-v1.twig') }}"

Let’s open the sec­ond file. Look in the _​datastar direc­to­ry for search-results-v1.twig. This is the file that gen­er­ates the con­tent of our AJAX request.

This seems pret­ty straight­for­ward if you don’t think too hard about it. But, the more you think about it, the more impres­sive it becomes. The search-results-v1.twig receives the sig­nals, both the unchanged and updat­ed ones, from your datastar-search-v1.twig file and sends back HTML ele­ments that will be patched into your page in the cor­rect spots. You don’t have to mess with set­ting up GraphQL or an Ele­ment API. It just works.

Elements and IDs

We are using a stan­dard ele­ment in the HTML, iden­ti­fied by an ID, swap­ping in new con­tent when the AJAX returns its data. If you look at the two Twig files, you’ll see both con­tain a div with the ID of searchRe­sults.” You can have mul­ti­ple ele­ments returned from the AJAX request. You’ll see anoth­er ele­ment, a pre ele­ment, with the ID of debug­ger.” Both frag­ments will be returned and placed in sep­a­rate loca­tions in the page based on where the ele­ments with the asso­ci­at­ed ID are locat­ed in the HTML.

Are we done?

This is going to be a long blog post. If book­mark­ing and brows­er his­to­ry aren’t rel­e­vant to you, skip to step 4, which dis­cuss­es how to make Craft use the search phrase to gen­er­ate search results. 

If you want to take the long jour­ney with me, let’s keep going because there is more we can do to build a rich­er search page. Think of steps 2 and 3 as extra cred­it.

Step 2: Enhanced bookmarkable search results

In the sec­ond iter­a­tion, we refine the search. This imple­men­ta­tion will:

  • Use query para­me­ters to reflect the user’s search input, enabling users to share or book­mark search results.
  • Make search­es per­sist across page reloads.
  • Pre­vent unnec­es­sary requests when there is no phrase to search.

These updates enhance usabil­i­ty and acces­si­bil­i­ty, ensur­ing that search results are not lost when nav­i­gat­ing away or refresh­ing the page. How­ev­er, it intro­duces some edge-case UX prob­lems we will fix in step 3.

Reference files from the repo:

  1. datastar-search-v2.twig
  2. _datastar/search-results-v2.twig

Open https://​supergeek​ery​.com/​d​a​t​a​s​t​a​r​-​s​e​a​r​ch-v2 in a new win­dow and try the fol­low­ing:

  1. Notice that the search field has focus. This is a search page, so we will assume that the user is here to search and we focus input on the input field.
  2. Type a word in the text input. Along with the live ren­dered search results, notice that the page URL reflects the phrase you entered.
  3. Notice that the page title includes the phrase we’re search­ing for.
  4. We also track the num­ber of search­es exe­cut­ed. (I did this for debug­ging pur­pos­es, which will make more sense in step 3.)
  5. Refresh the page, and the same search results will be dis­played.
  6. You can add anoth­er word while keep­ing the exist­ing word you typed. You’ll see the URL, and the search results will update again.
  7. Final­ly, use the back but­ton in your brows­er and return to the pre­vi­ous search result.

We will review both files, but I will start with search-results-v2.twig this time. This tem­plate will ulti­mate­ly exe­cute the data­base search using Craft’s search func­tion­al­i­ty but at this point, it sim­ple reports back the search term. We will also update the URL para­me­ter and the doc­u­ment title inside a sec­tion of this Twig file called executescript that allows you to exe­cute JavaScript when Datas­tar gets an update from the serv­er. 

(Please note that the executescript is a fea­ture of the Datas­tar plu­g­in and should not be con­fused with the script tag that is part of Craft CMS itself.)

On with the JavaScript

I relied exclu­sive­ly on JavaScript here in an ear­li­er ver­sion of this tem­plate. Ben encour­aged me to lean into Twig as much as pos­si­ble, a gen­er­al best prac­tice with the Datas­tar plu­g­in, which is what you see here. Rely­ing on Twig makes the page log­ic eas­i­er to under­stand. (If you’re using anoth­er serv­er-side solu­tion oth­er than Craft CMS, check out exam­ples in PHP, Go, C# and oth­ers in the doc­u­men­ta­tion.)

{% executescript %}
    {% set phrase = signals.phrase|trim %}
    // Note: The `url` variable is defined in the datastar-search-v2.twig template.
    // We use it to update the browser's URL without reloading the page.
    url = new URL(window.location.href);

    {% if phrase %}
    url.searchParams.set("phrase", '{{ phrase }}');
    {% else %}
    {# Remove the query parameter if the search phrase is empty #}
    url.searchParams.delete('phrase');
    {% endif %}

    {# Update the URL without triggering a page reload #}
    window.history.pushState({}, '', url);

    {# Update the title of the page #}
    document.title = `Search: {{ phrase }}`;
{% endexecutescript %}

The JavaScript is pret­ty easy to under­stand. We get the url and set the phrase para­me­ter to the signal.phrase. If one is not set, we remove the URL para­me­ter entire­ly. We push the new URL to the win­dow’s his­to­ry and update the doc­u­men­t’s title. The JavaScript inside the executescript block is exe­cut­ed when­ev­er we suc­cess­ful­ly issue an AJAX request from the search page. 

We’ll now take a look at the datastar-search-v2.twig file.

Before we jump into the Twig code, look at the script sec­tion near the bot­tom of the tem­plate. 

// define the URL variable to be used later inside the datastar template called during the search process. That JS is inserted and then removed after execution by Datastar.
let url;

We define a JavaScript vari­able with let called url and it appears we don’t do any­thing with it. Why do that? The rea­son is that we use the url vari­able in the script that gets added to the page when­ev­er we pull in con­tent from the search results tem­plate in the executescript por­tion of that file. We need a vari­able that we can reuse which is why we use the let key­word.

Next, we set focus on the input ele­ment after the page con­tent loads. This is just basic JavaScript. No Datas­tar mag­ic required.

Now let’s focus on the Twig code near the begin­ning of this tem­plate. 

If it is present in the URL, we grab the phrase query para­me­ter using Twig and then set the phrase Twig vari­able to that val­ue or fall back to an emp­ty string. 

{% set phrase = craft.app.request.getQueryParam('phrase') ?? '' %}

The con­tain­er ele­ment uses that infor­ma­tion to set its phrase Datas­tar sig­nal. The counter sig­nal from ver­sion 1 of the tem­plate has been renamed to searchCounter to bet­ter reflect what we’re keep­ing track of. Notice that I’ve updat­ed the syn­tax here since I’m pass­ing in a Twig vari­able. 

<div id="container" data-signals='{ "phrase": {{ phrase|json_encode }}, "searchCounter": 0 }'>

The text input’s event lis­ten­er has been updat­ed slight­ly to incre­ment the searchCounter sig­nal when we issue the AJAX request. This is how we track how many search­es we have made.

data-on-input__debounce.200ms="{{ datastar.get('_datastar/search-results-v2.twig') }} && $searchCounter++;"

We add a new on page load event lis­ten­er to the input ele­ment. It will check if the phrase has con­tent when that page is first loaded. If it does, we’ll issue an AJAX request for search results. This makes our search results book­mark­able. To see it work, input a search term on the exam­ple page and then hit the brows­er reload but­ton. The same search results will be dis­played.

data-on-load="$phrase.length >= 1 && {{ datastar.get('_datastar/search-results-v2.twig') }} && $searchCounter++;"

If we stopped there, when you click the back but­ton in your brows­er, the search func­tion­al­i­ty won’t work as expect­ed. The search request for the phrase won’t be exe­cut­ed to give you the results you expect. 

We need to set up an event lis­ten­er on the search page to lis­ten for the popstate event. If that event is heard, we retrieve the phrase from the URL and update the sig­nal by chang­ing the val­ue in the input field and man­u­al­ly trig­ger­ing an input event.

window.addEventListener('popstate', (event) => {
  // When the browser navigates back or forward,
  // the current URL already reflects the desired state
  // so we don't need to update the URL or the input field.
  const currentUrl = new URL(window.location.href);
  const searchPhraseValue = currentUrl.searchParams.get('phrase') || '';

  // Update the input field value, which is bound to the Datastar signal
  searchPhrase.value = searchPhraseValue;
  searchPhrase.dispatchEvent(new Event('input', {bubbles: true}));
});

The script I’ve shared is how I ini­tial­ly wrote this JavaScript. How­ev­er, there is an error in this code that’s not obvi­ous. Can you spot it? 

The prob­lem is the dis­patched input event and the action we take based on that event in the search-results-v2.twig page.

You may won­der why I need­ed to dis­patch the event man­u­al­ly. The phrase is updat­ed in the input field when the user clicks the back but­ton. Would­n’t that cause the input event to fire? No, it won’t. The input event doc­u­men­ta­tion reveals why.

The input event fires when the value of an input, select, or textarea ele­ment has been changed as a direct result of a user action (such as typ­ing in a textbox or check­ing a check­box).

If the user does not type direct­ly into the input field, no input event will be fired, and no search will be made. We can try to fix the prob­lem by man­u­al­ly trig­ger­ing the input event using the dispatchEvent func­tion.

searchPhrase.dispatchEvent(new Event('input', {bubbles: true}));

So what’s wrong with this solu­tion? Go back to the live exam­ple page at https://​supergeek​ery​.com/​d​a​t​a​s​t​a​r​-​s​e​a​r​ch-v2 and try to do the fol­low­ing.

  1. Type in cat” and see the search result.
  2. Add dog” to the search field and see the result.
  3. Add fish” to the search field and see the result.
  4. Now use the back but­ton in your brows­er. The brows­er URL and the input field have been updat­ed to show cat dog”, but the search results are still show­ing the results for cat dog fish.”
  5. Now use the back but­ton again. We have the same issue, although we expect search results for cat,” the search results are still show­ing the results for cat dog fish.”

We will fix this in the next step of the process by intro­duc­ing cus­tom events.

Step 3: Fix the user experience with custom events

This sec­tion will fix our bro­ken UX when the brows­er but­tons are used. This imple­men­ta­tion will:

  • Add a cus­tom event to pre­vent the unin­tend­ed rewrit­ing of brows­ing his­to­ry.
  • Allow the back and for­ward but­tons in the brows­er to work as expect­ed.

The exam­ple page is at https://​supergeek​ery​.com/​d​a​t​a​s​t​a​r​-​s​e​a​r​ch-v3.

We need a way to update the browser’s his­to­ry con­di­tion­al­ly and Datas­tar has us cov­ered with sup­port for cus­tom events

Reference files from the repo:

  1. datastar-search-v3.twig
  2. _datastar/search-results-v3.twig

Let’s look at the search page to start. In the con­tain­er ele­ment, we’re now track­ing addi­tion­al sig­nals: lastPhrase and browserHistoryCounter. We will store the last suc­cess­ful search term and use it in the log­ic in the code below. The brows­er his­to­ry counter is help­ful for debug­ging. It allows us to dif­fer­en­ti­ate between new search queries and queries based on a popstate event. 

In the code, we see the input event lis­ten­er has been updat­ed.

data-on-input__debounce.200ms="($phrase.trim() !== $lastPhrase.trim()) && {{ datastar.get('_datastar/search-results-v3.twig', { updateHistory: true }) }} && $searchCounter++, $lastPhrase = $phrase.trim();"

First, we ensure the phrase and last searched phrase are not the same. The trim func­tion pre­vents a new search from being fired if only a space char­ac­ter is added after a word. If our con­di­tion­al returns true, we’ll exe­cute the search. 

In the search result request, we are lean­ing on anoth­er fea­ture of the Datas­tar plu­g­in, which lets us pass addi­tion­al data along with our request. The updateHistory vari­able, which is an arbi­trary vari­able name, allows us to con­trol if we need to add a URL to our brows­er his­to­ry. We’ll update the search results tem­plate short­ly to use this data. We still have work to do here first. We must dif­fer­en­ti­ate between an input event trig­gered on the text input field and a pop­state event trig­gered on the win­dow when the back but­ton is used.

We’ll do that by cre­at­ing a cus­tom event that allows us to pass around extra data. Let’s go over the script sec­tion of the page.

let url;

const container = document.getElementById('container');
document.addEventListener('DOMContentLoaded', () => {
  const searchPhrase = document.querySelector('[data-bind-phrase]');

  // set focus on the search input
  searchPhrase.focus();

  window.addEventListener('popstate', (event) => {
    // When the browser navigates back or forward,
    // the current URL already reflects the desired state.
    const currentUrl = new URL(window.location.href);
    const searchPhraseValue = currentUrl.searchParams.get('phrase') || '';

    // Update the input field value, which is bound to the Datastar signal
    searchPhrase.value = searchPhraseValue;

    // a new custom event that we can listen for
    const historyChangeEvent = new CustomEvent('historychange', {
      detail: {
        phrase: searchPhrase.value
      }
    });

    // Dispatch the custom event on the search input
    searchPhrase.dispatchEvent(historyChangeEvent);
  });
});

When there is a popstate event on the win­dow, we dis­patch a cus­tom historychange event on the input field and then lis­ten to it with a Datas­tar attribute. Here, we update the phrase and make the search request with the update his­to­ry val­ue set to false.

data-on-historychange="$phrase=evt.detail.phrase; $browserHistoryCounter++; {{ datastar.get('_datastar/search-results-v3.twig', { updateHistory: false }) }}"

Let’s now look in the search-results-v3.twig tem­plate file. 

We use the update his­to­ry val­ue to push a new item to our brows­er his­to­ry only when a new search term has been entered. Now our brows­er but­tons work as expect­ed.

{% if updateHistory %}
    window.history.pushState({}, '', url);
    // Update the title of the page
    document.title = `Search: {{ phrase }}`;
{% endif %}

These changes fix our UX bugs. Users can use the for­ward and back but­tons as much as they like, and the page will work as expect­ed.

Step 4: Implementing the Craft CMS search functionality

Now we will add the actu­al search results to our tem­plate. 

The exam­ple page is at https://​supergeek​ery​.com/​d​a​t​a​s​t​a​r​-​s​e​a​r​ch-v4.

Reference files from the repo:

  1. datastar-search-v4.twig
  2. _datastar/search-results-v4.twig

We’re only going to change the search results tem­plate. The code is straight­for­ward. This sim­ple Twig code search­es your entries for the phrase. I’m search­ing in the blog sec­tion of my site. 

{# update the section from 'blog' to whatever section you want to search in your own Craft site #}
{% set results = craft.entries()
    .search(signals.phrase)
    .orderBy('score')
    .section('blog')
    .collect() %}
{% else %}
    {% set results = [] %}
{% endif %}

The search results sec­tion loops through the search results and dis­plays them in a list. 

{% patchelements %}
    <div id="searchResults">
        {% if results | length %}
        <h2 class="text-lg font-bold">Search results ({{ results | length }})</h2>
        <ul class="list-inside list-disc mt-1">
            {% for result in results %}
                <li class="ml-4">{{ result.title }} - {{ result.url }}</li>
            {% endfor %}
        </ul>
        {% elseif signals.phrase | length %}
        <h2 class="text-lg font-bold pb-2">Search results</h2>
        <p class="italic">No results found for <span class="font-bold">"{{ signals.phrase ?? '' }}"</span></p>
        {% endif %}
    </div>
{% endpatchelements %}

It’s hard to over­state how sim­ple this part is to cre­ate. There is no pars­ing of JSON from an API. You’re not doing a GraphQL query. This is basic Craft Twig code. Even if you’re new to Craft, this prob­a­bly makes sense. You’ve made it this far so let’s take it one step fur­ther.

Step 5: Adding complexity with additional user-selected search options

In this step, we add a new fea­ture that lets users refine their search by adding radio but­tons to alter the search behav­ior. I use my site for blog posts and as a book­mark­ing data­base. The user can now select which sec­tion they search on the site. 

The exam­ple page is at https://​supergeek​ery​.com/​d​a​t​a​s​t​a​r​-​s​e​a​r​ch-v5. 

Reference files from the repo:

  1. datastar-search-v5.twig
  2. _datastar/search-results-v5.twig

We’ve reviewed the tem­plates thor­ough­ly, so let’s only go over the changes. (I won’t cov­er it in this post, but you’ll find an exam­ple of Datas­tar’s class bind­ing in the code, too.)

At the top of the page, we set a new vari­able called sections, which is retrieved from the URL, and use it on the con­tain­er to add it to the Datas­tar sig­nals.

{% set sections = craft.app.request.getQueryParam('sections') ?? 0 %}
<div id="container"
         data-signals="{phrase: phrase, searchCounter: 0, browserHistoryCounter: 0, lastPhrase: '', sections: sections }">

We have added three radio but­tons. The options are to search all sec­tions, only the blog sec­tion, or the saved links sec­tion. Each radio but­ton has a unique val­ue and we bind the sections sig­nal to each one. 

Each radio but­ton also has an on-change event lis­ten­er. Since the event lis­ten­er is iden­ti­cal for each one, we use a Twig expres­sion to keep our code DRY.

{% set expression = '$phrase.trim().length && ' ~ datastar.get('_datastar/search-results-v5.twig', { updateHistory: true }) ~ ' && $searchCounter++; $lastPhrase = $phrase.trim()' %}
<input id="globalSearch" data-on-change="{{ expression }}" type="radio" data-bind-sections value="0">

When the user mod­i­fies the searched sec­tion, this expres­sion tells Datas­tar to per­form a new search. We’ve also updat­ed the script sec­tion of this page to incor­po­rate the sec­tions vari­able for the his­to­ry change event.

const historyChangeEvent = new CustomEvent('historychange', {
  detail: {
    phrase: searchPhrase.value,
    sections: sections
  }
});

Now let’s exam­ine the search results file. We need to dis­cuss an impor­tant secu­ri­ty fea­ture here. 

Notice that only two val­ues are accept­able for the sec­tions attribute. A mali­cious user can’t alter a search URL to query sec­tions oth­er than those we intend because we check for accept­able val­ues before we process the search. For more infor­ma­tion, read the secu­ri­ty page on the Datas­tar site, which dis­cuss­es pro­tect­ing your­self when accept­ing user input.

{# Default searching both sections #}
{% set sections = ['blog','savedLinks'] %}
{# Only reset the selection if it is one of the expected values #}
{% if signals.sections == '1' %}
    {% set sections = "blog" %}
{% endif %}
{% if signals.sections == '2' %}
    {% set sections = "savedLinks" %}
{% endif %}

We now have a well-built search page.

One more thing: View transitions

If you’ve been using the exam­ple pages, did you notice how smooth­ly the new search results appeared on the page? (If you’re using Fire­fox, you may not see them as of this writ­ing. That’s not a prob­lem since they are grace­ful­ly ignored when sup­port isn’t present in a brows­er.)

Datas­tar, the JavaScript frame­work, lets you use view tran­si­tions when serv­er-side events send back new ele­ments. Since we’ve used the Datas­tar Craft Plu­g­in, turn­ing this on is as sim­ple as updat­ing your con­fig file.

Check out the con­fig file from the repo we’ve been ref­er­enc­ing in this post. The useViewTransition option is set to true to add view tran­si­tions to frag­ments. It could­n’t be sim­pler.

/**
 * The element options to override the Datastar defaults. Null values will be ignored.
 */
'defaultElementOptions' => [
    'useViewTransition' => true, // <-- The only option changed from the default value (null) for this project
],

Closing thoughts on Datastar

I hope you share some of my excite­ment for Datas­tar. I admire its straight­for­ward syn­tax and the ele­gance of how it works. 

When I first heard about Datas­tar, I thought it was anoth­er Alpine.js, which has worked well for me. How­ev­er, Alpine’s domain only cov­ers the front end of a site. Datas­tar is a tiny frame­work that han­dles both fron­tend and back­end needs. 

This site’s search page was pre­vi­ous­ly Alpine-pow­ered and used an Ele­ment API end­point. That ver­sion of the page could­n’t book­mark a search phrase, which meant the brows­er his­to­ry prob­lem had­n’t pre­sent­ed itself. Rebuild­ing the page with Datas­tar impressed me so much that I wrote this post to help spread the word. From now on, I plan to include Datas­tar in my default starter projects. 

Would you have built this search dif­fer­ent­ly? Hit me up on the Craft CMS Dis­cord or social media. Cheers.