How to Integrate ts-fsrs for Spaced Repetition in a Nuxt Application

To add spaced repetition to a Nuxt 3 app, install ts-fsrs, store algorithm parameters in a Pinia settings store, wrap the library in composable hooks like useNextCard(), and persist card states in a reactive map keyed by content ID.

The ts-fsrs library is a TypeScript implementation of the FSRS (Free Spaced Repetition Scheduler) algorithm, a modern successor to SM-2 used by Anki and other learning platforms. The open-source TypeWords project demonstrates how to integrate this library into a Nuxt 3 application using the Composition API, Pinia, and custom composables that bridge the gap between raw algorithm calls and reactive UI state.

Installing ts-fsrs in Your Nuxt Project

Start by adding the dependency to your project. TypeWords uses pnpm, but any package manager works:

pnpm add ts-fsrs

Verify the installation by importing the core types in any .ts file:

import { FSRS, createEmptyCard, type Card, type FSRSParameters } from 'ts-fsrs'

The library ships with zero runtime dependencies and works in both server and client environments, making it ideal for Nuxt's universal rendering model.

Storing FSRS Parameters in a Pinia Settings Store

Spaced repetition requires tunable parameters and user-configurable thresholds. In TypeWords, these live in app/core/stores/setting.ts (lines 6-15):

// ~/stores/setting.ts
import { defineStore } from 'pinia'
import type { FSRSParameters } from 'ts-fsrs'

export const useSettingStore = defineStore('setting', {
  state: () => ({
    // Thresholds for mapping wrong-answer counts to Ratings
    fsrsEasyLimit: 0,   // 0 mistakes = Easy
    fsrsGoodLimit: 3,   // 1-3 mistakes = Good
    fsrsHardLimit: 6,   // 4-6 mistakes = Hard
    // 7+ mistakes = Again

    // Core FSRS algorithm parameters
    fsrsParameters: {
      requestRetention: 0.9,      // Target 90% retention rate
      maximumInterval: 36500,     // ~100 year cap
      w: [1, 1, 1, 1],            // Weights for difficulty calculation
    } as FSRSParameters,
  }),
})

This store decouples algorithm configuration from application logic, allowing users to adjust their learning experience through a settings UI without touching code.

Creating FSRS Composable Hooks

The critical integration layer lives in app/core/hooks/fsrs.ts (lines 4-25). This file exports two composables that transform raw ts-fsrs calls into reactive, Nuxt-friendly functions:

// ~/hooks/fsrs.ts
import {
  type Card,
  type CardInput,
  FSRS,
  type Grade,
  Rating,
} from 'ts-fsrs'
import { useSettingStore } from '@/stores/setting'

/**
 * Maps a wrong-answer count to an FSRS Rating
 * based on user-defined thresholds from settings
 */
export function useGetGradeByWrongTimes() {
  const store = useSettingStore()
  return (wrongTimes?: number): Rating => {
    if (wrongTimes === undefined) return Rating.Easy
    if (wrongTimes <= store.fsrsEasyLimit) return Rating.Easy
    if (wrongTimes <= store.fsrsGoodLimit) return Rating.Good
    if (wrongTimes <= store.fsrsHardLimit) return Rating.Hard
    return Rating.Again
  }
}

/**
 * Returns a function that computes the next Card state
 * given a current card and a user rating
 */
export function useNextCard() {
  const store = useSettingStore()
  const fsrs = new FSRS(store.fsrsParameters)

  return (card: CardInput | Card, grade: Grade): Card => {
    const result = fsrs.next(card, new Date(), grade)
    return result.card
  }
}

Key design decisions in this layer:

  • useGetGradeByWrongTimes() — abstracts the grading logic so components don't need to import Rating enums or know threshold values.
  • useNextCard() — instantiates FSRS once per composable call (caching it via closure) to avoid recreating the algorithm object on every review.

Both composables read from the reactive Pinia store, so parameter changes take effect immediately without restarting the session.

Using the Hooks in Practice Components

The actual integration point appears in app/composables/practice-words/usePracticeWordSession.ts (lines 279-280). Here's a distilled pattern you can adapt:

<script setup lang="ts">
import { useNextCard, useGetGradeByWrongTimes } from '@/hooks/fsrs'
import { usePracticeStore } from '@/stores/practice'
import { createEmptyCard } from 'ts-fsrs'

const getGrade = useGetGradeByWrongTimes()
const nextCard = useNextCard()
const practice = usePracticeStore()

