In this guide, we'll walk you through building an AI-powered movie recommendation app. Unlike traditional keyword search, AI-powered search uses machine learning to return results based on the context and meaning behind queries.

You'll build a search and recommendation system using Meilisearch and OpenAI's embedding model. The app will offer a search-as-you-type experience, combining precise keyword matching with the deeper context of semantic search, helping users find relevant movies even when their queries don't exactly match movie titles or descriptions.

Additionally, the app will feature AI-powered recommendations, suggesting similar movies based on the user’s selection, to enhance their experience.

Whether you're new to Meilisearch or expanding your search skills, this tutorial will guide you through building a cutting-edge movie search and recommendation system. Let's get started!

Prerequisites

Before we begin, make sure you have:

  • Node.js and npm (included with Node.js)
  • A running v1.10 Meilisearch project — a search engine to create out-of-the-box relevant search experiences
  • An API key from OpenAI to use their embedding models (at least a tier 2 key for optimal performance)

1. Set up Meilisearch

In this guide we'll use Meilisearch Cloud, as it's the easiest option get Meilisearch up and running fast. You can try it free for 14 days, no credit card required. It's also the recommended way to run Meilisearch in production environments.

If you prefer to run things on your own machine, no problem - Meilisearch is open-source, so you can install it locally.

1.1. Create a new index

Create an index called movies and add this movies.json to it. If necessary, follow the getting started guide.

Each document in the movies dataset represents a single movie and has the following structure:

  • id: a unique identifier for each movie
  • title: the title of the movie
  • overview: a brief summary of the movie's plot
  • genres: an array of genres that the movie belongs to
  • poster: a URL to the movie's poster image
  • release_date: the release date of the movie, represented as a Unix timestamp

In the Meilisearch Cloud dashboard:

  • Find the "Experimental features" section in your project settings
  • Check the "AI-powered search" box
AI-powered search checkbox checked

Alternatively, activate it via the API, using the experimental-features route.

1.3. Configure the embedder

To harness the power of AI-powered search, we need to configure an embedder for our index.

When we configure an embedder, we're telling Meilisearch how to convert our text data into embeddings–numerical representations of the text that capture its semantic meaning. This allows for semantic similarity comparisons, enabling our search to understand context and meaning beyond simple keyword matching.

We'll use OpenAI's model for this tutorial, but Meilisearch is compatible with various embedders. You can explore other options in our compatibility list. Don’t know which model to choose? We've got you covered, read our blog post on choosing the right model for semantic search.

Configure the embedder index setting:

  • In the Cloud UI:
Embedder configuration in Meilisearch Cloud UI
  • Or via API:
curl -X PATCH 'https://ms-*****.sfo.meilisearch.io/indexes/movies/settings' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer YOUR_MEILISEARCH_API_KEY' \
--data-binary '{
  "embedders": {
    "text": {
      "source": "openAi",
      "apiKey": "YOUR_OPENAI_API_KEY",
      "model": "text-embedding-3-small",
      "documentTemplate": "A movie titled '{{doc.title}}' that released in {{ doc.release_date }}. The movie genres are: {{doc.genres}}. The storyline is about: {{doc.overview|truncatewords: 100}}"
    }
  }
}'
  • text is the name we have given to our embedder
  • Replace https://ms-*****.sfo.meilisearch.io with your project's URL
  • ReplaceYOUR_MEILISEARCH_API_KEY and YOUR_OPENAI_API_KEY with your actual keys
  • The model field specifies the OpenAI model to use
  • The documentTemplate field customizes the data sent to the embedder
đź’ˇ
Tip: Create short, relevant document templates for better search results and optimal performance.

2. Create a React app

Now that our Meilisearch backend is configured, let's set up the frontend of our AI-powered search application using React.

2.1. Set up the project

We’ll use a Vite template to create a new React project with a basic structure, setting us up for rapid development.

npm create vite@latest movie-search-app -- --template react
cd movie-search-app
npm install

2.2. Install the Meilisearch client

Next, we need to install the Meilisearch JavaScript client to interact with our Meilisearch backend:

npm install meilisearch

2.3. Add Tailwind CSS

For styling, we'll use Tailwind CSS. Instead of installing it as a dependency, we'll use the Tailwind CSS Play CDN for simplicity. Add the following script tag to the <head> of your index.html file:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>AI-Powered movie search</title>
    <script src="https://cdn.tailwindcss.com"></script>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

We've also updated the <title> tag to reflect our app's purpose.

2.4. Verify the setup

To ensure everything is set up correctly, start the development server:

npm run dev

