Product & engineering plan for the two most-requested features on HTML Docs: multi-tab documents and a wiki-like directory structure.
HTML Docs is our platform for creating, editing, and publishing self-contained HTML documents—each one a single page with its own share link. This plan covers the two most-requested additions to that model: multi-tab documents and a wiki-like directory structure. Today the product is flat: one document, one URL, one content blob. That simplicity got us here, but both requested features ask for the same thing—structure. Users want to organize content within a document (tabs) and organize documents relative to each other (wiki). These are complementary, not competing.
Tabs let a single document contain multiple views—think of a spec with an Overview tab, a Technical Details tab, and a Changelog tab. One URL, one share link, but multiple pages of content behind a tab bar. This is an intra-document feature: it extends the document model downward.
Wiki lets users group documents into a navigable tree with cross-linking—a knowledge base, a project wiki, a documentation site. This is an inter-document feature: it extends the document model upward into collections.
Both features share a design principle: existing documents are untouched. A document with zero tabs is exactly what it is today. A document not in any collection stays in the flat dashboard. No migrations, no breaking changes.
A tab bar appears at the top of the document editor and viewer, below the title. Each tab holds its own content (its own set of editable regions), but all tabs share the document's title, settings, visibility, published slug, collaborators, and Docsmith AI context.
/d/<id> and /site/<slug>, the tab bar renders. URL gains a ?tab=slug parameter for deep linking. Default (no param) shows the first tab.Introduce a document_tabs table. Each row represents one tab within a document. The existing editable_regions table gains a nullable tab_id column to associate regions with a specific tab.
-- New table: document_tabs -- One row per tab. A document with zero rows here is a classic single-view doc. CREATE TABLE IF NOT EXISTS document_tabs ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), document_id uuid NOT NULL REFERENCES documents(id) ON DELETE CASCADE, slug text NOT NULL, -- URL-safe tab identifier, e.g. 'overview' label text NOT NULL, -- Display name, e.g. 'Overview' position integer NOT NULL DEFAULT 0, html_content text NOT NULL DEFAULT '', -- Shell HTML for this tab (with region placeholders) is_blank boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), -- Slug must be unique within a document UNIQUE(document_id, slug) ); CREATE INDEX IF NOT EXISTS document_tabs_doc_idx ON document_tabs(document_id, position); COMMENT ON TABLE document_tabs IS 'Tabs within a document. Zero rows = legacy single-view doc. Each tab has its own shell HTML and regions. Ordered by position.';
-- Modify editable_regions: add nullable tab_id -- NULL tab_id = region belongs to a single-view doc (backward compat) -- Non-null tab_id = region belongs to that specific tab ALTER TABLE editable_regions ADD COLUMN IF NOT EXISTS tab_id uuid REFERENCES document_tabs(id) ON DELETE CASCADE; CREATE INDEX IF NOT EXISTS editable_regions_tab_idx ON editable_regions(tab_id) WHERE tab_id IS NOT NULL; COMMENT ON COLUMN editable_regions.tab_id IS 'When non-null, this region belongs to a specific tab. NULL = classic single-view doc region (backward compat).';
Each tab gets its own html_content shell (with {{region-xxx}} placeholders), stored on the document_tabs row. The parent documents.html_content continues to hold the shell for single-view docs. When a doc has tabs, the parent's html_content is effectively unused (or holds a thin wrapper).
Why: Tabs may have completely different layouts, styles, and structures. Forcing them into one shell with hidden sections would be fragile. Independent shells per tab give each tab full design freedom while sharing identity (title, slug, settings) at the document level.
| Endpoint | Method | Change |
|---|---|---|
/api/v1/docs/[id]/tabs |
POST | New Create a new tab. Body: { label, slug?, position?, html_content? }. Returns the created tab object. |
/api/v1/docs/[id]/tabs |
GET | New List all tabs for a document, ordered by position. |
/api/v1/docs/[id]/tabs/[tabId] |
PUT | New Update a tab's label, slug, position, or html_content. |
/api/v1/docs/[id]/tabs/[tabId] |
DELETE | New Delete a tab and its regions. Blocks if it's the last tab. |
/api/v1/docs/[id]/regions |
GET | Modified Accepts optional ?tab_id= filter. Without it, returns all regions (backward compat). With it, returns only that tab's regions. |
POST /api/v1/docs |
POST | Modified Accepts optional tabs array in the body. Each tab: { label, slug?, html_content }. If provided, creates the doc with tabs instead of single-view content. |
/site/[slug] |
GET | Modified Reads ?tab=slug query param. If the doc has tabs, renders the specified tab's content. Default: first tab by position. |
components/tabs/tab-bar.tsx — Horizontal tab bar with drag-to-reorder (same DnD kit as block-controls), inline rename on double-click, "+" button, context menu for delete.components/tabs/tab-content.tsx — Wraps the existing editor/viewer content, swapping which shell HTML and regions are active based on the selected tab.hooks/use-document-tabs.ts — SWR/React Query hook for fetching and mutating tabs. Optimistic updates for reorder and rename.app/documents/[id]/page.tsx — Conditionally renders <TabBar> above the editor when tabs.length > 0. Passes the active tab's html_content and filtered regions to the existing editor components.components/shadow-dom-viewer.tsx — Accepts a tab context prop. When in tabbed mode, renders the active tab's shell instead of the document's root shell.components/document-viewer.tsx — Same tab context awareness. The shared/published view shows the tab bar in read-only mode (no "+" or drag).app/site/[slug]/route.ts — Reads ?tab=slug, fetches the matching document_tabs row, renders that tab's assembled HTML. Falls back to the first tab if param is missing or invalid. Injects a lightweight tab-switcher UI into the served HTML with plain CSS/JS (no React runtime).Each tab's regions collaborate independently. Presence indicators show which tab each collaborator is viewing. The Liveblocks/Supabase room ID stays the same (document-level), but region subscriptions filter by tab_id. When collaborator A edits Tab 1 and collaborator B edits Tab 2, there are no conflicts—they're editing different region sets.
The existing document_versions.snapshot JSONB column already stores the full region set. Extend the snapshot schema to include tabs: [{ id, slug, label, position, html_content }] alongside the flat region array. Restoring a version restores both the tab structure and all tab contents atomically. Single-view doc snapshots remain unchanged (no tabs key).
The chat panel gains tab awareness. When the user asks "edit this section," Docsmith operates on the currently active tab's content. The document-level knowledge field applies to all tabs. A future enhancement could add per-tab knowledge, but that's not in scope for v1.
Comments are already anchored to regions via region_key. Since tab regions have unique keys (and a tab_id), comments naturally belong to a specific tab. The comments panel filters by active tab, with a toggle to show "all comments across all tabs."
A tabbed doc publishes at a single slug. The published page at /site/<slug> includes a lightweight tab-switcher UI injected by the route handler—pure HTML/CSS, no React payload. Tab state is managed via ?tab=slug query param for shareability. Each tab's content is rendered independently so the published page doesn't need to load all tabs at once (lazy load on tab switch, or prerender all if total content is small).
A collection is a named group of documents arranged in a tree. It has its own sidebar navigation, its own published URL namespace, and cross-linking between member docs. Think of it as a mini-wiki or documentation site—not a folder system for the dashboard, but a published product.
/wiki/<collection-slug>. Individual pages within it are at /wiki/<collection-slug>/<doc-slug>. The root page is the collection's landing page.[[Page Title]] wikilink syntax resolves to sibling documents within the same collection. The editor shows inline autocomplete when typing [[.Two new tables: collections for the wiki container, and collection_items for the tree structure. Documents are referenced by ID—they are not moved or copied.
-- New table: collections -- A named group of documents arranged in a navigable tree. CREATE TABLE IF NOT EXISTS collections ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), owner_id uuid REFERENCES auth.users(id) ON DELETE SET NULL, title text NOT NULL, description text, icon text, -- Emoji or icon identifier slug text, -- Published at /wiki/<slug> visibility text NOT NULL DEFAULT 'private', published_at timestamptz, -- NULL = not published share_token text, share_code text, homepage_doc_id uuid REFERENCES documents(id) ON DELETE SET NULL, sidebar_style text NOT NULL DEFAULT 'tree', -- 'tree' | 'flat' theme jsonb, -- Custom branding (colors, logo URL) created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE UNIQUE INDEX IF NOT EXISTS collections_slug_idx ON collections(slug) WHERE slug IS NOT NULL; COMMENT ON TABLE collections IS 'A wiki/collection: a named group of documents in a navigable tree. Publishes at /wiki/<slug>.';
-- New table: collection_items -- Materialized-path tree. Each row links a document into a collection at a position. -- path encodes ancestry: '/' for root items, '/parent-id/' for children, etc. CREATE TABLE IF NOT EXISTS collection_items ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), collection_id uuid NOT NULL REFERENCES collections(id) ON DELETE CASCADE, document_id uuid NOT NULL REFERENCES documents(id) ON DELETE CASCADE, parent_item_id uuid REFERENCES collection_items(id) ON DELETE CASCADE, position integer NOT NULL DEFAULT 0, path ltree, -- Materialized path for efficient subtree queries custom_title text, -- Override display title in sidebar (NULL = use doc title) custom_slug text, -- Override URL slug within wiki (NULL = use doc slug/id) created_at timestamptz NOT NULL DEFAULT now(), -- A document appears once per collection (no duplicates in same tree) UNIQUE(collection_id, document_id) ); CREATE INDEX IF NOT EXISTS collection_items_tree_idx ON collection_items(collection_id, parent_item_id, position); CREATE INDEX IF NOT EXISTS collection_items_doc_idx ON collection_items(document_id); CREATE INDEX IF NOT EXISTS collection_items_path_idx ON collection_items USING GIST(path); COMMENT ON TABLE collection_items IS 'Tree nodes linking documents into a collection. parent_item_id forms the hierarchy; path (ltree) enables efficient ancestor/descendant queries.';
The tree uses both parent_item_id (adjacency list, simple to update) and path (ltree, fast subtree queries). The adjacency list is the source of truth; path is derived and rebuilt on tree mutations. This hybrid is the standard pattern for navigable trees in PostgreSQL: adjacency list for writes, ltree for reads.
Why not nested sets? Nested sets require rewriting half the tree on every insert. With wiki pages that get rearranged frequently, the write cost isn't worth it. ltree gives us O(1) subtree queries without the write penalty.
Why ltree? Supabase (PostgreSQL) supports the ltree extension natively. Queries like "all descendants of node X" become WHERE path <@ 'root.nodeX'.
| Endpoint | Method | Change |
|---|---|---|
/api/v1/collections |
POST | New Create a collection. Body: { title, description?, icon?, slug? }. |
/api/v1/collections |
GET | New List the authenticated user's collections. |
/api/v1/collections/[id] |
GET | New Get collection metadata + full tree (items with nested children). |
/api/v1/collections/[id] |
PATCH | New Update collection title, description, icon, slug, theme, sidebar_style. |
/api/v1/collections/[id] |
DELETE | New Delete a collection. Does NOT delete member documents. |
/api/v1/collections/[id]/items |
POST | New Add a document to the collection. Body: { document_id, parent_item_id?, position?, custom_title?, custom_slug? }. |
/api/v1/collections/[id]/items/[itemId] |
PATCH | New Move an item (change parent, position) or update custom_title/custom_slug. |
/api/v1/collections/[id]/items/[itemId] |
DELETE | New Remove a document from the collection (doesn't delete the doc). Cascades to children in the tree. |
/api/v1/collections/[id]/publish |
POST | New Publish the collection at /wiki/<slug>. Validates all member docs are publishable. |
/wiki/[collectionSlug] |
GET | New Route handler. Renders the collection's homepage doc with sidebar navigation. |
/wiki/[collectionSlug]/[...path] |
GET | New Route handler. Renders a specific page within the wiki. Path segments map to custom_slug values in the tree. |
components/wiki/collection-sidebar.tsx — Collapsible tree sidebar. Shows the collection title, icon, and nested page list. Current page is highlighted. Collapse/expand on each tree node. Mobile: slides in from left as a drawer.components/wiki/collection-settings.tsx — Settings panel: title, description, icon, slug, theme, visibility, publish toggle.components/wiki/tree-editor.tsx — Drag-and-drop tree editor for rearranging pages. Used in the collection settings/management view. Built on DnD Kit with nested droppable zones.components/wiki/wikilink-autocomplete.tsx — Inline autocomplete triggered by [[ in the editor. Shows matching doc titles from the same collection. Inserts a resolved link.components/wiki/wiki-page-layout.tsx — Layout component for the published wiki: sidebar + content area + breadcrumb trail + prev/next navigation at the bottom.app/wiki/[collectionSlug]/[...path]/route.ts — Route handler for published wiki pages.components/dashboard/ — Add a "Collections" section to the dashboard. Shows collection cards alongside the existing document list. Each card shows the collection title, icon, page count, and last-updated timestamp.app/documents/[id]/page.tsx — When the doc is part of a collection (detected via collection_items lookup), optionally show a breadcrumb bar linking back to the collection and sibling pages.components/share-dialog.tsx — When sharing a collection, the dialog shows the wiki URL (/wiki/<slug>) instead of the single-doc URL.[[Page Title]] WorksWikilinks are stored as a custom HTML element: <a data-wikilink="doc-id" href="/wiki/slug/page-slug">Page Title</a>. Resolution happens at two points:
[[Page Ti... to a matching document in the same collection, inserts the data-wikilink anchor. The href is set to the current published path.data-wikilink anchors and re-resolves the href to the current tree path (in case the page was moved or renamed). This ensures links stay valid even after restructuring.Broken wikilinks (deleted pages) render with a distinct visual style (red dotted underline, like Wikipedia's red links) and link to a "page not found" within the wiki context.
A published collection gets a clean URL structure: /wiki/<collection-slug>/<page-slug>. Each page gets its own <title>, meta description, and OG tags. The wiki landing page (/wiki/<collection-slug>) uses the homepage doc's content. Sitemap generation includes wiki pages. The sidebar is server-rendered HTML (no client JS required for navigation), making the wiki fully crawlable.
Collection membership is separate from document collaboration. A collection owner can add documents they have edit access to. Collaborators on individual documents retain their existing permissions. A future enhancement could add collection-level collaborator roles (collection editor, collection viewer), but v1 uses document-level permissions only.
When chatting in a doc that's part of a collection, Docsmith gains awareness of sibling pages. "Add a link to the API reference page" works because the AI can see the collection tree. The collection's description serves as additional context for AI operations across all member docs.
A document in a collection can have tabs. The two features compose naturally because they operate at different levels:
/wiki/collection-slug/page-slug?tab=tab-slugWikilinks can optionally target a specific tab: [[Page Title#Tab Slug]]. This resolves to /wiki/slug/page-slug?tab=tab-slug. The # separator is deliberately not / to avoid confusion with the URL path hierarchy.
No. They serve different purposes. Wiki pages are independently publishable documents with their own version history, comments, and collaborators. Tabs are lightweight views within a single document. A 3-page API reference is a wiki. A single API reference page with "Request / Response / Examples" is tabs.
If a user tries to add 15+ tabs to a document, that's a signal they should be using a collection instead. The UI could surface a gentle hint: "This doc has many tabs. Consider organizing these as a collection for better navigation."
Both features are additive. No existing data is modified.
document_tabs behaves exactly as it does today. The editable_regions.tab_id column is nullable—existing regions with NULL tab_id continue to work unchanged. The editor only shows the tab bar when tabs.length > 0.collections and collection_items tables are entirely new—no existing table is altered. A document's standalone publishing at /site/<slug> continues to work even when the same doc is in a collection.POST /api/v1/docs body gains an optional tabs field; callers that don't send it get the existing behavior. No breaking changes.When a user clicks "Add Tab" on a single-view document for the first time, the system:
document_tabs row with the current html_content and existing regions (setting their tab_id).html_content is preserved as-is for backward compat (it's now effectively the "fallback" for API callers that don't understand tabs).Ship multi-tab documents in the editor with real-time collaboration.
document_tabs table + editable_regions.tab_id column.TabBar and TabContent components.tab_id in the collaborative editing hooks./d/<id> shared view to render tabs read-only.Ship tabbed documents on published pages and the v1 API.
/site/<slug> route to render tab-switcher UI./api/v1/docs/[id]/tabs CRUD endpoints.POST /api/v1/docs to accept tabs array.Ship collection creation, tree management, and dashboard integration.
collections + collection_items tables. Enable the ltree extension./api/v1/collections).Ship the published wiki with sidebar navigation and wikilink resolution.
/wiki/[collectionSlug]/[...path] route handlers.[[wikilink]] autocomplete in the editor.href on publish).Should we cap tabs per document? Suggestion: soft limit at 12 with a UI hint to use a collection instead. No hard limit in the data model. Performance concern is minimal—each tab is a separate query, not a single massive render.
Proposed: /wiki/<collection-slug>/<page-slug>. Alternative: /<collection-slug>/<page-slug> (cleaner but risks collisions with future top-level routes). The /wiki/ prefix is safer and clearer. Could offer custom domains later as a premium feature.
If a document is published at /site/my-doc AND appears in a wiki at /wiki/my-wiki/my-doc, both URLs serve the same content. Should we show a canonical URL warning? Suggestion: allow it, set the wiki URL as the canonical, and add a rel="canonical" tag. The standalone /site/ URL is a convenience; the wiki URL is the organized home.
Suggested max depth: 4 levels (collection root + 3 levels of nesting). Deep trees become navigation nightmares. The sidebar UI should make this natural by not offering "nest" beyond level 4. No hard DB constraint—just a UI guardrail.
The ltree extension must be enabled on the Supabase project. This is a one-time CREATE EXTENSION IF NOT EXISTS ltree; in a migration. If Supabase restrictions block this, fall back to the adjacency list alone (parent_item_id) with recursive CTEs for subtree queries. Slower but functional.
v1: Collection inherits permissions from its member documents. If you can view a doc, you can view it in the wiki sidebar. The collection owner manages the tree structure. Future: collection-level roles (editor, viewer) that apply to all member docs, overriding individual doc permissions when viewing through the wiki context.
A published wiki should be exportable as a static site (zip of HTML files). This is a Phase 5 feature but worth designing the URL structure for now. The /wiki/slug/page-slug pattern maps cleanly to a directory structure on disk.