This guide walks you through adding Fortemi knowledge management to a React application. By the end you will have a working local-first archive that can create, list, and search notes stored in the browser with PGlite, using the same foundation Fortemi exposes for hybrid retrieval, tags, collections, jobs, capabilities, and Knowledge Shard portability. No server is required for the archive path.
- Node.js 22 or later — required by
@fortemi/core - pnpm 10 — the workspace uses pnpm;
npmandyarnalso work for downstream consumers - React 19 —
@fortemi/reacttargets the React 19 API (including the new JSX transform andusehook)
Install both packages. @fortemi/react re-exports everything you need from @fortemi/core, but you should install @fortemi/core explicitly if you access its types or utilities directly.
pnpm add @fortemi/core @fortemi/react@fortemi/core brings in @electric-sql/pglite as a dependency, which includes WebAssembly binaries. No additional configuration is needed for bundlers like Vite or webpack 5 — WASM is loaded at runtime via WebAssembly.instantiateStreaming.
The following is a minimal working app. It wraps the tree in FortemiProvider, creates a note on button click, and renders the list of notes.
// App.tsx
import { FortemiProvider, useCreateNote, useNotes } from '@fortemi/react'
function NoteBoard() {
const { createNote, loading: creating } = useCreateNote()
const { data, loading: fetching } = useNotes({ sort: 'created_at', order: 'desc' })
async function handleCreate() {
await createNote({ content: 'Hello from Fortemi', title: 'My first note' })
}
if (fetching) return <p>Loading notes...</p>
return (
<div>
<button onClick={handleCreate} disabled={creating}>
{creating ? 'Creating...' : 'New note'}
</button>
<ul>
{data?.items.map((note) => (
<li key={note.id}>
<strong>{note.title ?? 'Untitled'}</strong>
<time dateTime={note.created_at.toISOString()}>
{note.created_at.toLocaleDateString()}
</time>
</li>
))}
</ul>
</div>
)
}
export default function App() {
return (
<FortemiProvider persistence="opfs" archiveName="my-app">
<NoteBoard />
</FortemiProvider>
)
}FortemiProvider is the root of the Fortemi tree. It initializes the embedded PGlite database, runs schema migrations, and makes the core services available to all hooks via React context.
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
persistence |
'opfs' | 'idb' | 'memory' |
Yes | — | Storage backend (see below) |
archiveName |
string |
No | 'default' |
Logical name for the database; used to namespace the storage path |
children |
ReactNode |
Yes | — | The rest of your component tree |
| Mode | Storage | Browser support | Notes |
|---|---|---|---|
'opfs' |
Origin Private File System | Chrome 86+, Edge 86+, Safari 15.2+ | Fastest. Uses the asynchronous Hierarchy Access API. Recommended for production. |
'idb' |
IndexedDB | All modern browsers including Firefox | Slightly slower than OPFS but universally supported. Good fallback. |
'memory' |
RAM only | All browsers | Data is lost on page refresh. Useful for tests and previews. |
To detect browser support at runtime and select a mode automatically:
function selectPersistence(): 'opfs' | 'idb' {
return 'storage' in navigator && typeof StorageManager !== 'undefined'
? 'opfs'
: 'idb'
}On mount, the provider:
- Creates a
TypedEventBus— an in-process pub/sub bus for note, job, and capability events - Opens an
ArchiveManagerwith the selected persistence mode and archive name - Runs all schema migrations against the PGlite instance
- Creates a
CapabilityManagerfor optional WASM features (embeddings, LLM) - Creates a
BlobStorescoped to the archive name for binary attachment storage
Once initialization completes, the provider makes five objects available via context to all child hooks:
interface FortemiContextValue {
db: PGlite // The PGlite database instance
events: TypedEventBus // In-process event bus
archiveManager: ArchiveManager
capabilityManager: CapabilityManager
blobStore: BlobStore
}You can access this context directly with useFortemiContext() if you need lower-level access. All built-in hooks call it internally.
While initialization runs, FortemiProvider renders nothing (null). Wrap it in a Suspense boundary or a loading screen at the app shell level if you need to display a spinner:
<Suspense fallback={<p>Starting database...</p>}>
<FortemiProvider persistence="opfs">
<App />
</FortemiProvider>
</Suspense>If initialization fails (for example, the browser blocks OPFS access), the provider throws an error that will be caught by the nearest error boundary.
useCreateNote returns a createNote function and loading/error state.
import { useCreateNote } from '@fortemi/react'
function NewNoteForm() {
const { createNote, loading, error } = useCreateNote()
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const content = (form.elements.namedItem('content') as HTMLTextAreaElement).value
const note = await createNote({
content,
title: 'Meeting notes', // optional; omit to let auto-title run
format: 'markdown', // optional; defaults to 'markdown'
tags: ['meetings', 'q1'], // optional
})
console.log('Created note with id:', note.id)
}
return (
<form onSubmit={handleSubmit}>
<textarea name="content" required />
{error && <p role="alert">{error.message}</p>}
<button type="submit" disabled={loading}>Save</button>
</form>
)
}The NoteCreateInput type:
interface NoteCreateInput {
content: string
title?: string
format?: string // 'markdown' | 'plain' | any custom format string
source?: string // e.g. 'web-clipper', 'import', 'user'
visibility?: string // 'private' | 'shared' (application-defined)
tags?: string[]
archive_id?: string // target a specific archive by name
}createNote returns a NoteFull object and emits a note.created event on the bus, which causes any active useNotes hooks to refresh automatically.
useNotes accepts optional filter and pagination options and returns a paginated result. It subscribes to note.created, note.updated, note.deleted, note.restored, and job.completed events so the list stays current without manual polling.
import { useNotes } from '@fortemi/react'
function NoteList() {
const { data, loading, error, refresh } = useNotes({
sort: 'updated_at',
order: 'desc',
limit: 20,
offset: 0,
tags: ['meetings'], // filter to notes tagged 'meetings'
is_starred: true, // only starred notes
include_deleted: false, // exclude soft-deleted notes (default)
})
if (loading) return <p>Loading...</p>
if (error) return <p>Error: {error.message}</p>
if (!data) return null
return (
<>
<p>{data.total} notes</p>
<ul>
{data.items.map((note) => (
<li key={note.id}>
<span>{note.title ?? 'Untitled'}</span>
<span>{note.tags.join(', ')}</span>
</li>
))}
</ul>
{data.total > data.limit && (
<button onClick={refresh}>Load more</button>
)}
</>
)
}NoteListOptions reference:
interface NoteListOptions {
limit?: number
offset?: number
sort?: 'created_at' | 'updated_at' | 'title'
order?: 'asc' | 'desc'
is_starred?: boolean
is_pinned?: boolean
is_archived?: boolean
include_deleted?: boolean
include_archived?: boolean
collection_id?: string
tags?: string[]
}Each item in data.items is a NoteSummary. To load the full content of a specific note, use useNote(id):
const { data: note, loading, error } = useNote(selectedId)
// note is NoteFull | null — includes note.original.content and note.current.contentuseSearch exposes a search function that automatically selects the best available search mode. When semantic capability is enabled, it generates a query embedding and uses hybrid search (BM25 + vector RRF). Otherwise it falls back to text search.
import { useEffect, useState, useCallback } from 'react'
import { useSearch } from '@fortemi/react'
function SearchPanel() {
const [query, setQuery] = useState('')
const { data, loading, error, search, clear } = useSearch()
const handleSearch = useCallback((q: string) => {
if (!q.trim()) { clear(); return }
search(q, { limit: 10 })
}, [search, clear])
// Debounce: search 300 ms after the user stops typing
useEffect(() => {
const timer = setTimeout(() => handleSearch(query), 300)
return () => clearTimeout(timer)
}, [query, handleSearch])
return (
<div>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search notes..."
/>
{loading && <p>Searching...</p>}
{error && <p role="alert">{error.message}</p>}
{data && (
<p>
{data.total} results (mode: {data.mode})
</p>
)}
{data && (
<ul>
{data.results.map((result) => (
<li key={result.id}>
<strong>{result.title ?? 'Untitled'}</strong>
<div dangerouslySetInnerHTML={{ __html: result.snippet }} />
</li>
))}
</ul>
)}
</div>
)
}search(query, options?) accepts 12 filter parameters:
interface SearchOptions {
limit?: number // 1-100, default: 20
offset?: number // pagination offset
tags?: string[] // filter: notes with ANY of these tags
collection_id?: string // filter: notes in this collection
date_from?: Date // filter: created on or after
date_to?: Date // filter: created on or before
is_starred?: boolean // filter: starred status
is_archived?: boolean // filter: archived status
format?: string // filter: 'markdown' | 'plain' | 'html'
source?: string // filter: 'user' | 'mcp' | 'import' | 'api'
visibility?: string // filter: 'private' | 'shared' | 'public'
include_facets?: boolean // include tag/collection counts (default: false)
}SearchResponse shape:
interface SearchResponse {
results: SearchResult[]
total: number
query: string
mode: 'text' | 'semantic' | 'hybrid' // actual search mode used
semantic_available: boolean
limit: number
offset: number
facets?: SearchFacets // present when include_facets: true
}
interface SearchResult {
id: string
title: string | null
snippet: string // excerpt with match context (<mark> tags for text mode)
rank: number // relevance score (higher is more relevant)
created_at: Date
updated_at: Date
tags: string[]
}Additional search hooks: useSearchHistory() persists recent queries to localStorage, and useSearchSuggestions(history) provides prefix-matched autocomplete from the note vocabulary. See Search for details.
- Hook reference — Complete API for all hooks including
useNote,useUpdateNote,useDeleteNote,useTags,useCollections, anduseJobQueue: hooks.md - Capabilities — How to enable semantic search and LLM features using the
CapabilityManagerand built-in loaders: capabilities.md - Archives — Working with named archives to isolate or switch datasets at runtime: archives.md
- Job queue — Background processing for title generation, auto-tagging, and embeddings with
useJobQueue: job-queue.md - TypeScript types — All exported types from
@fortemi/coreand@fortemi/react: api-types.md