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.jsonBackend: 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):
| Hook | HTTP Method | Path |
|---|---|---|
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/flashcardsDeployment Considerations
- Same origin: Deploy both behind a reverse proxy so the frontend calls
/api/loopkitwithout 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 deploybefore starting the backend in production