You should see a URL (usually http://localhost:5173) where you can view your app in the browser. If you see the Vite + React welcome page, you're all set!

With these steps completed, we have a React project ready for building our AI-powered movie search interface. In the next sections, we'll start implementing the search functionality using Meilisearch.

3. Build an AI-powered search experience

Hybrid search combines traditional keyword search with AI-powered semantic search. Keyword search is great for precise matches, while semantic search understands context. By using both, we get the best of both worlds - precise results and contextually relevant matches.

3.1. Create a MovieSearchService.jsx file

We have a Meilisearch instance running, and to interact with it, we create a MovieSearchService.jsx file in our src directory. This service acts as a client-side interface to our Meilisearch backend, providing essential search-related functionality for our movie database.

First, we need to add the Meilisearch credentials to an .env file. You can find the Database URL (your host) and Default Search API Key on your Meilisearch Cloud project’s Settings page.

VITE_MEILISEARCH_HOST=https://ms-************.sfo.meilisearch.io
VITE_MEILISEARCH_API_KEY='yourSearchAPIKey'

Note that variables in Vite projects must be prefixed with VITE_ to be accessible in the application code.

Now, let’s create the Meilisearch client to connect to the Meilisearch instance:

// src/MovieSearchService.jsx
import { MeiliSearch } from 'meilisearch';

const client = new MeiliSearch({
  host: import.meta.env.VITE_MEILISEARCH_HOST || 'http://localhost:7700',
  apiKey: import.meta.env.VITE_MEILISEARCH_API_KEY || 'yourSearchAPIKey',
});

// We target the 'movies' index in our Meilisearch instance.
const index = client.index('movies');

Next, let’s create a function to perform a hybrid search:

// src/MovieSearchService.jsx

// ... existing search client configuration

const hybridSearch = async (query) => {
  const searchResult = await index.search(query, {
    hybrid: {
      semanticRatio: 0.5,
      embedder: 'text',
    },
  });
  return searchResult;
};

export { hybridSearch }

When adding the hybrid parameter to the search query, Meilisearch returns a mix of semantic and full-text matches.

The semanticRatio determines the balance between keyword and semantic search, with 1 being full semantic and 0 full keyword. A ratio of 0.5 means the results will be equally influenced by both methods. Adjusting this ratio allows you to fine-tune the search behavior to best suit your data and user needs.

The embedder specifies the configured embedder. Here, we are using the text embedder configured in step 1.3.

3.2. Create the search UI components

First, let's create a dedicated directory for our components src/components to keep our project clean and manageable as it grows.

3.2.1. The search input

Now, we can create our search input component. This will be the main interface for users to interact with our AI-powered search. Create a new file SearchInput.jsx in the src/components directory:

// src/components/SearchInput.jsx
import React from 'react';

const SearchInput = ({ query, setQuery }) => {
  return (
    <div className="relative">
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search for movies..."
        className="px-6 py-4 w-full my-2 border border-gray-300 rounded-md pr-10"
      />
      {/* Clear button appears when there's any text in the input (query is truthy) */}
      {query && (
      //  Clicking the clear button sets the query to an empty string
        <button
          onClick={() => setQuery('')}
          className="absolute right-6 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700"
        >
          &#x2715;
        </button>
      )}
    </div>
  );
};

export default SearchInput

The SearchInput component takes two props: query and setQuery. The input field's value is controlled by the query prop. When the user types in the input, it triggers the onChange event, which calls setQuery with the new value.

A clear button (❌) appears when there's any text in the input (when query is truthy). Clicking this button sets the query to an empty string, effectively clearing the input.

We'll control the state and behavior of the query and setQuery props in the parent component App.jsx.

3.2.2. The result card

Now that we have a search bar, we need a component to display the search results. Let's create a ResultCard component to showcase each movie returned by our search.

Create a new file ResultCard.jsx in the src/components directory:

// src/components/ResultCard.jsx
const ResultCard = ({ url, title, overview }) => {
    return (
      <div className='flex w-full sm:w-1/2 md:w-1/3 lg:w-1/4 p-3'>
        <div className='flex-1 rounded overflow-hidden shadow-lg'>
          <img
            className='w-full h-48 object-cover'
            src={url}
            alt={title}
          />
          <div className='px-6 py-3'>
     
            <div className='font-bold text-xl mb-2 text-gray-800'>
              {title}
            </div>
            <div className='font-bold text-sm mb-1 text-gray-600 truncate'>
              {overview}
            </div>
          </div>
        </div>
      </div>
    )
  }
  
  export default ResultCard

This component takes url, title, and overview as props. The component shows the movie poster using the url prop, followed by the title and a truncated overview, providing a compact preview of each movie.

3.3. Integrate search and UI in the main App component

Let's update the App.jsx component to tie everything together, handling the search logic and rendering the UI.

// src/App.jsx

// Import necessary dependencies and components
import { useState, useEffect } from 'react'
import './App.css'
import { hybridSearch } from './MovieSearchService';
import SearchInput from './components/SearchInput';
import ResultCard from './components/ResultCard'

function App() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    async function performSearch() {
      setIsLoading(true);
      setError(null);
      try {
        const response = await hybridSearch(query);
        setResults(response.hits);
      } catch (err) {
        setError('An error occurred while searching. Please try again.');
        console.error('Search error:', err);
      } finally {
        setIsLoading(false);
      }
    }
    
    performSearch();
  }, [query]);

  return (
    <div className='container w-10/12 mx-auto'>    
    <SearchInput
      query={query}
      setQuery={setQuery}
    />
    {isLoading && <p>Loading...</p>}
    {error && <p className="text-red-500">{error}</p>}
    <div className='flex flex-wrap'>
    {results.map((result) => (
      <ResultCard
      url={result.poster}
      title={result.title}
      overview={result.overview}
      key={result.id}
      />
    ))}
    </div>
    </div>
  );
}

