Back to Blog
Tutorial11 min read2026-08-03

Preact SPA with Redis Session Management via the KV Store

Deploy a Preact app with an Express backend that stores user sessions in PandaStack's managed Redis. KV_URL auto-injection, HTTP REST fallback, and why redis-cli doesn't work.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Browser-based authentication in a SPA usually means JWTs in localStorage, which works until you need server-side session invalidation. A user logs out on one device, but the JWT on their other device is still valid for another 24 hours. Session stores solve this: the backend issues a session ID, stores it in Redis, and every request checks if the session still exists. Revoking a session is instant.

PandaStack's KV store is managed Redis that wires itself to your app automatically. Link it to your project, and KV_URL, KV_REST_API_URL, and KV_REST_API_TOKEN appear in the environment. This guide builds a Preact SPA with an Express backend that uses Redis for sessions, shows why redis-cli won't connect (TLS SNI issue), and explains when to use the HTTP REST API instead of a native Redis client.

The Preact app with Express backend

Create a new Preact project:

npx create-preact preact-sessions
cd preact-sessions

Add an Express server in server/index.js:

import express from 'express'
import session from 'express-session'
import RedisStore from 'connect-redis'
import { createClient } from 'redis'
import path from 'path'
import { fileURLToPath } from 'url'

const __dirname = path.dirname(fileURLToPath(import.meta.url))

const app = express()
app.use(express.json())

const redisClient = createClient({ url: process.env.KV_URL })
await redisClient.connect()

app.use(
  session({
    store: new RedisStore({ client: redisClient }),
    secret: process.env.SESSION_SECRET || 'dev-secret',
    resave: false,
    saveUninitialized: false,
    cookie: { maxAge: 24 * 60 * 60 * 1000 } // 24 hours
  })
)

app.post('/api/login', (req, res) => {
  const { username, password } = req.body
  if (password === 'demo') {
    req.session.user = { username }
    res.json({ success: true, user: { username } })
  } else {
    res.status(401).json({ error: 'Invalid credentials' })
  }
})

app.get('/api/session', (req, res) => {
  if (req.session.user) {
    res.json({ user: req.session.user })
  } else {
    res.status(401).json({ error: 'Not authenticated' })
  }
})

app.post('/api/logout', (req, res) => {
  req.session.destroy(() => {
    res.json({ success: true })
  })
})

app.use(express.static(path.join(__dirname, '../dist')))

app.get('*', (req, res) => {
  res.sendFile(path.join(__dirname, '../dist/index.html'))
})

const port = process.env.PORT || 3000
app.listen(port, '0.0.0.0', () => {
  console.log(`Server listening on port ${port}`)
})

Update package.json to include the server dependencies and scripts:

{
  "name": "preact-sessions",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "start": "node server/index.js"
  },
  "dependencies": {
    "express": "^4.18.2",
    "express-session": "^1.17.3",
    "connect-redis": "^7.1.0",
    "redis": "^4.6.0",
    "preact": "^10.19.0"
  },
  "devDependencies": {
    "@preact/preset-vite": "^2.8.0",
    "vite": "^5.0.0"
  }
}

Update the Preact app to call the session API. Replace src/app.jsx:

import { useState, useEffect } from 'preact/hooks'

export function App() {
  const [user, setUser] = useState(null)
  const [username, setUsername] = useState('')
  const [password, setPassword] = useState('')

  useEffect(() => {
    fetch('/api/session', { credentials: 'include' })
      .then(res => res.ok ? res.json() : null)
      .then(data => data && setUser(data.user))
  }, [])

  const login = async (e) => {
    e.preventDefault()
    const res = await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'include',
      body: JSON.stringify({ username, password })
    })
    if (res.ok) {
      const data = await res.json()
      setUser(data.user)
    }
  }

  const logout = async () => {
    await fetch('/api/logout', { method: 'POST', credentials: 'include' })
    setUser(null)
  }

  if (user) {
    return (
      <div>
        <h1>Welcome, {user.username}</h1>
        <button onClick={logout}>Logout</button>
      </div>
    )
  }

  return (
    <form onSubmit={login}>
      <h1>Login</h1>
      <input
        type="text"
        placeholder="Username"
        value={username}
        onInput={e => setUsername(e.target.value)}
      />
      <input
        type="password"
        placeholder="Password (use 'demo')"
        value={password}
        onInput={e => setPassword(e.target.value)}
      />
      <button type="submit">Login</button>
    </form>
  )
}

