Getting started
This page gets you from zero to a paginated document twice: once headless in Node (just the layout engine) and once as an editable React view.
Install
Folio ships as @nextgensoftwares/folio-* packages. They are pre-1.0 and published privately to GitHub Packages: set up the scope and a token once as described in Installing (or link them locally with yalc while iterating). Pick the packages you need:
pnpm add @nextgensoftwares/folio-model @nextgensoftwares/folio-layout @nextgensoftwares/folio-fontspnpm add @nextgensoftwares/folio-model @nextgensoftwares/folio-layout @nextgensoftwares/folio-fonts @nextgensoftwares/folio-editor @nextgensoftwares/folio-react react react-dompnpm add @nextgensoftwares/folio-syncEvery package ships ESM, CJS and type declarations. Folio needs Node 22+ for tooling and a browser with Intl.Segmenter and WebAssembly at runtime.
Fonts
Layout measures text with HarfBuzz over real font files, so you need the font files themselves (.ttf/.otf), not just a CSS font stack. The default theme uses Geist, Geist Mono and Noto Sans Arabic (all OFL); the repository keeps copies in packages/fonts/fixtures/.
Lay out a document in Node
import { readFileSync } from 'node:fs';
import { FontEngine } from '@nextgensoftwares/folio-fonts';
import { layoutDocument, toPageBoundaries, type LineFragment } from '@nextgensoftwares/folio-layout';
import { standardSchema, validate, type FolioDocument } from '@nextgensoftwares/folio-model';
// 1. A measurer: HarfBuzz over the same fonts the theme names.
const fonts = await FontEngine.create({ fallback: ['Noto Sans Arabic'] });
fonts.addFont({ family: 'Geist', data: readFileSync('fonts/Geist-Variable.ttf') });
fonts.addFont({ family: 'Geist Mono', data: readFileSync('fonts/GeistMono-Variable.ttf') });
fonts.addFont({ family: 'Noto Sans Arabic', data: readFileSync('fonts/NotoSansArabic-Regular.ttf') });
// 2. A document: plain ProseMirror-shaped JSON.
const doc: FolioDocument = {
type: 'doc',
content: [
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Hello, pages' }] },
{ type: 'paragraph', attrs: { textAlign: 'justify' }, content: [{ type: 'text', text: 'Lorem ipsum… '.repeat(200) }] },
{ type: 'paragraph', attrs: { dir: 'rtl' }, content: [{ type: 'text', text: 'نص عربي داخل المستند' }] },
],
};
console.log(validate(doc, standardSchema)); // [] when the document fits the schema
// 3. Layout: positioned fragments per page.
const layout = layoutDocument(doc, {
measurer: fonts,
headerFooter: { footer: { enabled: true, showPageNumber: true } },
});
for (const page of layout.pages) {
const lines = page.fragments.filter((f): f is LineFragment => f.kind === 'line');
console.log(`page ${page.number}: ${lines.length} lines, blocks ${page.blocks?.first}–${page.blocks?.last}`);
}
console.log(toPageBoundaries(layout)); // [{ pageNumber, startNodeIndex, endNodeIndex, estimatedHeight }]Everything is in CSS px (1/96 in). The default theme is A4 at 96 DPI (794 × 1123 px) with 11 pt body text at line-height 1.6.
Testing without fonts
Unit tests often use a fixed-advance measurer so numbers stay round:
import type { TextMeasurer } from '@nextgensoftwares/folio-layout';
const mono: TextMeasurer = { width: (text, font) => text.length * font.size * 0.5 };This is exactly what Folio's own test suites do.
A complete editor in five lines
@nextgensoftwares/folio-react ships a ready-made editor app, <FolioApp>: an app bar with an editable title and save state, the formatting toolbar, the page canvas, side panels and a status bar with page, word count and zoom. It is responsive (panels become drawers, phones get a bottom formatting bar), follows light and dark, and every region is a slot, so plugins' panels and buttons show up on their own.
import { createFolioApp, FolioApp, FolioStyles } from '@nextgensoftwares/folio-react';
const fonts = await loadFonts(); // a FontEngine, see below
const app = createFolioApp({ doc, measurer: fonts }); // plugins: [...] when you have some
export const Editor = () => (
<><FolioStyles /><FolioApp app={app} title="Untitled document" /></>
);That is the editor below, running in this page. Click into it and type.
Give <FolioApp> a height (it fills its box; 100vh for a full-page app). createFolioApp composes the plugins once, sets up incremental layout and creates the FolioEditor. The UI kit guide covers the props, the theming tokens and how to restyle or rearrange any part of it.
Embed the editor in React, piece by piece
When you want your own layout instead of <FolioApp>, use the parts it is made of. The editor is headless: FolioEditor holds the ProseMirror state and the current layout; FolioView from @nextgensoftwares/folio-react draws the pages, caret, selection and menus, and forwards keyboard, IME and clipboard input through a hidden textarea.
import { useEffect, useState } from 'react';
import { FolioEditor } from '@nextgensoftwares/folio-editor';
import { FontEngine } from '@nextgensoftwares/folio-fonts';
import { LayoutCache, layoutDocument, type DocumentLayout } from '@nextgensoftwares/folio-layout';
import { standardSchema, type FolioDocument } from '@nextgensoftwares/folio-model';
import { FolioStyles, FolioToolbar, FolioView } from '@nextgensoftwares/folio-react';
const FACES = [
{ family: 'Geist', url: '/fonts/Geist-Variable.ttf', weight: '100 900' },
{ family: 'Geist Mono', url: '/fonts/GeistMono-Variable.ttf', weight: '100 900' },
{ family: 'Noto Sans Arabic', url: '/fonts/NotoSansArabic-Regular.ttf', weight: '400' },
];
async function loadFonts() {
const engine = await FontEngine.create({ fallback: ['Noto Sans Arabic'] });
await Promise.all(FACES.map(async (f) => {
const data = await (await fetch(f.url)).arrayBuffer();
engine.addFont({ family: f.family, data }); // layout measures with these bytes…
const face = new FontFace(f.family, data, { weight: f.weight });
document.fonts.add(face); // …and the browser draws with the same bytes
await face.load();
}));
return engine;
}
function createEditor(fonts: FontEngine, doc: FolioDocument) {
const cache = new LayoutCache();
let previous: DocumentLayout | undefined;
// Incremental layout: same cache + the previous layout on every call.
const layout = (d: FolioDocument) =>
(previous = layoutDocument(d, { measurer: fonts, cache, ...(previous ? { previous } : {}) }));
return new FolioEditor({ schema: standardSchema, doc, layout, measurer: fonts });
}
export function DocumentEditor({ doc }: { doc: FolioDocument }) {
const [state, setState] = useState<{ editor: FolioEditor; fonts: FontEngine } | null>(null);
useEffect(() => {
void loadFonts().then((fonts) => setState({ fonts, editor: createEditor(fonts, doc) }));
}, [doc]);
if (!state) return <p>Loading fonts…</p>;
const { editor, fonts } = state;
return (
<>
<FolioStyles />
<FolioView
editor={editor}
metrics={(font) => fonts.metrics(font)}
zoom="fit-width"
top={<FolioToolbar editor={editor} />}
style={{ height: '100vh' }}
/>
</>
);
}Key points:
- Same fonts on both sides. Layout decides line breaks with HarfBuzz; the browser only draws glyphs at the positions layout chose. Register the same font bytes with
document.fonts, and passmetricsso painted baselines match. - Pass
cacheandprevious. That turns every relayout after an edit into incremental layout. Keep both outside React state. <FolioStyles />injects the UI's CSS once; alternatively import@nextgensoftwares/folio-react/styles.css.- To read changes, subscribe:
editor.subscribe(() => save(editor.folioDoc)), or useuseEditorVersion(editor). For real persistence use@nextgensoftwares/folio-sync, which saves steps, not documents.
In progress
@nextgensoftwares/folio-react is being finalized in parallel with these docs; prop names in the API reference reflect the current source.
Add plugins
Plugins bundle schema, layout, painters, commands and UI. Compose them once and hand the pieces to the editor, the layout and the view:
import { composePlugins } from '@nextgensoftwares/folio-editor';
import { mediaPlugin, mediaSizer, objectUrlUploader } from '@nextgensoftwares/folio-plugin-media';
const composed = composePlugins(standardSchema, [mediaPlugin({ uploader: objectUrlUploader() })]);
const layout = composed.wrapLayout((d) =>
layoutDocument(d, { measurer: fonts, renderers: composed.renderers, mediaSize: mediaSizer, cache }));
const editor = new FolioEditor({
schema: composed.schema,
doc,
layout,
measurer: fonts,
plugins: composed.pmPlugins,
keymap: composed.keymap,
});
// <FolioView editor={editor} painters={composed.painters} ui={composed.ui} input={composed.input} … />
// <FolioToolbar editor={editor} ui={composed.ui} />See Plugin anatomy for every field and the custom block tutorial to write your own.
Run the playground
The repository's sibling project folio-playground (Vite + React) exercises every feature: the kitchen-sink sample, a ~5,000-page stress book, the mock API with simulated networks and offline mode, two-writer collaboration, and an editing benchmark.
cd folio && pnpm install && pnpm build
cd ../folio-playground && pnpm install && pnpm dev