Skip to main content

Building SvelteKit Applications with Serverless Redis

· 11 min read
Geoff Rich

SvelteKit is the upcoming full-stack application framework for Svelte, the UI framework that builds your app at compile time to produce smaller, faster JavaScript. While SvelteKit allows you to write server-side logic using endpoints, it's up to you how you want to persist your application's data.

In this post, I will show how to store data using Redis in a SvelteKit application. We'll use Redis to cache movie API responses and display a random movie, using data from The Movie Database (TMDB) API.

You will either need a Redis connection string or run Redis locally to follow along with the demo. If you don't already have a Redis instance, I recommend Upstash. Like SvelteKit, it's optimized for serverless applications and is free if you aren't making a lot of requests (the cap is 10k/day at time of writing). Regardless of where you get your Redis instance, you should make sure it is located close to where your application is deployed to reduce latency.

Prerequisites#

  • Basic familiarity with SvelteKit (e.g. pages vs endpoints, loading, retrieving query and params for a given page)
  • A TMDB API key and Redis instance (e.g. on Upstash) if you want to run or deploy the demo

Starter overview#

Clone the starter repo. The main branch has the end result, so checkout the initial branch to follow along with this post. If you want to push your changes, fork the repo first.

git clone https://github.com/geoffrich/movie-search-redis.gitcd movie-search-redisgit checkout initial

This is a small SvelteKit application that allows searching for movies and viewing their details using the TMDB API. It uses TypeScript to make interacting with the API responses easier, but this is not a requirement to use Redis or SvelteKit. The following routes already exist (corresponding to the files in src/routes):

  • / renders the home page
  • /search renders a list of search results. It takes query and page as query params, e.g. ?query=star wars&page=3 shows the third page of results for "Star Wars"
  • /search.json is a server endpoint that queries the TMDB API and returns the list of results. It takes the same query parameters as /search
  • /movie/[id] renders details for a movie with the given ID, e.g. /movie/11
  • /movie/[id].json is a server endpoint that returns movie details from the TMDB API. Since the TMDB response returns more data than we use on the page, we adapt the response to return a subset of the data.

You can see the demo running on Netlify.

Note that the TMDB API calls only happen in the server endpoints. This is so our API key is not exposed to the client.

To run the demo locally, create a .env file in the root of the project and add your TMDB API key and Redis connection string (sample below). Then run npm install to install dependencies and run npm run dev to run the app.

TMDB_API_KEY=KEY_GOES_HEREREDIS_CONNECTION=CONNECTION_GOES_HERE

At runtime, we can access these values using process.env['TMDB_API_KEY'] or process.env['REDIS_CONNECTION']. The values are read from the .env file using dotenv in hooks.ts.

Caching the API response in Redis#

One improvement we can make to the existing project is to cache the API response for an individual movie in Redis. Currently, the app makes a request to the TMDB API every time a movie page is loaded. When we first make a request, we can store the API response in Redis so that we don't need to keep requesting the data from TMDB.

While the TMDB API is fast and doesn't necessarily need to be cached, this can be a useful technique for APIs that take a while to respond or that have request limits. It also provides a level of resiliency if the API were to go down.

We've already added ioredis as a dependency. This is a Redis client for Node that will help us interact with our Redis database.

Create the file src/lib/redis.ts. This file initializes the Redis client and exports it for use by other functions. It also contains some helper functions for getting keys. Add the code below to the file.

import Redis from "ioredis";
const connectionString = process.env["REDIS_CONNECTION"];
export const MOVIE_IDS_KEY = "movie_ids";
/** Return the key used to store movie details for a given ID in Redis */export function getMovieKey(id): string {  return `movie:${id}`;}
export default connectionString ? new Redis(connectionString) : new Redis();

Note that this implementation creates a single Redis client per serverless instance. Another option is to create and close a Redis connection per request. Upstash also has a REST API that does not require you to initialize a Redis connection. The best option depends on your app's latency and memory requirements.

Go to the /src/routes/movie/[id].json.ts file. Import the Redis client and the getMovieKey function from redis.ts.

import redis, { getMovieKey } from "$lib/redis";

Take a look at the getMovieDetailsFromApi function. It calls the TMDB API to get movie details and credits and returns them. Before returning the data, we want to store it in our Redis cache so that next time we retrieve the cached version instead of calling the API. Add a new cacheMovieResponse function to the file to cache the data in Redis.

