Skip to content

Add Redis or Valkey caching to a Lovable app

7 min readLovableValkeyRedisCachingDatabases

Most Lovable apps hit the same wall around the time they pick up real users. A query is slow. An endpoint gets hammered. You want a place to stash hot state that is faster and more disposable than Postgres. The answer is Redis, or Valkey, which is the same protocol with a friendlier license. But there is a catch that trips people up, and an earlier version of this post got it wrong, so let me be straight about it before we write any code.

Lovable generates a Vite React single-page app. It is TypeScript, Tailwind, and shadcn/ui compiled into a bundle that ships to the browser. There is no server process in it. A Redis or Valkey client speaks raw TCP, and raw TCP needs a server to open the socket, so you cannot new Redis(...) from inside a Lovable app any more than you can open a Postgres connection from it. You also never want a cache password sitting in a browser bundle, which is exactly where a Lovable env var (import.meta.env.VITE_*) lands. So the cache code has to run somewhere with a server. On Lovable that place is a Supabase Edge Function. This is the same architecture problem covered in Connect a Postgres database to a Lovable app, and the fix has the same shape: put a small server between the browser and the data store.

Contents

Redis or Valkey?

If you do not know the difference, pick Valkey. The clients are the same (redis, node-redis, ioredis all speak the same protocol). The commands are the same. Everything through Redis 7.2 is the same, because that is where the two projects split. The difference is the license.