export default App

We use several state variables:

  • query: stores the current search query
  • results: holds the search results
  • isLoading: indicates when a search is in progress
  • error: stores any error messages

The core of the component is a useEffect hook that triggers a performSearch function whenever the query changes. This function manages the search process, including setting loading states, calling the hybridSearch function, updating results, and handling any errors.

In the render method, we structure our UI with the SearchInput component at the top, followed by loading and error messages when applicable. The search results are displayed as a grid of ResultCard components, mapping over the results array.

4. Build the movie recommendation system

Now that we've implemented the search logic, let's enhance our application with a recommendation system. Meilisearch offers an AI-powered similarity search feature through its /similar route. This feature allows us to retrieve a number of documents that are similar to a target document, which is perfect for creating movie recommendations.

Let's add this functionality to our MovieSearchService.jsx:

// src/MovieSearchService.jsx

// ... existing search client configuration and hybridSearch function

const searchSimilarMovies = async (id, limit = 3, embedder = 'text') => {
  const similarDocuments = await index.searchSimilarDocuments({id, limit, embedder });
  return similarDocuments;
};

export { hybridSearch, searchSimilarMovies }

The searchSimilarDocuments index method takes the id of the target movie and the embedder name as parameters. It can also can be used together with other search parameters such as limit to control the number of recommendations.

4.1. Create a modal to display the recommendations

Let’s create a modal to display movie details and recommendations. A modal allows us to show more information without navigating away from the search results, which improves user experience by maintaining context.

//src/components/MovieModal.jsx

import React, { useEffect, useRef } from 'react';
import ResultCard from './ResultCard';

const MovieModal = ({ movie, similarMovies, onClose }) => {
  const modalRef = useRef(null);

  useEffect(() => {
    const handleEscape = (e) => {
      if (e.key === 'Escape') onClose();
    };
    document.addEventListener('keydown', handleEscape);
    modalRef.current?.focus();
    return () => document.removeEventListener('keydown', handleEscape);
  }, [onClose]);

  return (
    <div className="fixed inset-0 bg-black bg-opacity-80 flex items-center justify-center p-4 z-50"
         role="dialog"
         aria-modal="true"
         aria-labelledby="modal-title">
      <div ref={modalRef}
           className="bg-white rounded-lg p-6 max-w-4xl w-full max-h-[95vh] overflow-y-auto"
           tabIndex="-1">
        <h2 id="modal-title" className="text-2xl font-bold mb-4">{movie.title}</h2>
        <div className="flex mb-4">
          <div className="mr-4">
            <img
              className='w-48 object-cover'
              src={movie.poster}
              alt={movie.title}
            />
          </div>
          <div className="flex-1">
            <p>{movie.overview}</p>
          </div>
        </div>
        
        <h3 className="text-xl font-semibold mb-4">Similar movies</h3>
        <div className='flex flex-wrap justify-between'>
          {similarMovies.map((similarMovie, index) => (
            <ResultCard
              key={index}
              url={similarMovie.poster}
              title={similarMovie.title}
            />
          ))}
        </div>
        
        <button
          onClick={onClose}
          className="absolute top-2 right-2 w-10 h-10 flex items-center justify-center text-gray-500 hover:text-gray-700 bg-gray-200 rounded-full" // Added background and increased size
          aria-label="Close modal"
        >
          <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
          </svg>
        </button>
      </div>
    </div>
  );
}

export default MovieModal;

This component takes 3 props:

  • movie: an object containing details of the selected movie. This prop is used to display the main content of the modal.
  • similarMovies: an array of movie objects representing films similar to the main movie. We reuse our ResultCard component to showcase each recommended movie.
  • onClose: a function that is called when the modal should be closed. This function is triggered when the close button is clicked or when the 'Escape' key is pressed.

The useRef and useEffect hooks are used to manage focus and keyboard interactions, which are crucial for accessibility. The aria-* attributes further enhance the modal's accessibility for screen readers.

