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 importRatingenums or know threshold values.useNextCard()— instantiatesFSRSonce 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 reviewstability— memory retention predictiondifficulty— estimated item difficultyelapsed_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-fsrsas 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 fromts-fsrsinternals. - Maintain state in a reactive
Record<string, Card>map keyed by content identifier. - Persist between sessions using
localStoragefor anonymous users or database storage for accounts. - Build queues by filtering against
card.duetimestamps, 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →