My App
LoopKitExamples

Full-Stack Integration

End-to-end NestJS backend + React frontend working together

Overview

This example shows how to integrate the @loopkit/nestjs backend with the @loopkit/react frontend into a cohesive full-stack application.

Project Structure

my-flashcard-app/
├── backend/
│   ├── src/
│   │   ├── app.module.ts
│   │   ├── main.ts
│   │   ├── prisma.service.ts
│   │   └── loopkit/
│   │       ├── loopkit.module.ts
│   │       ├── deck.controller.ts
│   │       ├── study.controller.ts
│   │       ├── card.controller.ts
│   │       └── stats.controller.ts
│   └── prisma/
│       └── schema.prisma
├── frontend/
│   ├── src/
│   │   ├── App.tsx
│   │   ├── api.ts
│   │   └── components/
│   └── package.json
└── package.json

Backend: LoopKit Module

Encapsulate LoopKit in its own NestJS module:

// backend/src/loopkit/loopkit.module.ts
import { Module, OnModuleInit } from '@nestjs/common';
import {
    LoopKitModule,
    PrismaLoopKitAdapter,
    NoteTypeService,
    createContentPipeline,
    createMarkdownTransform,
} from '@loopkit/nestjs';
import { parse } from 'marked';
import { PrismaService } from '../prisma.service';
import { DeckController } from './deck.controller';
import { StudyController } from './study.controller';
import { CardController } from './card.controller';
import { StatsController } from './stats.controller';

@Module({
    imports: [
        LoopKitModule.register({
            storage: new PrismaLoopKitAdapter(new PrismaService()),
            contentPipeline: createContentPipeline([
                createMarkdownTransform({ parse }),
            ]),
        }),
    ],
    controllers: [
        DeckController,
        StudyController,
        CardController,
        StatsController,
    ],
})
export class FlashcardModule implements OnModuleInit {
    constructor(private readonly noteTypes: NoteTypeService) {}

    async onModuleInit() {
        await this.noteTypes.seedDefaults();
    }
}

Backend: Stats Controller

Expose computed statistics via the API:

// backend/src/loopkit/stats.controller.ts
import { Controller, Get, Param } from '@nestjs/common';
import {
    LoopKitStorage,
    DeckService,
    reviewsPerDay,
    retentionRate,
    reviewForecast,
    deckBreakdown,
    studyStreak,
    averageEase,
} from '@loopkit/nestjs';

@Controller('loopkit/decks/:deckId/stats')
export class StatsController {
    constructor(
        private readonly storage: LoopKitStorage,
        private readonly decks: DeckService,
    ) {}

    @Get()
    async getStats(@Param('deckId') deckId: string) {
        const descendantIds = await this.storage.getDescendantDeckIds(deckId);
        const allDeckIds = [deckId, ...descendantIds];

        const [cards, logs] = await Promise.all([
            this.storage.findCards(deckId),
            this.storage.findReviewLogs({ deckId }),
        ]);

        // Gather cards from all descendant decks too
        for (const id of descendantIds) {
            const deckCards = await this.storage.findCards(id);
            cards.push(...deckCards);
            const deckLogs = await this.storage.findReviewLogs({ deckId: id });
            logs.push(...deckLogs);
        }

        return {
            breakdown: deckBreakdown(cards),
            retention: retentionRate(logs),
            streak: studyStreak(logs),
            averageEase: averageEase(cards),
            reviewsPerDay: Object.fromEntries(reviewsPerDay(logs)),
            forecast: Object.fromEntries(reviewForecast(cards)),
        };
    }
}

Frontend: API Configuration

Set up the authenticated fetcher:

// frontend/src/api.ts
export function createAuthenticatedFetcher(getToken: () => string | null): typeof fetch {
    return (input, init) => {
        const token = getToken();
        return fetch(input, {
            ...init,
            headers: {
                'Content-Type': 'application/json',
                ...init?.headers,
                ...(token ? { Authorization: `Bearer ${token}` } : {}),
            },
        });
    };
}
// frontend/src/App.tsx
import { LoopKitProvider } from '@loopkit/react';
import '@loopkit/react/styles.css';
import { createAuthenticatedFetcher } from './api';

const fetcher = createAuthenticatedFetcher(() => localStorage.getItem('auth_token'));

export function App() {
    return (
        <LoopKitProvider
            apiUrl={import.meta.env.VITE_API_URL + '/loopkit'}
            fetcher={fetcher}
        >
            {/* Your app routes */}
        </LoopKitProvider>
    );
}

API Route Mapping

The frontend hooks make requests to these API paths (relative to apiUrl):

HookHTTP MethodPath
useDeck(id)GET/decks/{id}, /decks/{id}/counts
useDecks()GET/decks
useDeckTree()GET/decks/tree
useReviewSession.startSession(id)POST/decks/{id}/study
useReviewSession.grade(grade)POST/cards/{id}/grade
useReviewSession.undo()POST/reviews/{id}/undo
useCard(id)GET/cards/{id}, /cards/{id}/render
useCardEditor.create()POST/notes
useCardEditor.update()PUT/notes/{id}
useNoteTypes()GET/note-types
useStats(deckId)GET/decks/{id}/stats
useImport.importCSV()POST/import/csv
useImport.importJSON()POST/import/json
useExport.exportCSV()GET/decks/{id}/export/csv
useExport.exportJSON()GET/decks/{id}/export/json
useTags()GET/tags

Your backend controllers must implement these routes. Map them to the LoopKit services as shown in the NestJS API example.

CORS Configuration

If your frontend and backend run on different ports during development:

// backend/src/main.ts
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter } from '@nestjs/platform-fastify';
import { AppModule } from './app.module';

async function bootstrap() {
    const app = await NestFactory.create(AppModule, new FastifyAdapter());

    app.enableCors({
        origin: ['http://localhost:5173'], // Vite default port
        credentials: true,
    });

    await app.listen(3000);
}
bootstrap();

Environment Variables

# frontend/.env
VITE_API_URL=http://localhost:3000

# backend/.env
DATABASE_URL=postgresql://user:password@localhost:5432/flashcards

Deployment Considerations

  • Same origin: Deploy both behind a reverse proxy so the frontend calls /api/loopkit without CORS issues
  • Separate origins: Configure CORS on the backend and use the full URL in apiUrl
  • Authentication: The custom fetcher pattern works with any auth system — JWT, sessions, API keys
  • Database: Run prisma migrate deploy before starting the backend in production

On this page