4.2. Implement modal functionality

Let’s update the main App.jsx component so we can call the similar movies function and open the modal when we click on a movie.

First let’s import the modal and the searchSimilarMovies function we created earlier:

// src/App.jsx
// ... existing imports
import MovieModal from './components/MovieModal';
import { hybridSearch, searchSimilarMovies } from './MovieSearchService';

Add state for selectedMovie using useState:

// src/App.jsx
// ... existing state ...
const [selectedMovie, setSelectedMovie] = useState(null);

This creates a state variable to store the currently selected movie, initially set to null, and a function to update it.

Next, let’s create 2 functions:

  • handleMovieClick to update the selectedMovie state with the movie that was clicked, enabling the modal to display the selected movie's details
  • closeModal to reset the selectedMovie state to null
const handleMovieClick = (movie) => {
  setSelectedMovie(movie);
};

const closeModal = () => {
  setSelectedMovie(null);
};

Now, we can update the ResultCard component to trigger the handleMovieClick function when clicked and add the MovieModal component to the JSX, conditionally rendering it when a movie is selected.

// src/ ResultCard.jsx
const ResultCard = ({ url, title, overview, onClick }) => {
    return (
      <div className='flex w-full sm:w-1/2 md:w-1/3 lg:w-1/4 p-3' onClick={onClick}>
        <div className='flex-1 rounded overflow-hidden shadow-lg'>
          <img
            className='w-full h-48 object-cover'
            src={url}
            alt={title}
          />
          <div className='px-6 py-3'>
     
            <div className='font-bold text-xl mb-2 text-gray-800'>
              {title}
            </div>
            <div className='font-bold text-sm mb-1 text-gray-600 truncate'>
              {overview}
            </div>
          </div>
        </div>
      </div>
    )
  }
  
  export default ResultCard
    // src/App.jsx	
    // ... in the return statement
    <div className='flex flex-wrap'>
    {results.map((result) => (
      <ResultCard
      url={result.poster}
      title={result.title}
      overview={result.overview}
      key={result.id}
      onClick={() => handleMovieClick(result)}
      />
    ))}
    </div>
    {selectedMovie && (
      <MovieModal
        movie={selectedMovie}
        onClose={closeModal}
      />
    )}
    </div>

Let’s create a new state variable similarMovies (initially an empty array) and its setter function setSimilarMovies to store and update the list of movies similar to the selected one.

const [similarMovies, setSimilarMovies] = useState([]);

Now, we need to udpate the handleMovieClick function to also fetch similar movies, and update the similarMovies state with the results, which we will pass on to the modal.

const handleMovieClick = async (movie) => {
  setSelectedMovie(movie);
  try {
    const similar = await searchSimilarMovies(movie.id);
    setSimilarMovies(similar.hits);
  } catch (err) { // error handling for the API call.
    console.error('Error fetching similar movies:', err);
    // Avoid broken content by setting `similarMovies` to an empty array
    setSimilarMovies([]);
  }
};

// ... existing code ...

<MovieModal
  movie={selectedMovie}
  similarMovies={similarMovies}
  onClose={closeModal}
/>

Finally, we need to update the closeModal to reset similarMovies state variable:

  const closeModal = () => {
    setSelectedMovie(null);
    setSimilarMovies([]);
  };

5. Run the application

Start the development server and enjoy!

npm run dev

Our app should look like this:

Typing "ace ventura" in the search bar getting results with each keystroke, clicking on the movie cat of "Ace Ventura: pet detective" and a modal opens with the movie poster and the full overview and 3 similar movies: "Ace Ventura Jr: pet detective", "Ace Ventura: when the nature calls", and "The Animal".

Conclusion

Congratulations! You've successfully built an AI-powered movie search and recommendation system using Meilisearch and React. Let's recap what you've accomplished:

  1. Set up a Meilisearch project and configured it for AI-powered search
  2. Implemented hybrid search combining keyword and semantic search capabilities
  3. Created a React UI for searching movies
  4. Integrated Meilisearch's similarity search for movie recommendations

What's next?

To improve user experience and allow for more precise searching, you could set a faceted search interface to allow users filter movies by genre or sort them by release date.

When you're ready to build an app with your own data, make sure to configure your index settings first to follow best practices. This will optimize indexing performance and search relevancy.

When you're ready to build an app with your own data, make sure to configure your index settings first to follow best practices. This will optimize indexing performance and search relevancy, ensuring your app runs smoothly and provides accurate results.


Meilisearch is an open-source search engine with intuitive developer experience to build user-facing search. You can self-host it or get a premium experience with Meilisearch Cloud.

For more things Meilisearch, you can join the community on Discord or subscribe to the newsletter. You can learn more about the product by checking out the roadmap and participating in product discussions.