Tutorial: Sessions in Practice
In this tutorial, you will set up sessions from scratch, use them to store user preferences, implement flash messages for post-redirect feedback, and learn how to switch between storage backends.
Prerequisites
- A Remix V3 project with a router (see the fetch-router tutorial)
- Basic understanding of cookies (see the cookie overview)
What You Will Build
- A session setup with cookie-based storage
- A theme preference that persists across page loads
- Flash messages that display once after a redirect
- A migration from cookie storage to filesystem storage
Step 1: Create the Session Cookie
Every session needs a cookie to identify the user. Create a signed, HTTP-only cookie:
// app/session.ts
import { createCookie } from 'remix/cookie'
export let sessionCookie = createCookie('__session', {
httpOnly: true, // Not accessible to client-side JavaScript
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
sameSite: 'Lax', // Sent on top-level navigations but not cross-site requests
secrets: [process.env.SESSION_SECRET!], // HMAC-SHA256 signing key
maxAge: 60 * 60 * 24 * 7, // 1 week
})Generate a strong secret
Use a random string of at least 32 characters for your session secret:
node -e "console.log(crypto.randomUUID() + crypto.randomUUID())"Step 2: Create the Session Storage
Start with cookie storage -- it stores session data directly in the cookie, so there is no external dependency:
// app/session.ts (continued)
import { createCookieSessionStorage } from 'remix/session/cookie-storage'
export let sessionStorage = createCookieSessionStorage(sessionCookie)Step 3: Add Session Middleware to the Router
The session middleware reads the session from the cookie on each request and saves changes back automatically:
// app/server.ts
import { createRouter } from 'remix/fetch-router'
import { session } from 'remix/session-middleware'
import { Session } from 'remix/session'
import { sessionCookie, sessionStorage } from './session.ts'
let router = createRouter({
middleware: [
session(sessionCookie, sessionStorage),
],
})Now every handler can access the session via context.get(Session).
Step 4: Store User Preferences
Build a theme toggle that remembers the user's preference across visits:
// app/server.ts (continued)
import { route } from 'remix/fetch-router/routes'
let routes = route({
home: '/',
setTheme: '/set-theme',
})
// Show the current theme and a toggle form
router.get(routes.home, async ({ context }) => {
let session = context.get(Session)
let theme = session.get('theme') ?? 'light'
return new Response(
`<!DOCTYPE html>
<html data-theme="${theme}">
<body>
<h1>Current theme: ${theme}</h1>
<form method="POST" action="/set-theme">
<input type="hidden" name="theme" value="${theme === 'light' ? 'dark' : 'light'}" />
<button type="submit">Switch to ${theme === 'light' ? 'dark' : 'light'}</button>
</form>
</body>
</html>`,
{ headers: { 'Content-Type': 'text/html' } },
)
})
// Handle theme change
router.post(routes.setTheme, async ({ context }) => {
let formData = await context.request.formData()
let theme = formData.get('theme') as string
let session = context.get(Session)
session.set('theme', theme)
// The session middleware saves the change automatically
return new Response(null, {
status: 302,
headers: { Location: '/' },
})
})Visit http://localhost:3000, click the toggle, and refresh -- the theme persists.
Step 5: Implement Flash Messages
Flash messages are one-time notifications that display after a redirect. They are set in one request and consumed in the next.
let routes = route({
// ... existing routes
books: '/books',
createBook: '/books/create',
})
// List books with optional flash message
router.get(routes.books, async ({ context }) => {
let session = context.get(Session)
let notice = session.get('notice') // Consumed on read -- won't appear again
let books = await db.findAll(booksTable)
return new Response(
`<!DOCTYPE html>
<html>
<body>
${notice ? `<div class="notice">${notice}</div>` : ''}
<h1>Books</h1>
<ul>
${books.map((b) => `<li>${b.title}</li>`).join('')}
</ul>
<a href="/books/create">Add a book</a>
</body>
</html>`,
{ headers: { 'Content-Type': 'text/html' } },
)
})
// Create a book and redirect with a flash message
router.post(routes.createBook, async ({ context }) => {
let formData = await context.request.formData()
let title = formData.get('title') as string
await db.insert(booksTable, { title })
let session = context.get(Session)
session.flash('notice', `"${title}" was created successfully!`)
// Post/Redirect/Get: redirect back to the list
return new Response(null, {
status: 302,
headers: { Location: '/books' },
})
})The flash message appears once on the redirected page. If the user refreshes, it is gone.
Step 6: Use Multiple Flash Keys
You can use different flash keys for different message types:
// Success
session.flash('success', 'Settings saved.')
// Error
session.flash('error', 'Failed to update profile.')
// In the template
let success = session.get('success')
let error = session.get('error')
let html = `
${success ? `<div class="alert-success">${success}</div>` : ''}
${error ? `<div class="alert-error">${error}</div>` : ''}
`Step 7: Switch to Filesystem Storage
Cookie storage is limited to about 4KB. If you need more space, switch to filesystem storage. The change is a single line:
// app/session.ts
import { createCookie } from 'remix/cookie'
import { createFsSessionStorage } from 'remix/session/fs-storage'
export let sessionCookie = createCookie('__session', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'Lax',
secrets: [process.env.SESSION_SECRET!],
maxAge: 60 * 60 * 24 * 7,
})
// Changed from createCookieSessionStorage to createFsSessionStorage
export let sessionStorage = createFsSessionStorage('./sessions')No other code changes are needed. The session middleware, handlers, and session.get/session.set calls all work exactly the same way because every storage backend implements the same SessionStorage interface.
With filesystem storage, the cookie now contains only the session ID (a short random string) instead of all the session data. The actual data is stored in files like ./sessions/a1b2c3d4.json.
Step 8: Switch to Redis for Production
For production with multiple servers, switch to Redis:
// app/session.ts
import { createCookie } from 'remix/cookie'
import { createRedisSessionStorage } from 'remix/session-storage-redis'
export let sessionCookie = createCookie('__session', {
httpOnly: true,
secure: true,
sameSite: 'Lax',
secrets: [process.env.SESSION_SECRET!],
maxAge: 60 * 60 * 24 * 7,
})
export let sessionStorage = createRedisSessionStorage({
url: process.env.REDIS_URL!,
ttl: 60 * 60 * 24 * 7, // Match cookie maxAge
})Again, no other code changes. All handlers continue to use session.get, session.set, and session.flash as before.
Step 9: Environment-Based Storage
A practical pattern is choosing the storage backend based on the environment:
// app/session.ts
import { createCookie } from 'remix/cookie'
import { createMemorySessionStorage } from 'remix/session/memory-storage'
import { createFsSessionStorage } from 'remix/session/fs-storage'
import { createRedisSessionStorage } from 'remix/session-storage-redis'
export let sessionCookie = createCookie('__session', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'Lax',
secrets: [process.env.SESSION_SECRET!],
maxAge: 60 * 60 * 24 * 7,
})
export let sessionStorage =
process.env.NODE_ENV === 'production'
? createRedisSessionStorage({ url: process.env.REDIS_URL!, ttl: 60 * 60 * 24 * 7 })
: process.env.NODE_ENV === 'test'
? createMemorySessionStorage()
: createFsSessionStorage('./sessions')Summary
| Concept | What It Does |
|---|---|
session.get(key) | Reads a value (consumes flash values) |
session.set(key, value) | Writes a persistent value |
session.flash(key, value) | Writes a one-time value |
session.unset(key) | Removes a key |
session.destroy() | Deletes the entire session |
session.regenerateId() | New ID, same data (security) |
| Cookie storage | Data in the cookie, no server state, 4KB limit |
| FS storage | Data on disk, single server only |
| Memory storage | Data in process memory, testing only |
| Redis / Memcache | Data in external store, multi-server production |
Next Steps
- Session Middleware Tutorial -- Details on how the middleware integrates with the router.
- Redis Storage Tutorial -- Production Redis setup.
- API Reference -- Complete documentation of every function and method.