Navigation
The editor sidebar has two views: Files and Navigation.
Files shows your actual file tree: the markdown, MDX, and media files that make up your content.
Navigation is where you define the structure your readers see: which pages appear, in what order, and how they're grouped.
You can add pages, folders, groups, links, and dividers, then drag and drop to reorder. Everything is visual. No config files to edit by hand.
Edit visually
Organize your docs in the Navigation tab. Add pages, create folders, drag to reorder.navigation.json is generated
Every change is reflected in anavigation.jsonfile in your project root.Push to GitHub
navigation.jsonis committed alongside your content. Your docs framework reads it to render the sidebar.

Editing the navigation
New projects start without a navigation. Click Create navigation in the Navigation tab to get started.
The navigation tree supports six node types:
- Tabs: top-level containers. If your docs site has a top navigation bar (e.g. "Guides", "API Reference"), each tab becomes an entry. Most projects only need one, and with a single tab, frameworks typically skip the top nav entirely.
- Pages: references to content files in your project.
- Links: external URLs.
- Folders: collapsible containers that group related pages.
- Groups: non-collapsible containers with a heading.
- Dividers: horizontal rules to break up long lists.
You can add, rename, and delete any of these from the context menu, and drag and drop to reorder or move items between containers.
If you already have a docs site with content, you can generate a navigation.json from your existing sidebar config using Claude Code or a similar tool. Add it to your project root and re-import the project into Dhub.
The navigation.json file
The navigation.json file is the serialized output of everything you build in the Navigation panel. It lives in your project root and is committed to your repository like any other file.
The file is placed in the project root you specified when importing your project. In a monorepo, this is typically a subdirectory, not the repository root.
[
{
"label": "Docs",
"type": "tab",
"path": "docs",
"children": [
{
"label": "Getting Started",
"type": "group",
"children": [
{
"label": "Installation",
"type": "page",
"path": "docs/getting-started/installation.mdx"
},
{
"label": "Project Structure",
"type": "page",
"path": "docs/getting-started/project-structure.mdx"
},
{
"label": "Deploying",
"type": "page",
"path": "docs/getting-started/deploying.mdx"
},
{
"label": "Changelog",
"type": "link",
"url": "https://mywebsite.com/changelog"
}
]
},
{
"type": "divider"
},
{
"label": "API Reference",
"type": "group",
"children": [
{
"label": "Configuration",
"type": "folder",
"children": [
{
"label": "TypeScript",
"type": "page",
"path": "docs/api-reference/configuration/typescript.mdx"
},
{
"label": "JavaScript",
"type": "page",
"path": "docs/api-reference/configuration/javascript.mdx"
}
]
},
{
"label": "Functions",
"type": "folder",
"children": [
{
"label": "Generate",
"type": "page",
"path": "docs/api-reference/functions/generate.mdx"
},
{
"label": "Parse",
"type": "page",
"path": "docs/api-reference/functions/parse.mdx"
}
]
}
]
}
]
}
]Page
Contains label and path (relative to project root, e.g. docs/getting-started/installation.mdx).
Link
Contains label and url (e.g. https://mywebsite.com/changelog).
Folder
Contains label and children. Rendered as a collapsible section.
Group
Contains label and children. Rendered as a non-collapsible section heading.
Divider
No additional fields. Renders as a horizontal rule.
Docusaurus sidebar integration
Docusaurus uses a sidebars.js (or .ts) config. You can read navigation.json and convert it to the format Docusaurus expects.
Each tab becomes a named sidebar, with the key derived from the tab label (e.g. "API Reference" becomes api-reference). Page paths are stripped of the tab's path prefix since Docusaurus already scopes pages to the content directory.
Here's a sidebars.js that reads navigation.json and builds the Docusaurus sidebar config from it:
const {readFileSync} = require('node:fs');
const {join} = require('node:path');
const sidebars = buildSidebars();
function buildSidebars() {
const navPath = join(__dirname, 'navigation.json');
const tabs = JSON.parse(readFileSync(navPath, 'utf-8'));
const result = {};
for (const tab of tabs) {
const sidebarId = slugify(tab.label);
result[sidebarId] = convertChildren(tab.children || [], tab.path);
}
return result;
}
function convertChildren(children, tabPath) {
return children.map(node => convertNode(node, tabPath)).filter(Boolean);
}
function convertNode(node, tabPath) {
switch (node.type) {
case 'page':
return {
type: 'doc',
id: stripPrefix(stripExtension(node.path), tabPath),
label: node.label,
};
case 'link':
return {
type: 'link',
label: node.label,
href: node.url,
};
case 'folder':
return {
type: 'category',
label: node.label,
items: convertChildren(node.children || [], tabPath),
};
case 'group':
return {
type: 'category',
label: node.label,
collapsible: false,
collapsed: false,
items: convertChildren(node.children || [], tabPath),
};
case 'divider':
return {
type: 'html',
value: '<hr>',
className: 'sidebar-divider',
};
default:
return null;
}
}
function stripExtension(filePath) {
return filePath.replace(/\.(mdx?|jsx?|tsx?)$/, '');
}
function stripPrefix(filePath, prefix) {
if (prefix && filePath.startsWith(prefix + '/')) {
return filePath.slice(prefix.length + 1);
}
return filePath;
}
function slugify(label) {
return label
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
}
module.exports = sidebars;Fumadocs sidebar integration
Fumadocs builds its sidebar from a page tree, which it normally derives from your folders and meta.json files. DocsLayout also accepts a page tree you build yourself, so you can read navigation.json and convert it.
Add the adapter below as lib/navigation.ts, then pass its tree to DocsLayout. From then on the sidebar, the breadcrumbs and the previous and next links all follow navigation.json, and meta.json files are no longer read for them.
Page paths in navigation.json are relative to the content directory you set when importing the project. With pages in content/docs and /content as the content directory, a page is written as docs/guides/install.mdx, which mirrors its URL. The adapter looks each page up in your Fumadocs source, so the URLs always match the ones Fumadocs generates.
How the node types map:
- Tabs: with one tab, its children are the sidebar. With several, each tab becomes a root folder, which Fumadocs UI shows as a switcher at the top of the sidebar.
- Pages and links: page tree pages. A link is marked external.
- Folders: collapsible folders.
- Groups: folders that cannot be collapsed.
- Dividers: separators.
Fumadocs finds the active sidebar item by its URL, so a page should appear in navigation.json only once.
import fs from 'node:fs';
import path from 'node:path';
import type * as PageTree from 'fumadocs-core/page-tree';
import { source } from './source';
type NavNode =
| { type: 'page'; label: string; path: string }
| { type: 'link'; label: string; url: string }
| { type: 'folder'; label: string; children?: NavNode[] }
| { type: 'group'; label: string; children?: NavNode[] }
| { type: 'divider' };
type NavTab = {
type: 'tab';
label: string;
path?: string;
children?: NavNode[];
};
const NAV_FILE = path.join(process.cwd(), 'navigation.json');
// content hash so client components re-memoize when navigation changes
const hash = (text: string): string => {
let value = 5381;
for (let i = 0; i < text.length; i++) {
value = (value * 33) ^ text.charCodeAt(i);
}
return (value >>> 0).toString(36);
};
const stripExtension = (filePath: string): string =>
filePath.replace(/\.(mdx?)$/, '');
const stripPrefix = (filePath: string, prefix?: string): string => {
if (prefix && filePath.startsWith(prefix + '/')) {
return filePath.slice(prefix.length + 1);
}
return filePath;
};
// Map from content-relative file path (without extension) to page URL,
// so URLs always match what the loader generates
const buildUrlMap = (): Map<string, string> => {
const urls = new Map<string, string>();
for (const page of source.getPages()) {
const filePath: string | undefined =
(page as { path?: string }).path ??
(page as { file?: { path?: string } }).file?.path;
if (filePath) {
urls.set(stripExtension(filePath), page.url);
}
}
return urls;
};
const convertNode = (
node: NavNode,
tabPath: string | undefined,
urls: Map<string, string>
): PageTree.Node | null => {
switch (node.type) {
case 'page': {
const contentPath = stripExtension(stripPrefix(node.path, tabPath));
const url = urls.get(contentPath);
if (!url) {
console.warn(`[navigation] page not found for path: ${node.path}`);
return null;
}
return { type: 'page', name: node.label, url };
}
case 'link':
return { type: 'page', name: node.label, url: node.url, external: true };
case 'folder':
return {
type: 'folder',
name: node.label,
children: convertChildren(node.children, tabPath, urls),
};
case 'group':
return {
type: 'folder',
name: node.label,
collapsible: false,
children: convertChildren(node.children, tabPath, urls),
};
case 'divider':
return { type: 'separator' };
default:
return null;
}
};
const convertChildren = (
children: NavNode[] | undefined,
tabPath: string | undefined,
urls: Map<string, string>
): PageTree.Node[] =>
(children ?? [])
.map((child) => convertNode(child, tabPath, urls))
.filter((node): node is PageTree.Node => node !== null);
const firstPage = (nodes: PageTree.Node[]): PageTree.Item | undefined => {
for (const node of nodes) {
if (node.type === 'page' && !node.external) return node;
if (node.type === 'folder') {
const found = firstPage(node.children);
if (found) return found;
}
}
return undefined;
};
export function getNavigationTree(): PageTree.Root {
const content = fs.readFileSync(NAV_FILE, 'utf-8');
const tabs = JSON.parse(content) as NavTab[];
const urls = buildUrlMap();
const $id = `navigation-${hash(content)}`;
// A single tab renders as a plain sidebar; multiple tabs become
// root folders, which Fumadocs UI renders as Layout Tabs
if (tabs.length === 1) {
return {
$id,
name: tabs[0].label,
children: convertChildren(tabs[0].children, tabs[0].path, urls),
};
}
return {
$id,
name: 'docs',
children: tabs.map((tab): PageTree.Folder => {
const children = convertChildren(tab.children, tab.path, urls);
return {
type: 'folder',
name: tab.label,
root: true,
index: firstPage(children),
children,
};
}),
};
}The adapter is written for a Next.js app that loads content with Fumadocs MDX, where lib/source.ts exports source. The Fumadocs starter has it in place.
Other frameworks
The same approach works for any framework with a programmatic sidebar config. Read navigation.json, walk the tree, and map each node type to your framework's equivalent.