function handleAnswer(word: string, mistakesMade: number) {
  // Step 1: Determine rating based on performance
  const rating = getGrade(mistakesMade)

  // Step 2: Retrieve existing card or create new one
  const currentCard = practice.fsrsData[word] ?? createEmptyCard()

  // Step 3: Compute next state using FSRS algorithm
  const updatedCard = nextCard(currentCard, rating)

  // Step 4: Persist for future sessions
  practice.fsrsData[word] = updatedCard
}
</script>

The createEmptyCard() helper from ts-fsrs generates a card with default scheduling values for new items. After each review, the returned card contains updated fields:

  • due — timestamp for next review
  • stability — memory retention prediction
  • difficulty — estimated item difficulty
  • elapsed_days, scheduled_days, reps, lapses — tracking metadata

Persisting FSRS State Across Sessions

To maintain learning progress, store the fsrsData map between app restarts. TypeWords defines this map in app/core/stores/base.ts (lines 22-25):

// ~/stores/base.ts or your practice store
import { defineStore } from 'pinia'
import type { Card } from 'ts-fsrs'

export const usePracticeStore = defineStore('practice', {
  state: () => ({
    // Map content IDs to their FSRS Card state
    fsrsData: {} as Record<string, Card>,
  }),
})

For client-side persistence, extend this with a Nuxt plugin or useLocalStorage:

// ~/plugins/fsrs-persist.client.ts
export default defineNuxtPlugin(() => {
  const practice = usePracticeStore()

  // Restore on startup
  const saved = localStorage.getItem('fsrsData')
  if (saved) {
    try {
      practice.fsrsData = JSON.parse(saved)
    } catch (e) {
      console.error('Failed to restore FSRS data')
    }
  }

  // Save on beforeunload
  window.addEventListener('beforeunload', () => {
    localStorage.setItem('fsrsData', JSON.stringify(practice.fsrsData))
  })
})

For server-side persistence (user accounts), replace localStorage with an API call to your backend database, using the same Record<string, Card> structure.

Scheduling Due Items with ts-fsrs in Nuxt

To build a review queue, filter your content by the due property:

// Composable for fetching items ready for review
export function useDueItems() {
  const practice = usePracticeStore()
  const now = new Date()

  return computed(() => {
    return Object.entries(practice.fsrsData)
      .filter(([_, card]) => new Date(card.due) <= now)
      .sort((a, b) => +new Date(a[1].due) - +new Date(b[1].due))
  })
}

This leverages Vue's reactivity system—when fsrsData updates after a review, the due list recalculates automatically.

Summary

  • Install ts-fsrs as a dependency and verify universal module compatibility.
  • Centralize parameters in a Pinia store (fsrsParameters + rating thresholds) for runtime adjustability.
  • Wrap algorithm calls in composables (app/core/hooks/fsrs.ts) to insulate components from ts-fsrs internals.
  • Maintain state in a reactive Record<string, Card> map keyed by content identifier.
  • Persist between sessions using localStorage for anonymous users or database storage for accounts.
  • Build queues by filtering against card.due timestamps, leveraging Vue's computed reactivity.

Frequently Asked Questions

What is the difference between ts-fsrs and the original SM-2 algorithm?

ts-fsrs implements the FSRS algorithm, which uses machine learning to predict memory retention more accurately than SM-2's fixed intervals. According to benchmark studies cited in the ts-fsrs repository, FSRS reduces the number of reviews needed to maintain a given retention rate by approximately 30% compared to SM-2. Both algorithms output similar data structures (Card with due date), so you can migrate between them without restructuring your application state.

Can ts-fsrs run on the server side in Nuxt 3?

Yes—ts-fsrs has no browser-only dependencies and executes fully in Node.js environments. You can instantiate new FSRS() in server APIs, server middleware, or Nitro handlers to compute card states during SSR or API requests. The TypeWords implementation keeps fsrsData in a Pinia store that hydrates from server to client, but you could alternatively maintain card state entirely server-side for multi-device synchronization.

How do I migrate existing review data from another spaced repetition system?

Convert your existing interval data to ts-fsrs's Card structure using createEmptyCard() as a base, then manually populate stability, difficulty, and due fields with best-guess values derived from your previous system's next-review date. For precise migration, the ts-fsrs documentation provides conversion formulas from SM-2's interval and ease factor values to FSRS's stability and difficulty parameters.

Why does useNextCard recreate the FSRS instance on each call?

It doesn't—useNextCard() instantiates const fsrs = new FSRS(store.fsrsParameters) once when the composable is invoked, then returns a closure that reuses this instance. This design ensures parameter changes from the settings store propagate when the composable is re-called (e.g., after a user saves new preferences), while avoiding per-review instantiation overhead. For stricter caching across component lifecycles, you could move the FSRS instance to a shared composable state using useState.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →