SvelteKit Page Reaction Component with Upstash Redis
I made a reactions component in SvelteKit that uses Upstash Redis to store the user reactions. It uses a SvelteKit form action to submit the reaction to the server. Itās a nice example of how to use Upstash with SvelteKit.
Iāll go through creating the project so you can follow along if you like or you can Tl;Dr and go to the example.
Create the Upstash Redis database
Upstash make it really straightforward to create a Redis database.
Go to https://console.upstash.com/login and create an account if you donāt have one already.
In the Redis databases section Iāll click on the āCreate Databaseā button. Iām then prompted to give the database a name, Iāll call it sveltekit-reactions
.
For the type I can choose between āGlobalā or āRegionalā. Iāll go with the default āGlobalā option.
The primary region Iāll go with where the project will be built which will eventually be on Vercel so Iāll go with us-west-2
. For the read region Iāll go with us-west-1
.
Iāll leave the rest of the options as the default and click the āCreateā button.
Take note of the āREST APIā section here, Iāll need the UPSTASH_REDIS_REST_URL
and the UPSTASH_REDIS_REST_TOKEN
to go into the .env
file in the project.
Which brings me to the next step.
Create the SvelteKit project
Aight! Now I can scaffold out the SvelteKit project. Iāll add in the terminal commands if youāre following along, Iāll kick off the SvelteKit CLI with the pnpm create
command.
pnpm create svelte sveltekit-reactions
Iāll pick the following options:
create-svelte version 5.0.2 ā Welcome to SvelteKit! ā ā Which Svelte app template? ā Skeleton project ā ā Add type checking with TypeScript? ā Yes, using TypeScript syntax ā ā Select additional options (use arrow keys/space bar) ā ā¼ Add ESLint for code linting ā ā¼ Add Prettier for code formatting ā ā¼ Add Playwright for browser testing ā ā¼ Add Vitest for unit testing ā
Then change directory into the project and install the dependencies I need, right now Iāll install @upstash/redis
and @upstash/ratelimit
as dev dependencies.
cd sveltekit-reactions pnpm i -D @upstash/redis @upstash/ratelimit
So Iām not messing around with styling Iāll install Tailwind CSS with the daisyUI and Tailwind typography plugins with svelte-add
.
npx svelte-add@latest tailwindcss --daisyui --typography # install configured dependencies pnpm i
Now a quick check to see if everything is working as expected.
pnpm run dev
Sweet! So, now onto creating the form component with the reactions.
Create the reactions component
Iāll create the folders and files I need now for the project. First, the reactions component which Iām going to put in the src/lib/components
directory. The -p
flag will create the parent directory if it doesnāt exist.
Then create additional files for configuring the component, a utils file for re-useable functions and another file for the Upstash Redis client.
Iāll do that with the following commands.
# make the components lib directory mkdir src/lib/components -p # make the reactions component touch src/lib/components/reactions.svelte touch src/lib/{config,redis,utils}.ts # add the page server file fo the form action touch src/routes/+page.server.ts
The component will be a form that will submit the reaction to the server via a SvelteKit form action.
Iāll scaffold out the component first then move onto the form action.
Rather than have a predefined set of reactions Iāll make it so that the user can add their own reactions in the src/lib/config.ts
file.
Iāll also add in the config for the Upstash Ratelimit.slidingWindow
here as well, ten requests inside a ten second window. (š more on this later)
export const reactions = [ { type: 'likes', emoji: 'š' }, { type: 'hearts', emoji: 'ā¤ļø' }, { type: 'poops', emoji: 'š©' }, { type: 'parties', emoji: 'š' }, ] export const limit_requests = 10 export const limit_window = '10 s'
Then in the src/lib/components/reactions.svelte
file Iāll import the reactions from the config file and use them in the component.
<script lang="ts"> import { reactions } from '$lib/config' export let path: string | null = '/' </script> <div class="flex justify-center"> <form method="POST" action="/?path={path}" class="grid grid-cols-2 gap-5 sm:flex" > {#each reactions as reaction} <button name="reaction" type="submit" value={reaction.type} class="btn btn-primary shadow-xl text-3xl font-bold" > <span> {reaction.emoji} </span> </button> {/each} </form> </div>
Iāve added some Tailwind and daisyUI classes to the form for some basic styling.
So, for now I just want to render out the emoji reaction as Iāve not wired up the count from redis yet.
I have added a POST
method to the form and a name
and type
attribute to the button. This will be used in the form action to get the value of the button that was clicked.
I also added in the action
attribute which points to where the action is located, in my case Iām going to create the action in the src/routes/+page.server.ts
file so Iāll use /
for the route.
Iāll also add in a path
prop which Iāll need to identify the page that the reaction was submitted from, Iāll default it to the index /
if thereās nothing passed. I can then pass the path
to the form action as a query parameter, so, on the server, I can get the path (url.searchParams.get('path')
) for use in identifying where the reaction came from.
So I can see whatās going on with the component as I build it out Iāll stick the component on the index page.
<script lang="ts"> import Reactions from '$lib/components/reactions.svelte' </script> <h1>Welcome to SvelteKit</h1> <p> Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation </p> <Reactions />
If I click one of the buttons now I get a 405
error telling me that a POST
method is not allowed as there are no actions for the page.
Iāll create the form action next.
Create the form action
Now I want to get the form action working so I can get the value of the button that was clicked and send it to the server.
In the src/routes/+page.server.ts
file Iāll add in an actions object, in this case Iām going to need only the default action.
In the default action Iāll need the form data which I can get out of the event.request
which will be name
and value
of the button that was clicked. I can then get the reaction
out of the data
object.
The last thing Iāll need is the path
which will be the page the component is on. In the previous section I added a path
prop to the component currently itās defaulting to /
.
For now I want to validate the action is working so Iāll just log out the data to the console.
export const actions = { default: async ({ request, url }) => { const data = await request.formData() const reaction = data.get('reaction') const path = url.searchParams.get('path') console.log('=====================') console.log(data) console.log(reaction) console.log(path) console.log('=====================') return {} }, } export const load = async () => { return {} }
Iāll return and empty object for the default
action and the load
function for now.
Clicking on one of the buttons now I can see the data in the terminal where the dev server is running.
===================== FormData { [Symbol(state)]: [ { name: 'reaction', value: 'likes' } ] } likes / =====================
Cool, so I now have the base of what I want to store in Redis.
Add the Redis client
Now Iāll set up the redis client, Iāll first need to create a .env
file to add the Upstash API keys to. Iāll create the .env
file in the root of the project from the terminal.
touch .env
Then get the REST API keys from my Upstash dashboard, Iāll scroll to the REST API section, select the .env
option then use the handy copy option and paste them into the .env
file.
Now I can import the keys into the src/lib/redis.ts
file and create the Redis client and initialise Upstash Ratelimit. Iāll also add in the config for the Upstash Ratelimit.slidingWindow
here as well.
import { building } from '$app/environment' import { UPSTASH_REDIS_REST_TOKEN, UPSTASH_REDIS_REST_URL, } from '$env/static/private' import { Ratelimit } from '@upstash/ratelimit' import { Redis } from '@upstash/redis' import { limit_requests, limit_window } from './config' let redis: Redis let ratelimit: Ratelimit if (!building) { redis = new Redis({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN, }) ratelimit = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(limit_requests, limit_window), }) } export { ratelimit, redis }
Iām checking if the app is building and if itās not Iāll create the Redis client and initialise Upstash Ratelimit and export these for use in the src/routes/+page.server.ts
file.
Add reactions to Redis
Now I can check the connection is working and start adding the reactions to the Upstash Redis database on button click.
I wonāt go into the specifics of Redis here as there are plenty of resources out there for that. Essentially itās a key value pair, the key in this case being the reaction and the page it was clicked on.
I want to know the page the reaction was clicked on so thatās why Iām passing in the path
to the form action. So when I create the key I can use the path
and the reaction
to create a unique key.
If I use the component on the about page and someone clicks the like button the key in the Redis database will be about:likes
. Iām then using the incr
method to increment the value of the key by one.
import { redis } from '$lib/redis.js' export const actions = { default: async ({ request, url }) => { const data = await request.formData() const reaction = data.get('reaction') const path = url.searchParams.get('path') const redis_key = `${path}:${reaction}` const result = await redis.incr(redis_key) return { success: true, status: 200, reaction: reaction, path: path, count: result, } }, } export const load = async () => { return {} }
Once the key is created and the value is incremented I can return the data to the client. Iāll return the reaction
, path
and the count
which is the value of the key.
I can now receive the data
as a prop to the component.
Get Redis data into component
Ok, in my component/form I can now accept a data
prop which will have the reaction
, path
and count
from the server in it. But the data from the server isnāt going back to the component itās going back to where the component is being used.
So, in my index page Iāll need to accept the data
prop coming back from the server (form action) which I can then pass to the component.
On the index page Iāll accept the data
prop to the page and pass that onto the component.
Iāll also add in a pre
tag to visually see the shape of the data.
<script lang="ts"> import Reactions from '$lib/components/reactions.svelte' export let data: any </script> <pre>{JSON.stringify(data, null, 2)}</pre> <h1>Welcome to SvelteKit</h1> <p> Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation </p> <Reactions {data} />
Now I can pass the data
prop to the component and use it to show the count of the reaction along with another pre
tag to show the shape of the data.
<script lang="ts"> import { reactions } from '$lib/config' export let data: any </script> <pre>{JSON.stringify(data, null, 2)}</pre> <div class="flex justify-center"> <form method="POST" action="/" class="grid grid-cols-2 gap-5 sm:flex" > {#each reactions as reaction} <button name="reaction" type="submit" value={reaction.type} class="btn btn-primary shadow-xl text-3xl font-bold" > <span> {reaction.emoji} </span> </button> {/each} </form> </div>
Yes, Iām using an any
type here, Iāll fix that later. For now I want to see the data from the server.
The data for both the page and the component is showing an empty object at the moment because I havenāt loaded the data from the server yet.
In my src/routes/+page.server.ts
file Iāll need to get the data from Redis for each reaction type for the path of the page.
First up Iāll get my reaction types from the config file and pull out the reaction.type
then use that to map over and get the data from Redis with a Promise.all
and then return the data.
export const load = async ({ url: { pathname } }) => { const reaction_types = reactions.map(reaction => reaction.type) const promises = reaction_types.map(reaction => redis.get(`${pathname}:${reaction}`), ) const results = await Promise.all(promises) const count = {} as any reaction_types.forEach((reaction, index) => { count[reaction] = Number(results[index]) || 0 }) return { count } }
Again! Iāll come onto the any
type later.
Checking the index page I now get the reactions data loaded on both the page and in the component.
Clicking on a reaction button now increments the count and I can see the result in the pre
tag, I can remove these now.
Show the count
Now to show the count of each reaction type. Iāll add a span
tag inside the button and show the count there. I can pick the count out of the data
prop thatās being passed in for each reaction type.
<script lang="ts"> import { reactions } from '$lib/config' export let path: string | null = '/' export let data: any </script> <div class="flex justify-center"> <form method="POST" action="/?path={path}" class="grid grid-cols-2 gap-5 sm:flex" > {#each reactions as reaction} <button name="reaction" type="submit" value={reaction.type} class="btn btn-primary shadow-xl text-3xl font-bold" > <span> {reaction.emoji} {data?.count?.[reaction.type] || 0} </span> </button> {/each} </form> </div>
Iāll sort out the any
type now.
TypeScript types
Iāll address the any
type now and add in some TypeScript types for the data
prop. So I need a way to represent the Redis data, it looks something like this:
{ "count": { "likes": 3, "hearts": 1, "poops": 0, "parties": 0 } }
Keys are strings and the values are numbers. So Iāll create a ReactionCount
interface to represent the count.
Iāll also create a ReactionsData
interface to represent the data coming back from the server. This will have a path
and a count
which is the ReactionCount
interface.
interface ReactionCount { [key: string]: number } interface ReactionsData { path: string count: ReactionCount }
Iāll put these into the provided app.d.ts
file that comes with the SvelteKit skeleton template. The full src/app.d.ts
file looks like this:
// See https://kit.svelte.dev/docs/types#app // for information about these interfaces declare global { namespace App { // interface Error {} // interface Locals {} // interface PageData {} // interface Platform {} } interface ReactionCount { [key: string]: number } interface ReactionsData { path: string count: ReactionCount } } export {}
Iāll replace the any
type for data
in the component and on the index page with the ReactionsData
interface. Also in the load
function on the +page.server.ts
file.
Use enhance
Up till now each time I click a reaction button the page reloads and the data is fetched from Redis. Iāll use the SvelteKit enhance
function so thereās no page reload each time the buttons are clicked.
Iāll expand on the enhance
function later when I rate limit the reactions. Hereās the reactions.svelte
component now, with the types and enhance
added:
<script lang="ts"> import { enhance } from '$app/forms' import { reactions } from '$lib/config' export let path: string | null = '/' export let data: ReactionsData </script> <div class="flex justify-center"> <form method="POST" action="/?path={path}" use:enhance class="grid grid-cols-2 gap-5 sm:flex" > {#each reactions as reaction} <button name="reaction" type="submit" value={reaction.type} class="btn btn-primary shadow-xl text-3xl font-bold" > <span> {reaction.emoji} {data?.count?.[reaction.type] || 0} </span> </button> {/each} </form> </div>
Now I can spam the reaction buttons and the page doesnāt reload each time.
Rate limit the reactions
So, about that button spamming! Iāll add the rate limit to the src/routes/+page.server.ts
file.
Iāll import Upstash ratelimit which will record the responses from the current IP address temporarily (15 seconds) in Redis. To ge the IP address Iāll use the getClientAddress
function from SvelteKit.
If thereās more than 10 responses in that time Iāll apply the rate limit and throw an error back to the client. Iāll import the SvelteKit fail
function to do this.
import { reactions } from '$lib/config.js' import { ratelimit, redis } from '$lib/redis.js' import { fail } from '@sveltejs/kit' export const actions = { default: async ({ request, url, getClientAddress }) => { const ip = getClientAddress() const rate_limit_attempt = await ratelimit.limit(ip) if (!rate_limit_attempt.success) { const time_remaining = Math.floor( (rate_limit_attempt.reset - new Date().getTime()) / 1000, ) return fail(429, { error: `Rate limit exceeded. Try again in ${time_remaining} seconds`, time_remaining, }) } const data = await request.formData() const reaction = data.get('reaction') const path = url.searchParams.get('path') const redisKey = `${path}:${reaction}` const result = await redis.incr(redisKey) return { success: true, status: 200, reaction: reaction, path: path, count: result, } }, }
Now spamming the button on the client I can add ten reactions and then the count stop incrementing. The only feedback I get that the rate limit has been applied is the error message in the network tab in the browser console.
Iāll customise the use:enhance
function in the reactions.svelte
component to get the ActionResult
from the server. For now Iāll just log the result to the console then choose not to reset the form.
use:enhance={() => { return ({ update, result }) => { console.log(JSON.stringify(result, null, 2)) update({ reset: false }) } }}
Now if I spam a reaction button to go over the rate limit I get the following logged out to the browser console:
{ "type": "failure", "status": 429, "data": { "error": "Rate limit exceeded. Try again in 7 seconds", "time_remaining": 7 } }
When Iām not being rate limited the output looks like this:
{ "type": "success", "status": 200, "data": { "success": true, "status": 200, "reaction": "parties", "path": "/", "count": 274 } }
So I can use the type
property to check if the response was a success or failure. If itās a failure Iāll disable the buttons for the time remaining.
For the time_remaining
thatās passed from Redis ratelimit to the action I need a way to handle the result.
Iāll create a Svelte store for button_disabled
and a handle_result
function that will take in the result ("success"
or "failure"
) and set the store to true. After the timeout the store will be set back to true.
let button_disabled = writable(false) const handle_result = (result: ActionResult) => { if (result.type === 'failure') { $button_disabled = true setTimeout( () => { $button_disabled = false }, result?.data?.time_remaining * 1000, ) } }
In the use:enhance
function Iāll call the handle_result
function, then I can set the button disabled
attribute to the store value.
Hereās the full reactions.svelte
component now:
<script lang="ts"> import { enhance } from '$app/forms' import { reactions } from '$lib/config' import type { ActionResult } from '@sveltejs/kit' import { writable } from 'svelte/store' export let path: string | null = '/' export let data: ReactionsData let button_disabled = writable(false) const handle_result = (result: ActionResult) => { if (result.type === 'failure') { $button_disabled = true setTimeout(() => { $button_disabled = false }, result?.data?.time_remaining * 1000) } } </script> <div class="flex justify-center"> <form method="POST" action="/?path={path}" use:enhance={() => { return ({ update, result }) => { handle_result(result) console.log(JSON.stringify(result, null, 2)) update({ reset: false }) } }} class="grid grid-cols-2 gap-5 sm:flex" > {#each reactions as reaction} <button name="reaction" type="submit" value={reaction.type} class="btn btn-primary shadow-xl text-3xl font-bold" disabled={$button_disabled} > <span> {reaction.emoji} {data?.count?.[reaction.type] || 0} </span> </button> {/each} </form> </div>
Now spamming the reactions buttons they get disabled and set back to enabled once the timeout has passed.
Use the component on a different page
Up until now the component has just been used on the index page. The intention when I started out doing this is to be able to use the component on any page.
Iāll create an about page and use the component in there, Iāll also need a +page.server.ts
file to go with the +page.svelte
file, Iāll create them now with a terminal command.
mkdir -p src/routes/about touch src/routes/about/{+page.svelte,+page.server.ts}
In the src/routes/about/+page.svelte
file Iāll import the component and also the SvelteKit page store so I can get the current path.
<script lang="ts"> import { page } from '$app/stores' import Reactions from '$lib/components/reactions.svelte' export let data: ReactionsData let path = $page.route.id </script> <Reactions {data} {path} />
In the src/routes/about/+page.server.ts
file Iāll use the same load
as whatās in the src/routes/index/+page.server.ts
file.
export const load = async ({ url: { pathname } }) => { const reaction_types = reactions.map(reaction => reaction.type) const promises = reaction_types.map(reaction => redis.get(`${pathname}:${reaction}`), ) const results = await Promise.all(promises) const count = {} as any reaction_types.forEach((reaction, index) => { count[reaction] = Number(results[index]) || 0 }) return { count } }
Now if I check my Redis database I can see that the reactions for the about page has been added.
Refactor page server load and reactions actions š
Two parts to this refactor, the first is to refactor the page server load function, the second is to refactor the reactions actions.
The src/routes/+page.server.ts
load function is now duplicated across the index page and the about page, it makes sense to refactor this into a function that can be imported into the load
function of any +page.server.ts
file you want to use it in.
Over in the src/lib/utils.ts
file Iāll create a get_reaction_count
function that will take in the pathname
and return a ReactionCount
object.
import { reactions } from './config' import { redis } from './redis' const reaction_types = reactions.map(reaction => reaction.type) export async function get_reaction_count( pathname: string, ): Promise<ReactionCount> { const promises = reaction_types.map(reaction => redis.get(`${pathname}:${reaction}`), ) const results = await Promise.all(promises) const count = {} as ReactionCount reaction_types.forEach((reaction, index) => { count[reaction] = Number(results[index]) || 0 }) return count }
Then in the +page.server.ts
files I can import the function and use it in the load
function.
export const load = async ({ url: { pathname } }) => { + const count = await get_reaction_count(pathname) - const reaction_types = reactions.map(reaction => reaction.type) - const promises = reaction_types.map(reaction => - redis.get(`${pathname}:${reaction}`), - ) - const results = await Promise.all(promises) - const count = {} as any - reaction_types.forEach((reaction, index) => { - count[reaction] = Number(results[index]) || 0 - }) return { count } }
The server action for adding the reactions to redis is currently on the index page src/routes/index/+page.server.ts
file.
Iāll move this into itās own folder so it can be better identified. The +page.server.ts
file can go into itās own folder, Iāll make the folders for that to go into now:
# create the folder mkdir -p src/routes/api/reactions # copy the index +page.server.ts file into the new folder cp src/routes/+page.server.ts src/routes/api/reactions/+page.server.ts
Iām not going to need the load
function in this file so Iāll remove it.
In the src/routes/+page.server.ts
file Iāll remove the actions
object so thereās just the load
function importing the get_reaction_count
from the src/lib/utils.ts
file.
import { get_reaction_count } from '$lib/utils.js' export const load = async ({ url: { pathname } }) => { const count = await get_reaction_count(pathname) return { count } }
Thatās it, any code duplication has been taken care of and Iāve abstracted out the server action into itās own folder so it can be quickly identified.
Conclusion
I successfully created a reactions component with SvelteKit, powered by Upstash Redis for the data storage, and ensured its fair use with rate limiting.
I designed this component with the flexibility so it can be added on any page of a SvelteKit project, this should give an indication of user engagement for any project itās used on.
This walkthrough, while thorough, only touches the surface of what can be achieved with SvelteKit and Redis. The most important takeaway for me is to keep exploring, experimenting and building - because thatās where the real learning happens!
Example
Ok, Iāve gone through the steps to create this component. If you just want to check out the example of the source code you can see the example repo on GitHub and the live demo.
Thanks
Thanks to Jamie Barton for giving me the idea for this component where he does something similar with Grafbase. Thereās also the Upstash claps repo which is a Next.js example.
Thanks to Geoff Rich for his great posts on rate limiting with Redis and SvelteKit on the Upstash blog.
Also thanks to Kevin Ć berg Kultalahti for feedback on the structure of the project.
There's a reactions leaderboard you can check out too.