Making a search bar for blogposts

Using the search API

AtomicServer comes with a fast full-text search API out of the box. @tomic/lib provides some convenient helper functions on Store to make using this API very easy.

To use search all you need to do is:

const results = await store.search('how to make icecream');

The method returns an array of subjects of resources that match the given query.

To further refine the query, we can pass a filter object to the method like so:

const results = await store.search('how to make icecream', {
  filters: {
    [core.properties.isA]: myPortfolio.classes.blogpost,
  },
});

This way the result will only include resources that have an is-a of blogpost.

Running code on the client

To make a working search bar, we will have to run code on the client. Astro code only runs on the server but there are a few ways to have code run on the client. The most commonly used option would be to use a frontend framework like React or Svelte but Astro also allows script tags to be added to components that will be included in the <head /> of the page.

To keep this guide framework-agnostic we will use a script tag and a web component but feel free to use any framework you're more comfortable with, the code should be simple enough to adapt to different frameworks.

First, we need to make a change to our environment variables because right now they are not available to the client and therefore getStore will not be able to access ATOMIC_SERVER_URL. To make an environment variable accessible to the client it needs to be prefixed with PUBLIC_.

In .env change ATOMIC_SERVER_URL to PUBLIC_ATOMIC_SERVER_URL.

// .env
PUBLIC_ATOMIC_SERVER_URL=<REPLACE WITH URL TO YOUR ATOMIC SERVER>
ATOMIC_HOMEPAGE_SUBJECT=<REPLACE WITH SUBJECT OF THE HOMEPAGE RESOURCE>

Now update src/helpers/getStore.ts to reflect the name change.

// src/helpers/getStore.ts
import { Store } from '@tomic/lib';
import { initOntologies } from '../ontologies';

let store: Store;

export function getStore(): Store {
  if (!store) {
    store = new Store({
      serverUrl: import.meta.env.PUBLIC_ATOMIC_SERVER_URL,
    });

    initOntologies();
  }

  return store;
}

In src/components create a file called Search.astro.

<blog-search></blog-search>

<script>
  import { getStore } from '../../helpers/getStore';
  import { core } from '@tomic/lib';
  import { myPortfolio, type Blogpost } from '../../ontologies/myPortfolio';

  class BlogSearch extends HTMLElement {
    // Get access to the store. (Since this runs on the client a new instance will be created)
    private store = getStore();
    // Create an element to store the results in
    private resultsElement = document.createElement('div');

    // Runs when the element is mounted.
    constructor() {
      super();

      // We create an input element and add a listener to it that will trigger a search.
      const input = document.createElement('input');
      input.placeholder = 'Search...';
      input.type = 'search';

      input.addEventListener('input', (e) => {
        this.searchAndDisplay(input.value);
      });

      // Add the input and result list elements to the root of our webcomponent.
      this.append(input, this.resultsElement);
    }

    /**
     * Search for blog posts using the given query and display the results.
     */
    private async searchAndDisplay(query: string) {
      if (!query) {
        // Clear the results of the previous search.
        this.resultsElement.innerHTML = '';
        return;
      }

      const results = await this.store.search(query, {
        filters: {
          [core.properties.isA]: myPortfolio.classes.blogpost,
        },
      });

      // Map the result subjects to elements.
      const elements = await Promise.all(
        results.map(s => this.createResultItem(s)),
      );

      // Clear the results of the previous search.
      this.resultsElement.innerHTML = '';

      // Append the new results to the result list.
      this.resultsElement.append(...elements);
    }

    /**
     * Create a result link for the given blog post.
     */
    private async createResultItem(subject: string): Promise<HTMLAnchorElement> {
      const post = await this.store.getResource<Blogpost>(subject);

      const resultLine = document.createElement('a');
      resultLine.innerText = post.title;
      resultLine.style.display = 'block';
      resultLine.href = `/blog/${post.props.titleSlug}`;

      return resultLine;
    }
  }

  // Register the custom element.
  customElements.define('blog-search', BlogSearch);
</script>

If you've never seen web components before, <blog-search> is our custom element that starts as just an empty shell. We then add a <script> that Astro will add to the head of our HTML. In this script, we define the class that handles how to render the <blog-search> element. At the end of the script, we register the custom element class.

NOTE:
Eventhough the server will most likely keep up with this many requests, lower end devices might not so it's still a good idea to add some kind of debounce to your searchbar.

Now all that's left to do is use the component to the blog page.

// src/pages/blog/index.astro

...
<Layout resource={homepage}>
  <h2>Blog 😁</h2>
+ <Search />
  <ul>
    {
      posts.map(post => (
        <li>
          <BlogCard subject={post} />
        </li>
      ))
    }
  </ul>
</Layout>

And there it is! A working real-time search bar 🎉

The end, what's next?

That's all for this guide. Some things you could consider adding next if you liked working with AtomicServer and want to continue building this portfolio:

  • Add some more styling
  • Add some interactive client components using one of many Astro integrations (Consider checking @tomic/react or @tomic/svelte)
  • Do some SEO optimisation by adding meta tags to your Layout.astro.