Migrates Notion pages/databases (including images and PDFs, which Notion's own export skips) into a self-hosted Docmost wiki.
Two steps:
- Export —
notion-downloaderpulls pages via the Notion API into Markdown + downloaded assets on disk, one folder per configured root page/database. - Import —
src/import-to-docmost.tswalks that folder tree, creates matching Docmost pages via the API, uploads assets per page, rewrites internal links, renders Notion database properties (frontmatter) into the page body, and records a Notion→Docmost id map (sync-map.json) so re-runs update existing pages instead of duplicating them.
npm installcp .env.example .envand fill in:NOTION_TOKEN— from an internal integration at notion.so/my-integrationsNOTION_ROOT_PAGE_IDS/NOTION_ROOT_DB_IDS— 32-char id(s) of the page(s)/database(s) to export (comma-separated, no quotes/spaces). Share each root with the integration first — Notion only exposes pages explicitly shared with it. Root type is auto-detected, so it doesn't matter which of the two variables you use.DOCMOST_BASE_URL,DOCMOST_EMAIL,DOCMOST_PASSWORDDOCMOST_CA_CERT_PATH— only needed if your Docmost instance uses a self-signed/internal CA certificate (common for home-lab setups behind Caddy/Traefik). Point it at the CA root cert file.DOCMOST_SPACE_ID— the space's UUID, not its slug. Runnpm run list-spacesafter filling in the Docmost credentials above to get it.
npm run export # -> ./export/<rootId>/content (markdown + assets), one folder per root
npm run import # -> creates/updates pages in Docmost, writes sync-map.jsonRun against a small test subtree first (one page, one child, one image,
one PDF) and check the result in Docmost before pointing at the full
workspace — set NOTION_ROOT_PAGE_IDS/NOTION_ROOT_DB_IDS to just that one
id for the first run.
Docmost (community edition) has no database/table concept. Each Notion database becomes a parent page in Docmost, and every database row becomes a regular child page under it.
Docmost's page-update endpoint has a hard ~1MB request body limit that isn't
configurable via env var (Fastify default, not exposed in Docmost's server
setup). Pages whose content exceeds that get automatically split into
<title> (Teil 2), (Teil 3), ... child pages — see MAX_CHUNK_BYTES in
src/import-to-docmost.ts.
Notion's collapsible "toggle heading" blocks are not separate pages —
they're just inline content within one page. notion-downloader flattens
them into one giant Markdown file, which can produce a single enormous page
that loses the collapsible sections as real structure. src/advanced/
contains scripts to reconstruct that structure as real nested Docmost pages
by reading the page's actual block tree from the Notion API directly
(bypassing the already-flattened export):
split-toggle-page.ts <notionPageId> <docmostParentPageId>— recursively turns every toggle heading into its own Docmost sub-page.reparent-children.ts <notionPageId> <docmostParentPageId>— run afterwards. Notion sub-pages (child_pageblocks) that notion-downloader already exported flat under the root get moved to nest under their real toggle-heading parent, matching Notion's true structure.inspect-toggles.ts [notionPageId]— read-only recon: prints the toggle- heading tree and block-type counts before you commit to splitting anything.
This is a one-off, page-specific operation — inspect first, since not every page needs it (only ones that visibly used toggle headings for organization).
src/debug/ has small scripts for inspecting/fixing Docmost state directly,
useful when a migration run partially fails:
check-pages.ts— list top-level pages in the configured spacecheck-content.ts <pageId>— dump a page's full metadata + contentprint-tree.ts [pageId]— print a page's (or the whole space's) subtreedelete-pages.ts <pageId> [pageId...]— permanently delete pages (careful — cascades to children)restore-page.ts <pageId>— restore a page from Docmost's trash
sync-map.json (gitignored) tracks which Notion-exported file maps to which
Docmost page id, plus whether its content has been pushed (contentSynced).
Re-running npm run import after a fresh npm run export updates existing
pages' content instead of creating duplicates, and skips already-synced
pages entirely (so it won't re-upload the same attachments on every run).
Delete sync-map.json to force a full re-import.
A few real bugs were found and worked around along the way, in case you hit them too:
notion-downloader'spullCLI flags (--root-page,--root-is-db) don't map to the schema field the tool actually reads (rootId) — writing adownloader.jsonconfig file directly (asexport.tsdoes) is the reliable path.notion-downloaderresolves its./contentoutput path against the process's actual OS working directory, not its--cwdflag —export.tsworks around this by setting the child process'scwddirectly.- Its asset cache can go stale relative to the content output tree (seen
after fixing the bug above), silently skipping re-copying already-cached
images/files into the new output path.
export.tsdoes a clean pull (wipes the per-root folder first) every run to avoid this. - Asset/internal-link paths in the exported Markdown are sometimes absolute
(rooted at the export folder, e.g.
/Page/img.png), not relative to the referencing file — resolving them naively turns the leading/into the OS filesystem root. - Docmost wraps every API response as
{ data, success, status }.