Javascript-based application stacks have proven themselves to be the dominating architecture for web applications we all use. From MEAN to MERN and MEVN, the idea is to have a JavaScript-based web client and server, communicating through a REST or GraphQL API powered by the document model of MongoDB as its flexible data store.
Remix is a new JS framework that comes to disrupt the perception of static websites and tiering the view and the controller. This framework aims to simplify the web component nesting by turning our web components into small microservices that can load, manipulate, and present data based on the specific use case and application state.
The idea of combining the view logic with the server business logic and data load, leaving the state management and binding to the framework, makes the development fast and agile. Now, adding a data access layer such as MongoDB Atlas and its new Data API makes building data-driven web applications super simple. No driver is needed and everything happens in a loader function via some https calls.
To showcase how easy it is, I have built a demo movie search application based on MongoDB Atlas sample database sample_mflix. In this article, we will cover the main features of this application and learn how to use Atlas Data API and Atlas Search features.
If you want to get familiar with Remix in general, I recommend reading its documentation and exploring its tutorials.
#Setting Up an Atlas Cluster and Data API
First we need to prepare our data tier that we will work with our Remix application. Follow these steps:
- Get started with Atlas and prepare a cluster to work with.
- Enable the Data API and save the API key.
- Load a sample data set into the cluster. (This application is using sample_mflix for its demo.)
#Setting Up Remix Application
As other Node frameworks, the easiest way to bootstrap an app is by deploying a template application as a base:
1 npx create-remix@latest
The command will prompt for several settings. I have used the default ones with the default self hosting option.
Let’s also add a few node packages that we’ll be using in our application. Navigate to your newly created project and execute the following command:
1 npm install axios dotenv tiny-invariant
The application consists of two main files which host the entry point to the demo application with main page html components: app/root.jsx
and app/routes/index.jsx
. In the real world, it will probably be the routing to a login or main page.
1 - app 2 - routes 3 - index.jsx 4 - root.jsx
In app/root.jsx
, we have the main building blocks of creating our main page and menu to route us to the different demos.
1 <nav aria-label="Main navigation" className="remix-app__header-nav"> 2 <ul> 3 <li> 4 <Link to="/">Home</Link> 5 </li> 6 <li> 7 <Link to="/movies">Movies Search Demo</Link> 8 </li> 9 <li> 10 <Link to="/facets">Facet Search Demo</Link> 11 </li> 12 <li> 13 <a href="https://github.com/mongodb-developer/atlas-data-api-remix">GitHub</a> 14 </li> 15 </ul> 16 </nav>
If you choose to use TypeScript while creating the application, add the navigation menu to
app/routes/index.tsx
instead. Don't forget to importLink
fromremix
.
Main areas are exported in the app/routes/index.jsx
under the “routes” directory which we will introduce in the following section.
This file uses the same logic of a UI representation returned as JSX while loading of data is happening in the loader function. In this case, the loader only provides some static data from the “data” variable.
Now, here is where Remix introduces the clever routing in the form of routes directories named after our URL path conventions. For the main demo called “movies,” we created a “movies” route:
1 - routes 2 - movies 3 - $title.jsx 4 - index.jsx
The idea is that whenever our application is redirecting to <BASE_URL>/movies
, the index.jsx under routes/movies
is called. Each jsx file produces a React component and loads its data via a loader function (operating as the server backend data provider).
Before we can create our main movies page and fetch the movies from the Atlas Data API, let’s create a .env
file in the main directory to provide the needed Atlas information for our application:
1 DATA_API_KEY=<API-KEY> 2 DATA_API_BASE_URL=<YOUR-DATA-ENDPOINT-URL> 3 CLUSTER_NAME=<YOUR-ATLAS-CLUSTER-NANE>
Place the relevant information from your Atlas project locating the API key, the Data API base URL, and the cluster name. Those will be shortly used in our Data API calls.
⚠️Important:
.env
file is good for development purposes. However, for production environments, consider the appropriate secret repository to store this information for your deployment.
Let’s load this .env file when the application starts by adjusting the “dev” npm scripts in the package.json
file:
1 "dev": "node -r dotenv/config node_modules/.bin/remix dev"
#movies/index.jsx
File
Let's start to create our movies list by rendering it from our data loader and the sample_mflix.movies
collection structure.
Navigate to the ‘app/routes’ directory and execute the following commands to create new routes for our movies list and movie details pages.
1 cd app/routes 2 mkdir movies 3 touch movies/index.jsx movies/\$title.jsx
Then, open the movies/index.jsx
file in your favorite code editor and add the following:
1 import { Form, Link, useLoaderData , useSearchParams, useSubmit } from "remix"; 2 const axios = require("axios"); 3 4 export default function Movies() { 5 let [searchParams, setSearchParams] = useSearchParams(); 6 let submit = useSubmit(); 7 let movies = useLoaderData(); 8 let totalFound = movies.totalCount; 9 let totalShow = movies.showCount; 10 11 return ( 12 <div>
<h1>Movies</h1>
<Form method="get">
<input onChange={e => submit(e.currentTarget.form)}
id="searchBar" name="searchTerm" placeholder="Search movies..." />
<p>Showing {totalShow} of total {totalFound} movies found</p>
</Form>
<ul>
{movies.documents.map(movie => (
<li key={movie.title}>
<Link to={movie.title}>{movie.title}</Link>
</li>
))}
</ul>
</div> 13 ); 14 }
As you can see in the return clause, we have a title named “Movies,” an input inside a “get” form to post a search input if requested. We will shortly explain how forms are convenient when working with Remix. Additionally, there is a link list of the retrieved movies documents. Using the <Link>
component from Remix allows us to create links to each individual movie name. This will allow us to pass the title as a path parameter and trigger the $title.jsx
component, which we will build shortly.
The data is retrieved using useLoaderData()
which is a helper function provided by the framework to retrieve data from the server-side “loader” function.
#The Loader Function
The interesting part is the loader()
function. Let's create one to first retrieve the first 100 movie documents and leave the search for later.
Add the following code to the movies/index.jsx
file.
1 export let loader = async ({ request }) => { 2 let pipeline = [{ $limit: 100 }]; 3 4 let data = JSON.stringify({ 5 collection: "movies", 6 database: "sample_mflix", 7 dataSource: process.env.CLUSTER_NAME, 8 pipeline 9 }); 10 11 let config = { 12 method: 'post', 13 url: `${process.env.DATA_API_BASE_URL}/action/aggregate`, 14 headers: { 15 'Content-Type': 'application/json', 16 'Access-Control-Request-Headers': '*', 17 'api-key': process.env.DATA_API_KEY 18 }, 19 data 20 }; 21 22 let movies = await axios(config); 23 let totalFound = await getCountMovies(); 24 25 return { 26 showCount: movies?.data?.documents?.length, 27 totalCount: totalFound, 28 documents: movies?.data?.documents 29 }; 30 }; 31 32 const getCountMovies = async (countFilter) => { 33 let pipeline = countFilter ? 34 [{ $match: countFilter }, { $count: 'count' }] : 35 [{ $count: 'count' }]; 36 37 let data = JSON.stringify({ 38 collection: "movies", 39 database: "sample_mflix", 40 dataSource: process.env.CLUSTER_NAME, 41 pipeline 42 }); 43 44 let config = { 45 method: 'post', 46 url: `${process.env.DATA_API_BASE_URL}/action/aggregate`, 47 headers: { 48 'Content-Type': 'application/json', 49 'Access-Control-Request-Headers': '*', 50 'api-key': process.env.DATA_API_KEY 51 }, 52 data 53 }; 54 55 let result = await axios(config); 56 57 return result?.data?.documents[0]?.count; 58 }
Here we start with an aggregation pipeline to just limit the first 100 documents for our initial view pipeline = [ {$limit : 100}];
. This pipeline will be passed to our REST API call to the Data API endpoint:
1 let data = JSON.stringify({ 2 collection: "movies", 3 database: "sample_mflix", 4 dataSource: process.env.CLUSTER_NAME, 5 pipeline 6 }); 7 8 let config = { 9 method: 'post', 10 url: `${process.env.DATA_API_BASE_URL}/action/aggregate`, 11 headers: { 12 'Content-Type': 'application/json', 13 'Access-Control-Request-Headers': '*', 14 'api-key': process.env.DATA_API_KEY 15 }, 16 data 17 }; 18 19 let result = await axios(config);
We place the API key and the URL from the secrets file we created earlier as environment variables. The results array will be returned to the UI function:
1 return result?.data?.documents[0]?.count;
To run the application, we can go into the main folder and execute the following command:
1 npm run dev
The application should start on http://localhost:3000
URL.
#Adding a Search Via Atlas Text Search
For the full text search capabilities of this demo, you need to create a dynamic Atlas Search index on database sample_mflix
collection movies
(use default dynamic mappings). Require version 4.4.11+ (free tier included) or 5.0.4+ of the Atlas cluster for the search metadata and facet searches we will discuss later.
Since we have a <Form>
Remix component submitting the form input, data typed into the input box will trigger a data reload. The <Form>
reloads the loader function without refreshing the entire page. This will naturally resubmit the URL as /movies?searchTerm=<TYPED_VALUE>
and here is why it's easy to use the same loader function, extract to URL parameter, and add a search logic by just amending the base pipeline:
1 let url = new URL(request.url); 2 let searchTerm = url.searchParams.get("searchTerm"); 3 4 const pipeline = searchTerm ? 5 [ 6 { 7 $search: { 8 index: 'default', 9 text: { 10 query: searchTerm, 11 path: { 12 'wildcard': '*' 13 } 14 } 15 } 16 }, { $limit: 100 }, { "$addFields": { meta: "$$SEARCH_META" } } 17 ] : 18 [{ $limit: 100 }];
In this case, the submission of a form will call the loader function again. If there was a searchTerm
submitted in the URL, it will be extracted under the searchTerm
variable and create a $search
pipeline to interact with the Atlas Search text index.
1 text: { 2 query: searchTerm, 3 path: { 4 'wildcard': '*' 5 } 6 }
Additionally, there is a very neat feature that allow us to get the metadata for our search—for example, how many matches were for this specific keyword (as we don’t want to show more than 100 results).
1 { "$addFields" : {meta : "$$SEARCH_META"}}
When wiring everything together, we get a working searching functionality, including metadata information on our searches.
Now, if you noticed, each movie title is actually a link redirecting to ./movies/<TITLE>
url. But why is this good, you ask? Remix allows us to build parameterized routes based on our URL path parameters.
#movies/$title.jsx
File
The movies/$title.jsx
file will show each movie's details when loaded. The magic is that the loader function will get the name of the movie from the URL. So, in case we clicked on “Home Alone,” the path will be http:/localhost:3000/movies/Home+Alone
.
This will allow us to fetch the specific information for that title.
Open the movies/$title.jsx
file we created earlier, and add the following:
1 import { Link, useLoaderData } from "remix"; 2 import invariant from "tiny-invariant"; 3 4 const axios = require('axios'); 5 6 export let loader = async ({ params }) => { 7 invariant(params.title, "expected params.title"); 8 9 let data = JSON.stringify({ 10 collection: "movies", 11 database: "sample_mflix", 12 dataSource: process.env.CLUSTER_NAME, 13 filter: { title: params.title } 14 }); 15 16 let config = { 17 method: 'post', 18 url: process.env.DATA_API_BASE_URL + '/action/findOne', 19 headers: { 20 'Content-Type': 'application/json', 21 'Access-Control-Request-Headers': '*', 22 'api-key': process.env.DATA_API_KEY 23 }, 24 data 25 }; 26 27 let result = await axios(config); 28 let movie = result?.data?.document || {}; 29 30 return { 31 title: params.title, 32 plot: movie.fullplot, 33 genres: movie.genres, 34 directors: movie.directors, 35 year: movie.year, 36 image: movie.poster 37 }; 38 };
The findOne
query will filter the results by title. The title is extracted from the URL params provided as an argument to the loader function.
The data is returned as a document with the needed information to be presented like “full plot,” “poster,” “genres,” etc.
Let’s show the data with a simple html layout:
1 export default function MovieDetails() { 2 let movie = useLoaderData(); 3 4 return ( 5 <div>
<h1>{movie.title}</h1>
{movie.plot}
<br></br>
<div styles="padding: 25% 0;" class="tooltip">
<li>
Year
</li>
<Link class="tooltiptext" to={"../movies?filter=" + JSON.stringify({ "year": movie.year })}>{movie.year}</Link>
</div>
<br />
<div styles="padding: 25% 0;" class="tooltip">
<li>
Genres
</li>
<Link class="tooltiptext" to={"../movies?filter=" + JSON.stringify({ "genres": movie.genres })}>{movie.genres.map(genre => { return genre + " | " })}</Link>
</div>
<br />
<div styles="padding: 25% 0;" class="tooltip">
<li>
Directors
</li>
<Link class="tooltiptext" to={"../movies?filter=" + JSON.stringify({ "directors": movie.directors })}>{movie.directors.map(director => { return director + " | " })}</Link>
</div>
<br></br>
<img src={movie.image}></img>
</div> 6 ); 7 }
#facets/index.jsx
File
MongoDB Atlas Search introduced a new feature complementing a very common use case in the text search world: categorising and allowing a faceted search. Facet search is a technique to present users with possible search criteria and allow them to specify multiple search dimensions. In a simpler example, it's the search criteria panels you see in many commercial or booking websites to help you narrow your search based on different available categories.
Additionally, to the different criteria you can have in a facet search, it adds better and much faster counting of different categories. To showcase this ability, I have created a new route called facets
and added an additional page to show counts per genre under routes/facets/index.jsx
. Let’s look at its loader function:
1 export let loader = async ({ request }) => { 2 let pipeline = [ 3 { 4 $searchMeta: { 5 facet: { 6 operator: { 7 range: { 8 path: "year", 9 gte: 1900 10 } 11 }, 12 facets: { 13 genresFacet: { 14 type: "string", 15 path: "genres" 16 } 17 } 18 } 19 } 20 } 21 ]; 22 23 let data = JSON.stringify({ 24 collection: "movies", 25 database: "sample_mflix", 26 dataSource: process.env.CLUSTER_NAME, 27 pipeline 28 }); 29 30 let config = { 31 method: "post", 32 url: process.env.DATA_API_BASE_URL + "/action/aggregate", 33 headers: { 34 "Content-Type": "application/json", 35 "Access-Control-Request-Headers": "*", 36 "api-key": process.env.DATA_API_KEY 37 }, 38 data 39 }; 40 41 let movies = await axios(config); 42 43 return movies?.data?.documents[0]; 44 };
It uses a new stage called $searchMeta and two facet stages: one to make sure that movies start from a date (1900) and that we aggregate counts based on genres field:
1 facet: { 2 operator: { 3 range: { 4 path: "year", 5 gte: 1900 6 } 7 }, 8 facets: { 9 genresFacet: { 10 type: "string", 11 path: "genres" 12 } 13 } 14 }
To use the facet search, we need to amend the index and add both fields to types for facet. Editing the index is easy through the Atlas visual editor. Just click [...]
> “Edit with visual editor.”
An output document of the search query will look like this:
1 {"count":{"lowerBound":23494}, 2 "facet":{"genresFacet":{"buckets":[{"_id":"Drama","count":13771}, 3 {"_id":"Comedy","count":7017}, 4 {"_id":"Romance","count":3663}, 5 {"_id":"Crime","count":2676}, 6 {"_id":"Thriller","count":2655}, 7 {"_id":"Action","count":2532}, 8 {"_id":"Documentary","count":2117}, 9 {"_id":"Adventure","count":2038}, 10 {"_id":"Horror","count":1703}, 11 {"_id":"Biography","count":1401}] 12 }}}
Once we route the UI page under facets demo, the table of genres in the UI will look as:
#Adding Clickable Filters Using Routes
To make the application even more interactive, I have decided to allow clicking on any of the genres on the facet page and redirect to the movies search page with movies?filter={genres : <CLICKED-VALUE>}
:
1 <div class="tooltip"> 2 <Link to={ "../movies?filter=" + JSON.stringify({ genres: bucket._id }) } >
{bucket._id}
</Link>
<span class="tooltiptext"> 3 Press to filter by "{bucket._id}" genre 4 </span> 5 </div>
Now, every genre clicked on the facet UI will be redirected back to /movies?filter={generes: <VALUE-BUCKET._id>}
—for example, /movies?filter={genres : "Drama"}
.
This will trigger the movies/index.jsx
loader function, where we will add the following condition:
1 let filter = JSON.parse(url.searchParams.get("filter")); 2 ... 3 4 else if (filter) { 5 pipeline = [ 6 { 7 "$match": filter 8 },{$limit : 100} 9 ] 10 }
Look how easy it is with the aggregation pipelines to switch between a regular match and a full text search.
With the same approach, we can add any of the presented fields as a search criteria—for example, clicking directors on a specific movie details page passing /movies?filter={directors: [ <values> ]}
.
Click a filtered field (eg. "Directors") | Redirect to filtered movies list |
---|---|
![]() | ![]() |
#Wrap Up
Remix has some clever and renewed concepts for building React-based web applications. Having server and client code coupled together inside moduled and parameterized by URL JS files makes developing fun and productive.
The MongoDB Atlas Data API comes as a great fit to easily access, search, and dice your data with simple REST-like API syntax. Overall, the presented stack reduces the amount of code and files to maintain while delivering best of class UI capabilities.
I recommend you check out the full code at the following github repo and get started with your new application using MongoDB Atlas today!
If you have questions, please head to our developer community website where the MongoDB engineers and the MongoDB community will help you build your next big idea with MongoDB.