On March 21, 2024, Redis Ltd. dropped the permissive BSD-3-Clause license and moved Redis to a dual source-available model: the SSPL and the Redis Source Available License v2, starting with Redis 7.4 (Redis's own announcement). Neither of those is an OSI-approved open-source license. A week later, on March 28, the Linux Foundation forked the last BSD release, Redis 7.2.4, into Valkey, backed by AWS, Google Cloud, and Oracle among others. Valkey stayed BSD-3-Clause and has carried on with its own performance work and releases since. Redis later softened its stance and re-added an OSI license: Redis 8.0 in May 2025 is tri-licensed and includes AGPLv3 as an option. So Redis is open source again, just under a copyleft license rather than the permissive BSD it started with.

For a cache behind a Lovable app, none of that licensing drama touches your code. I reach for Valkey because it is permissively licensed and drop-in compatible, but the snippets below work identically against Redis. Redis vs Valkey: which to use in 2026 has the longer take if you want it.

Create the database

Go to layerbase.com/create/valkey, name it something like lovable-cache, and provision it. It takes about ten seconds. Valkey and Redis both run on the free tier, so a real cache costs nothing to start: Free is $0 for two databases, and there are no per-command meters, no per-request billing, and no bill that moves with your traffic. That last part matters more than it sounds for a cache, because the whole point of a cache is to absorb a lot of cheap reads. On a metered key-value service, "a lot of cheap reads" is the exact usage pattern that runs up the bill. Here it is flat.

The dashboard gives you a connection string in the standard shape:

text
rediss://default:<password>@your-host.cloud.layerbase.dev:6379

The rediss:// with two s characters means TLS. Use it as-is. The Layerbase listener requires TLS and refuses plain redis:// connections.

Already have a cache somewhere else (Upstash, Vercel KV, ElastiCache)? Pick Migrating from another platform in the create flow and it copies every key, type, and TTL across instead of starting empty.

Wire it into an Edge Function

An Edge Function is the one place a Lovable app has server-side compute. It runs on Deno, Deno can open a TCP socket, and Deno's npm compatibility lets you pull a normal Node client with an npm: specifier. I use node-redis (npm:redis) here because it connects cleanly under Deno with a URL, including TLS.

The connection string is a secret, so it lives as a function secret and never touches the frontend bundle:

bash
supabase secrets set VALKEY_URL="rediss://default:<password>@your-host.cloud.layerbase.dev:6379"

Then a minimal function that reads it from the Deno environment:

ts
// supabase/functions/cache-demo/index.ts
import { createClient } from 'npm:redis@4'

// Created once per worker, reused across warm invocations.
const redis = createClient({ url: Deno.env.get('VALKEY_URL')! })
redis.on('error', (err) => console.error('valkey error', err))
await redis.connect()

Deno.serve(async () => {
  await redis.set('ping', 'pong')
  const value = await redis.get('ping')
  return Response.json({ value })
})

That is the whole setup. VALKEY_URL stays server-side, the browser never sees it, and the rest is patterns.

Three patterns that pay for themselves

These are the three uses that earn back the cache in the first week.

Cache-aside for an expensive query

If you have a query that runs on every page load and only changes now and then (a trending list, a leaderboard, any aggregate), compute it once and serve the cached copy until it expires.

ts
import { createClient } from 'npm:redis@4'

const redis = createClient({ url: Deno.env.get('VALKEY_URL')! })
await redis.connect()

Deno.serve(async () => {
  const cached = await redis.get('trending:posts')
  if (cached) {
    return Response.json(JSON.parse(cached))
  }

  // Miss: do the expensive work once, then cache it for 5 minutes.
  const rows = await loadTrendingFromDatabase()
  await redis.set('trending:posts', JSON.stringify(rows), { EX: 300 })

  return Response.json(rows)
})

The first request in each window pays for the query. Every request after it for five minutes is a single key read. For a list that is identical for every visitor, that is usually all the caching you need.

Rate limiting

Anywhere users can hammer an endpoint (login, password reset, AI generation, payment retry), rate-limit it. A cache is the standard tool because the increment-and-check has to be atomic and cheap. And because this runs inside the Edge Function, reading a request header is fine here, unlike in the browser, where there is no reliable client IP to read in the first place.

ts
Deno.serve(async (req) => {
  const ip = req.headers.get('x-forwarded-for') ?? 'unknown'
  const key = `ratelimit:ai:${ip}`

  const count = await redis.incr(key)
  if (count === 1) {
    await redis.expire(key, 60) // window resets after 60 seconds
  }
  if (count > 10) {
    return new Response('Slow down', { status: 429 })
  }

  // ...run the AI generation and return the result
  return Response.json({ ok: true })
})

Ten requests per minute per IP. Adjust the numbers per endpoint. The counter and its TTL both live in Valkey, so it works even across cold starts and multiple function workers.

Checkpoint and draft storage

Auth sessions on Lovable belong to Supabase, so this is not about replacing your login. It is about the transient state that is too hot or too short-lived to deserve a Postgres row: a multi-step form draft, a wizard's progress, a long-running generation's checkpoint. Give it a key and a TTL and let it expire on its own.

ts
// Save a draft that self-expires in 24 hours.
await redis.set(`draft:${userId}`, JSON.stringify(state), {
  EX: 60 * 60 * 24,
})

// Read it back on the next step.
const raw = await redis.get(`draft:${userId}`)
const draft = raw ? JSON.parse(raw) : null

No cleanup job, no deleted_at column, no cron to prune stale rows. The TTL is the cleanup.

Calling it from the frontend

Your React code never touches Valkey. It calls the function the same way it already calls Supabase functions:

ts
const { data } = await supabase.functions.invoke('cache-demo')

The browser talks to the function, the function talks to Valkey, and the credential stays where it belongs.

The full-ownership path

Staying inside Lovable with Edge Functions is the fast path. If you want to own the whole stack, Lovable has two-way GitHub sync: connect it, your project lands in a repo you control, and from there you can deploy the frontend anywhere and stand up your own API layer next to it (Hono, Express, a Next.js route handler). That server holds VALKEY_URL and runs the same commands, just with a normal Node client instead of the npm: specifier. The Postgres post walks through this move in full for a database, and a cache rides along the same way. Same Valkey, same connection string, one more server that happens to be yours.

Local development with the Layerbase CLI

You do not want to develop against your production Valkey. The Layerbase CLI, powered by SpinDB under the hood, runs Valkey locally with no Docker:

bash
npm i -g layerbase
lbase create lovable-cache-dev --engine valkey --start
lbase url lovable-cache-dev

lbase url prints a local connection string. Point your Edge Function or API layer at it while you build, and swap in the cloud string when you deploy. lbase stop lovable-cache-dev shuts it down without dropping the data.

Where to start

The short version: a Lovable app is a browser SPA with no server, so cache code cannot live in your React. It lives in a Supabase Edge Function (fast path) or in your own API after a GitHub sync (full ownership), with the Valkey URL kept as a server-side secret. From there the patterns are the standard ones: cache-aside for expensive reads, atomic rate limiting, and self-expiring checkpoint storage.

  • Create a Valkey at layerbase.com/create/valkey, free tier, no usage meters.
  • Put the URL in an Edge Function secret, never a VITE_ variable.
  • If a job queue is next on the list (background tasks, scheduled work), vqueue runs on top of the same Valkey.

If flat pricing is what pushed you to add a second engine, The multi-database tax covers why one predictable bill beats juggling metered services.