async function cacheMovieResponse(id: number, movie, credits) {  try {    const cache: MovieDetails = {      movie,      credits,    };    // store movie response for 24 hours    await redis.set(getMovieKey(id), JSON.stringify(cache), "EX", 24 * 60 * 60);  } catch (e) {    console.log("Unable to cache", id, e);  }}

Each movie ID will be stored under a different key. For example, if we were retrieving information on the movie with ID 11, the key would be movie:11. The last two arguments to redis.set say that we should only cache the data for 24 hours (86400 seconds). We shouldn't cache the data forever—it violates the terms of use, and the data will eventually become stale.

Note that you need to stringify the JS object before you can store it in the cache. We also catch any exceptions thrown during this operation to make our endpoint more resilient. If we can't cache the data, we should still return the data from the API instead of throwing an unhandled exception.

We can now use the new cacheMovieResponse function inside getMovieDetailsFromApi to store the API response in the cache.

async function getMovieDetailsFromApi(id: number) {  const [movieResponse, creditsResponse] = await Promise.all([    getMovieDetails(id),    getCredits(id),  ]);  if (movieResponse.ok) {    const movie = await movieResponse.json();    const credits = await creditsResponse.json();
    // add this line    await cacheMovieResponse(id, movie, credits);
    return {      movie,      credits,    };  }
  return {    status: movieResponse.status,  };}

Now we have stored data in the cache, but we still need to retrieve the cached data. Let's add a function to read the movie details from the cache.

async function getMovieDetailsFromCache(  id: number): Promise<MovieDetails | Record<string, never>> {  try {    const cached: string = await redis.get(getMovieKey(id));    if (cached) {      const parsed: MovieDetails = JSON.parse(cached);      console.log(`Found ${id} in cache`);      return parsed;    }  } catch (e) {    console.log("Unable to retrieve from cache", id, e);  }  return {};}

Because the data was stored as a string, we need to parse it into an object to make it usable. Similar to the previous function, we log any exceptions, but do not allow them to escape the caching function. We can always fall back to retrieving data from the API.

Finally, we can call our caching function in the main request handler. If we find the data in the cache, we return it right away; otherwise, we read from the API as before.

export const get: RequestHandler = async function ({ params }) {    const { id: rawId } = params;    // validate and sanitize the input    const id = parseInt(rawId);    if (isNaN(id)) {        return {            status: 400        };    }
    // add these lines    const { movie, credits } = await getMovieDetailsFromCache(id);    if (movie && credits) {        return {            body: adaptResponse(movie, credits)        };    }
    // fallback to the API    const result = await getMovieDetailsFromApi(id);

You can see the final code for this endpoint in the demo repo.

With these changes, try navigating to a movie's page. When you refresh the page, you should see "Found id in cache" in the console log, indicating that we successfully stored and retrieved that movie from the cache.

Retrieving a random movie#

Redis can do more than just caching API responses. Let's look at how we can build a route that will redirect the user to a random movie.

This isn't as simple as picking a random number between 1 and 300000 and using that as the movie ID. Not every number in that range will correspond to a movie—for instance, there is no movie with an ID of 1 or 1000. It would also be tricky to keep track of what the maximum possible ID is, since that will always be changing as new movies are added. Instead, we'll select random movies using a two step process:

  1. When a search query is performed, place all the IDs returned in a Redis Set.
  2. When the /movie/random route is requested, retrieve a random member of that set and redirect to the corresponding movie detail page.

The possible random movies returned will start small, but grow larger as more searches are performed.

To populate the random set, update /src/routes/search.json.ts to the following.

import type { RequestHandler } from "@sveltejs/kit";import type { SearchResponse } from "$lib/types/tmdb";import redis, { MOVIE_IDS_KEY } from "$lib/redis";
const VOTE_THRESHOLD = 20;
export const get: RequestHandler = async function ({ query }) {  const searchQuery = query.get("query");  const page = query.get("page") ?? 1;  const response = await fetch(    `https://api.themoviedb.org/3/search/movie?api_key=${process.env["TMDB_API_KEY"]}&page=${page}&include_adult=false&query=${searchQuery}`  );  const parsed: SearchResponse = await response.json();
  // add these lines  const filteredMovies = parsed.results.filter(    (movie) => movie.vote_count >= VOTE_THRESHOLD  );  if (filteredMovies.length > 0) {    try {      await redis.sadd(MOVIE_IDS_KEY, ...filteredMovies.map((r) => r.id));    } catch (e) {      console.log(e);    }  }
  return {    body: parsed,  };};

Note that we aren't adding every movie returned to the set. I chose to filter out movies that don't have many votes, since those movies are less likely to be vetted by users. You can adjust VOTE_THRESHOLD to your liking.

With this change, searching for movies will start populating the set of movie IDs. Perform some searches to add some IDs to the set. For example:

After performing a few searches, you should have a set of random IDs stored in Redis. Let's create routes for the /movie/random endpoint and page.

src/routes/movie/random.json.ts

import type { RequestHandler } from "@sveltejs/kit";import redis, { MOVIE_IDS_KEY } from "$lib/redis";
export const get: RequestHandler = async function () {  const randomId = await redis.srandmember(MOVIE_IDS_KEY);  return {    body: randomId,  };};

This server endpoint uses SRANDMEMBER to pick a random ID from the movie ID set.

src/routes/movie/random.svelte

<script context="module" lang="ts">  import type { Load } from "@sveltejs/kit";
  export const load: Load = async function ({ fetch }) {    const result = await fetch(`/movie/random.json`);    if (result.ok) {      const id = await result.json();      return {        redirect: `/movie/${id}`,        status: 303,      };    }
    return {      status: result.status,      error: new Error("Could not retrieve random id"),    };  };</script>

The corresponding Svelte page only needs a load function since it does not render any UI. It calls the server endpoint we just added and redirects to the corresponding movie page.

That's all there is to it! Now, navigate to http://localhost:3000/movie/random. You should be automatically redirected to a random movie from the previous searches you performed. To make accessing this route easier, you can add it to the navigation in /src/routes/__layout.svelte

<header>  <nav>    <a href="/">Search</a>    <a href="/movie/random">Random</a>  </nav></header>

You can see this working in the live demo.

Wrapping up#

There are many more ways to use Redis, but I hope this post gave you a good understanding of the basics of integrating Redis in a SvelteKit app. You can see the final code on GitHub and the live demo on Netlify.

Have any questions? Reach out on Twitter. You can also find my other writing about Svelte on my blog.