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 14kb library called Datas­tar. The Datas­tar plu­g­in for Craft CMS makes using it even eas­i­er. If you’re a Craft devel­op­er, it’s a no-brain­er addi­tion to your toolk­it.

The search page 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? 

So, I hear you ask­ing, What is Datas­tar?” Let’s look at the Datas­tar get­ting start­ed page for some help: 

Datas­tar helps you build reac­tive web appli­ca­tions with the sim­plic­i­ty of serv­er-side ren­der­ing and the pow­er of a full-stack SPA frame­work.

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.)

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!  

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 ctx.signals area. This is a 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 page.

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. With that out of the way, let’s look at the code.

The code

First look 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. This is where we’re defin­ing the data for our reac­tive search. You’ll see this attribute eval­u­ates to a JSON-like string. In that string, 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 your 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 frag­ments that will be insert­ed 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.

Fragments and IDs

I’ve men­tioned frag­ments” a cou­ple of times. A frag­ment is basi­cal­ly an ele­ment in the HTML, iden­ti­fied by an ID, that will get swapped out with 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 frag­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 refined 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.

This iter­a­tion enhances 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 need to review both files, but I will start with search-results-v2.twig this time. This part of the process will do the data­base search using Craft’s search func­tion­al­i­ty. We will also update the URL para­me­ter and the doc­u­ment title inside a spe­cial sec­tion called executescript that allows you to exe­cute JavaScript when Datas­tar gets an update from the serv­er. 

I relied exclu­sive­ly on all JavaScript here in an ear­li­er ver­sion of this file. Ben encour­aged me to lean into Twig as much as pos­si­ble, which is what you see here. It 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 %}
    const 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 datastar-search-v2.twig page. We’ll now take a look at that file.

We set focus on the input ele­ment using basic JavaScript. Look at the script sec­tion. 

Set­ting the page’s ini­tial phrase based on the query para­me­ter is not much more com­pli­cat­ed. If it is present in the URL, we grab the phrase query para­me­ter using Twig and then set our vari­able to that phrase or fall back to an emp­ty string.

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

The con­tain­er ele­ment now uses that infor­ma­tion to set its phrase sig­nal. The counter sig­nal has been renamed to searchCounter to bet­ter reflect what we’re keep­ing track of.

<div id="container" data-signals="{phrase: '{{ phrase }}', 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 added 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 the 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 you click the back but­ton in your brows­er, it won’t work as expect­ed. The search request for the phrase won’t be exe­cut­ed. 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 dis­patchEvent 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 and see the result for dog.” It appeared to work.
  5. Now use the back but­ton again. The page will skip back and miss the cat” search result entire­ly.

Since we push state to the win­dow his­to­ry every time we exe­cute a search in the search-results-v2.twig file, we unin­ten­tion­al­ly cor­rupt our brows­er his­to­ry when the browser’s back and for­ward but­tons are used. 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 pop­state 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. This 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 send addi­tion­al data along with our request. The updateHistory vari­able 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.

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

  searchPhrase.addEventListener('input', (e) => {
    console.log('searchPhrase', e.target.value);
  });
  // 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 pop­state 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. matchon

{# 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 frag­ment loops through the search results and dis­plays them in a list. 

{% fragment %}
    <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>
{% endfragment %}

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? 

Datas­tar, the JavaScript frame­work, lets you use view tran­si­tions when serv­er-side events send back new frag­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 fragment options to override the Datastar defaults. Null values will be ignored.
 */
'defaultFragmentOptions' => [
    'settleDuration' => null,
    '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.