·11 min read

Building SvelteKit Applications with Serverless Redis

Geoff RichGeoff Rich Svelte core team member (Guest Author)

This post was updated in December 2022 and is now compatible with SvelteKit 1.0.

SvelteKit is a framework for building web applications on top of 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, 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. routing and loading data)
  • 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.git
cd movie-search-redis
git checkout initial
git clone https://github.com/geoffrich/movie-search-redis.git
cd movie-search-redis
git 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 folders 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"
  • /movie/[id=id] renders details for a movie with the given ID, e.g. /movie/11. While not important for this tutorial, [id=id] indicates that there is a parameter named id that needs to be validated by the match function in src/params/id.ts.

If SvelteKit's routing system is unfamiliar to you, give the documentation a read first. You can see the demo running on Netlify.

Note that the TMDB API calls only happen in the +page.server.ts files, which only run on the server. 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_HERE
REDIS_CONNECTION=CONNECTION_GOES_HERE

At runtime, we can access these values by importing them from SvelteKit's $env module, which will automatically prevent sensitive environment variables from being accidentally exposed via client-side code.

import { TMDB_API_KEY } from "$env/static/private";
import { TMDB_API_KEY } from "$env/static/private";

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.

(You can also cache responses by setting cache headers with SvelteKit's setHeaders function. However, depending on your CDN, this may only reuse the data for requests from an individual user's browser.)

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_CONNECTION } from "$env/static/private";
import Redis from "ioredis";
 
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: number): string {
  return `movie:${id}`;
}
 
export default REDIS_CONNECTION ? new Redis(REDIS_CONNECTION) : new Redis();
import { REDIS_CONNECTION } from "$env/static/private";
import Redis from "ioredis";
 
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: number): string {
  return `movie:${id}`;
}
 
export default REDIS_CONNECTION ? new Redis(REDIS_CONNECTION) : 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=id]/+page.server.ts file. Import the Redis client and the getMovieKey function from redis.ts.

import redis, { getMovieKey } from "$lib/redis";
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: TMDB.Movie,
  credits: TMDB.MovieCreditsResponse,
) {
  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);
  }
}
async function cacheMovieResponse(
  id: number,
  movie: TMDB.Movie,
  credits: TMDB.MovieCreditsResponse,
) {
  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,
    };
  }
 
  console.log("Bad status from API", movieResponse.status);
  throw error(500, "unable to retrieve movie details from API");
}
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,
    };
  }
 
  console.log("Bad status from API", movieResponse.status);
  throw error(500, "unable to retrieve movie details from API");
}

If we get a bad response from the API, we use SvelteKit's error helper to show an error page.

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 = 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 {};
}
async function getMovieDetailsFromCache(
  id: number,
): Promise<MovieDetails | Record<string, never>> {
  try {
    const cached = 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 inside our load function. If we find the data in the cache, we return it right away; otherwise, we read from the API as before.

export const load: PageServerLoad = async function ({ params }) {
  const id = parseInt(params.id ?? '');
 
  // add these lines
  const { movie, credits } = await getMovieDetailsFromCache(id);
  if (movie && credits) {
    return {
      movie: adaptResponse(movie, credits)
    };
  }
 
  // fall back to the API
  const result = await getMovieDetailsFromApi(id);
export const load: PageServerLoad = async function ({ params }) {
  const id = parseInt(params.id ?? '');
 
  // add these lines
  const { movie, credits } = await getMovieDetailsFromCache(id);
  if (movie && credits) {
    return {
      movie: adaptResponse(movie, credits)
    };
  }
 
  // fall back to the API
  const result = await getMovieDetailsFromApi(id);

You can see the final code for this load function 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/+page.server.ts to the following.

import { TMDB_API_KEY } from "$env/static/private";
import redis, { MOVIE_IDS_KEY } from "$lib/redis";
import type { SearchResponse } from "$lib/types/tmdb";
 
import type { PageServerLoad } from "./$types";
 
const VOTE_THRESHOLD = 20;
 
export const load: PageServerLoad = async function ({ url, setHeaders }) {
  const searchQuery = url.searchParams.get("query");
  const page = url.searchParams.get("page") ?? 1;
  const response = await fetch(
    `https://api.themoviedb.org/3/search/movie?api_key=${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);
    }
  }
 
  setHeaders({
    "cache-control": "max-age=300",
  });
  return {
    searchResponse: parsed,
  };
};
import { TMDB_API_KEY } from "$env/static/private";
import redis, { MOVIE_IDS_KEY } from "$lib/redis";
import type { SearchResponse } from "$lib/types/tmdb";
 
import type { PageServerLoad } from "./$types";
 
const VOTE_THRESHOLD = 20;
 
export const load: PageServerLoad = async function ({ url, setHeaders }) {
  const searchQuery = url.searchParams.get("query");
  const page = url.searchParams.get("page") ?? 1;
  const response = await fetch(
    `https://api.themoviedb.org/3/search/movie?api_key=${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);
    }
  }
 
  setHeaders({
    "cache-control": "max-age=300",
  });
  return {
    searchResponse: 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 a route for the /movie/random page. Create a folder at src/routes/movie/random and a +page.server.ts file.

src/routes/movie/random/+page.server.ts

import { redirect } from "@sveltejs/kit";
import redis, { MOVIE_IDS_KEY } from "$lib/redis";
 
import type { PageServerLoad } from "./$types";
 
export const load: PageServerLoad = async function () {
  const randomId = await redis.srandmember(MOVIE_IDS_KEY);
  throw redirect(303, `/movie/${randomId}`);
};
import { redirect } from "@sveltejs/kit";
import redis, { MOVIE_IDS_KEY } from "$lib/redis";
 
import type { PageServerLoad } from "./$types";
 
export const load: PageServerLoad = async function () {
  const randomId = await redis.srandmember(MOVIE_IDS_KEY);
  throw redirect(303, `/movie/${randomId}`);
};

This server load function uses SRANDMEMBER to pick a random ID from the movie ID set and redirects the user to the corresponding page.

That's all there is to it! Now, navigate to http://localhost:5173/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>
<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.