Update vite.config.js to proxy API requests during development:

import { defineConfig } from 'vite'
import preact from '@preact/preset-vite'

export default defineConfig({
  plugins: [preact()],
  server: {
    proxy: {
      '/api': 'http://localhost:3000'
    }
  }
})

Test locally with a local Redis instance or a temporary KV store:

export KV_URL="redis://localhost:6379"
npm run build
npm start

Visit http://localhost:3000, log in with any username and password demo, and verify the session persists across page reloads. The session ID is stored in a cookie, and the session data is stored in Redis.

Provision the KV store

Go to the PandaStack dashboard, navigate to Databases → KV Store, and click New KV. Choose a plan (Free tier is fine for testing). PandaStack provisions a managed Redis instance and shows connection details.

Do not copy these manually — the next step wires them automatically.

Deploy the app and link the KV store

Create pandastack.json in the repo root:

{
  "type": "container",
  "name": "preact-sessions",
  "language": "nodejs",
  "buildCommand": "npm run build",
  "startCommand": "npm start",
  "healthCheckPath": "/api/session",
  "env": [
    {
      "key": "SESSION_SECRET",
      "description": "Secret key for session encryption. Generate with: openssl rand -hex 32"
    }
  ]
}

Push to GitHub:

git add .
git commit -m "Add Preact app with Redis sessions"
git push

Deploy via the dashboard:

# Go to https://dashboard.pandastack.io/deploy?repo=yourname/preact-sessions

The deploy screen prompts for SESSION_SECRET. Generate one and paste it in. Click Deploy. The build runs, the container starts, but the app crashes with KV_URL not set. This is expected — you haven't linked the KV store yet.

In the project's Settings tab, find the KV Store section and click Link KV Store. Select your Redis instance. PandaStack injects KV_URL, KV_REST_API_URL, and KV_REST_API_TOKEN as environment variables and triggers a redeploy.

The new container picks up the connection string, connects to Redis, and the app goes live. Test it:

curl https://preact-sessions-abc123.pandastack.io/api/session
# {"error":"Not authenticated"}

curl -X POST https://preact-sessions-abc123.pandastack.io/api/login \
  -H "Content-Type: application/json" \
  -d '{"username":"alice","password":"demo"}' \
  -c cookies.txt

curl -b cookies.txt https://preact-sessions-abc123.pandastack.io/api/session
# {"user":{"username":"alice"}}

curl -X POST https://preact-sessions-abc123.pandastack.io/api/logout -b cookies.txt

curl -b cookies.txt https://preact-sessions-abc123.pandastack.io/api/session
# {"error":"Not authenticated"}

The session is stored in Redis, retrieved on subsequent requests, and destroyed on logout.

Why redis-cli doesn't work (and the HTTP workaround)

If you try to connect to the KV store with redis-cli:

redis-cli -u "$KV_URL"
# Error: Connection closed by foreign host

This fails because PandaStack's managed Redis requires TLS with SNI (Server Name Indication), and redis-cli doesn't send the SNI header. The Redis server can't determine which tenant's database to connect to, so it drops the connection.

The workaround: use the HTTP REST API instead. PandaStack's KV store includes an Upstash-compatible REST façade that works over HTTP:

curl -X GET "$KV_REST_API_URL/get/mykey" \
  -H "Authorization: Bearer $KV_REST_API_TOKEN"

For session management, the native Redis client (via KV_URL) is better — lower latency, connection pooling, support for pub/sub. But for debugging or one-off commands, the REST API is the only option when redis-cli fails.

In code, you can use both:

