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

> Integrate ts-fsrs for spaced repetition in your Nuxt 3 app. Learn to manage parameters with Pinia, use composables like useNextCard, and persist card states effectively.

- Repository: [Zyronon/TypeWords](https://github.com/zyronon/TypeWords)
- Tags: how-to-guide
- Published: 2026-09-03

---

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

```bash
pnpm add ts-fsrs

```

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

```ts
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`](https://github.com/zyronon/TypeWords/blob/main/app/core/stores/setting.ts) (lines 6-15):

```ts
// ~/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`](https://github.com/zyronon/TypeWords/blob/main/app/core/hooks/fsrs.ts) (lines 4-25). This file exports two composables that transform raw `ts-fsrs` calls into reactive, Nuxt-friendly functions:

```ts
// ~/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`](https://github.com/zyronon/TypeWords/blob/main/app/composables/practice-words/usePracticeWordSession.ts) (lines 279-280). Here's a distilled pattern you can adapt:

```vue
<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`](https://github.com/zyronon/TypeWords/blob/main/app/core/stores/base.ts) (lines 22-25):

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

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

```ts
// 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`](https://github.com/zyronon/TypeWords/blob/main/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`.