Skip to content

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

What You Will Build

  1. A session setup with cookie-based storage
  2. A theme preference that persists across page loads
  3. Flash messages that display once after a redirect
  4. A migration from cookie storage to filesystem storage

Every session needs a cookie to identify the user. Create a signed, HTTP-only cookie:

ts
// 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:

bash
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:

ts
// 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:

ts
// 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:

ts
// 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.

ts
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:

ts
// 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:

ts
// 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:

ts
// 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:

ts
// 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

ConceptWhat 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 storageData in the cookie, no server state, 4KB limit
FS storageData on disk, single server only
Memory storageData in process memory, testing only
Redis / MemcacheData in external store, multi-server production

Next Steps

Released under the MIT License.