// Native Redis client (fast, full feature set)
import { createClient } from 'redis'
const client = createClient({ url: process.env.KV_URL })
await client.connect()
await client.set('key', 'value')

// HTTP REST API (slower, but works everywhere)
const url = `${process.env.KV_REST_API_URL}/set/key/value`
await fetch(url, { headers: { Authorization: `Bearer ${process.env.KV_REST_API_TOKEN}` } })

Deploy via the API with KV linking

For CI, you can create the project and link the KV store in one API call. First, get the KV store ID:

curl -H "Authorization: Bearer psk_live_your_token_here" \
  https://api.pandastack.io/v1/kv

Copy the id, then create the project:

curl -X POST https://api.pandastack.io/v1/projects \
  -H "Authorization: Bearer psk_live_your_token_here" \
  -H "Content-Type: application/json" \
  -d '{
    "slug": "container",
    "name": "preact-sessions",
    "repositoryName": "yourname/preact-sessions",
    "branch": "main",
    "kvStoreId": "kv-abc123",
    "env": [
      { "name": "SESSION_SECRET", "value": "prod-secret-from-ci" }
    ],
    "autoDeploy": true
  }'

The kvStoreId field links the KV store before the first deploy, so KV_URL is set from the start.

Session expiration and cleanup

The connect-redis session store sets a TTL on each session key in Redis. When the session expires (default 24 hours), Redis deletes it automatically. No manual cleanup required.

For shorter sessions (e.g., 1 hour), update the cookie.maxAge in Express:

session({
  cookie: { maxAge: 60 * 60 * 1000 } // 1 hour
})

For longer sessions or "remember me" functionality, store a separate long-lived token in Redis and refresh the session on each request.

What breaks and how to fix it

Sessions don't persist across requests: The Express session middleware isn't configured to use Redis. Check that store: new RedisStore({ client: redisClient }) is passed to the session() call, and that redisClient is connected before starting the server.

"ECONNREFUSED" errors: The KV_URL is unset or wrong. Verify the KV store is linked in the project settings, and check the environment variables via panda projects env.

Sessions expire immediately: The SESSION_SECRET changed between deploys. Express uses the secret to sign session cookies; if the secret changes, old cookies are invalid. Use a consistent secret across deploys (store it as an environment variable).

High Redis memory usage: Sessions are accumulating without expiring. Check that connect-redis is setting TTLs correctly. You can manually inspect Redis keys via the HTTP API:

curl -X GET "$KV_REST_API_URL/keys/*" \
  -H "Authorization: Bearer $KV_REST_API_TOKEN"

If you see thousands of session keys with no TTL, the session middleware config is broken.

Choosing sessions vs JWTs

Sessions in Redis are better for:

  • Instant logout (destroy the session, user is logged out everywhere)
  • Server-side permissions checks (load user roles from the session)
  • Multi-device logout (delete all sessions for a user)

JWTs in localStorage are better for:

  • Stateless APIs (no Redis dependency)
  • Microservices (each service validates the JWT independently)
  • Offline-first apps (the token works without a server roundtrip)

For a Preact SPA with server-side logic, sessions are simpler: the backend owns authentication, and the frontend just sends cookies.

Next steps

You've deployed a Preact app with an Express backend that uses PandaStack's managed Redis for sessions. The same pattern works for any SPA framework (React, Vue, Svelte) paired with any backend (Fastify, Hono, Flask, Django).

For production apps, consider:

  • Using HTTPS-only cookies (cookie: { secure: true }) in production
  • Implementing CSRF protection (csurf middleware)
  • Adding a session activity timeout (auto-logout after 15 minutes of inactivity)
  • Storing user permissions in Redis alongside the session for faster authorization checks

PandaStack handles the Redis infrastructure; you write the session logic.

References

  • [Preact documentation](https://preactjs.com/guide/v10/getting-started)
  • [express-session guide](https://github.com/expressjs/session)
  • [PandaStack KV store](https://docs.pandastack.io/kv)
  • [Redis best practices](https://redis.io/docs/manual/patterns/)

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also