Warning
This is an internal project, and is not intended for public use. No support or stability guarantees are provided.
The syncPageIndex function automatically maintains index pages by extracting metadata from documentation pages and updating parent directory indexes. It's designed to work with Next.js file-based routing, keeping navigation indexes in sync as pages are added or modified.
Note
This function is typically called by the
transformMarkdownMetadataplugin during the build process. Most users won't need to call it directly.
When you have documentation pages organized in directories:
app/components/
├── page.mdx ← Index page (auto-updated)
├── button/
│ └── page.mdx ← Button docs
├── checkbox/
│ └── page.mdx ← Checkbox docs
└── dialog/
└── page.mdx ← Dialog docs
The syncPageIndex function:
page.mdx with links and descriptionsimport { syncPageIndex } from '@mui/internal-docs-infra/pipeline/syncPageIndex';
await syncPageIndex({
pagePath: './app/components/button/page.mdx',
metadata: {
slug: 'button',
path: './button/page.mdx',
title: 'Button',
description: 'A clickable button component.',
},
});
This updates ./app/components/page.mdx with an entry for the Button page.
Update multiple pages in a single operation (more efficient with file locking):
await syncPageIndex({
pagePath: './app/components/page.mdx', // The index file itself
metadataList: [
{ slug: 'button', path: './button/page.mdx', title: 'Button', description: '...' },
{ slug: 'checkbox', path: './checkbox/page.mdx', title: 'Checkbox', description: '...' },
{ slug: 'dialog', path: './dialog/page.mdx', title: 'Dialog', description: '...' },
],
});
Update all parent indexes up to a base directory:
await syncPageIndex({
pagePath: './app/components/forms/text-field/page.mdx',
metadata: { ... },
updateParents: true,
baseDir: './app',
});
This updates:
./app/components/forms/page.mdx./app/components/page.mdx./app/page.mdxControl which directories receive index updates:
await syncPageIndex({
pagePath: './app/docs/getting-started/page.mdx',
metadata: { ... },
baseDir: './app',
include: ['docs'], // Only update indexes under 'docs'
exclude: ['docs/internal'], // But skip 'docs/internal'
});
Check if indexes are up-to-date without modifying files:
await syncPageIndex({
pagePath: './app/components/button/page.mdx',
metadata: { ... },
errorIfOutOfDate: true, // Throws if index needs updating
onlyUpdateIndexes: true, // Don't create new indexes
});
The function generates markdown with a specific structure:
# Components
[//]: # 'This section is autogenerated, but the following list can be modified except within the parentheses. Automatically sorted alphabetically.'
<PagesIndex>
- Button - ([Outline](#button), [Contents](./button/page.mdx))
- Checkbox - ([Outline](#checkbox), [Contents](./checkbox/page.mdx))
- Dialog - ([Outline](#dialog), [Contents](./dialog/page.mdx))
[//]: # 'This section is autogenerated, DO NOT EDIT AFTER THIS LINE, run: pnpm docs-infra validate'
## Button
A clickable button component.
<details>
<summary>Outline</summary>
- Sections:
- Installation
- Usage
- Props
- Examples
</details>
[Read more](./button/page.mdx)
[//]: # 'More entries...'
</PagesIndex>
The index has two zones separated by the DO NOT EDIT AFTER THIS LINE marker. You can freely edit the list above the marker to reorder entries, rename titles, and add tags. The outline sections below the marker are fully managed — never edit them manually. After changing the list, run pnpm docs-infra validate and the outlines will be regenerated to match.
To change the display order, rearrange the list items in the index file. The order you set is preserved across future syncs. New pages are appended at the end. After reordering, run pnpm docs-infra validate to regenerate the outline sections to match.
Tags are user-managed labels that appear in the index list as [Tag] indicators. They are edited directly in the index page.mdx file, not passed via the syncPageIndex API.
To add tags, edit the list entries in your index file:
- Button [New] - ([Outline](#button), [Contents](./button/page.mdx))
Tag behavior:
syncPageIndex updates the index, existing tags are kept[New] tag: New pages added to the index automatically receive a [New] tagSee transformMarkdownMetadata for the full tag format reference.
Tags describe a page's lifecycle status relative to other pages (e.g., New, Preview, Private) rather than intrinsic content. They are stored in the page index—not in the individual page's frontmatter or metadata export—for several reasons:
No circular rebuilds: Tags like [New] can be added automatically when a page is created. If the tag were written back into the page itself, it would trigger a rebuild of that page, which in turn would re-run the indexer—creating a circular dependency. Hoisting tags into the parent index avoids this.
User files stay untouched: Progressing a page from [Private] → [Preview] → [Stable] only requires editing the index file. The source Markdown is never modified, which keeps diffs clean and Code Owners predictable—changing a release phase triggers different reviewers than changing page content.
Route-prefix scope: A tag applies to the entire route prefix, not just a single page. For a component with sub-routes (overview, examples, API reference), the tag on the index entry covers all of them:
app/components/drawer/ (tag: New)
layout.tsx
page.mdx (overview)
examples/
page.mdx
api-reference/
page.mdx
Quick scanning: Reviewing the index file gives an at-a-glance view of which pages are new, in preview, private, etc.—without opening each page individually.
Both tags and keywords end up in the sitemap, but they serve distinct purposes:
| Aspect | Tags | Keywords |
|---|---|---|
| Purpose | Lifecycle / status metadata | Content-related concepts |
| Examples | [New], [Preview], [Private] | interactive, forms, layout |
| Source | Edited in the index file | Exported from the page's metadata |
| Scope | Relative to sibling pages | Intrinsic to the page's content |
| Use case | Sidebar badges, filtering by phase | Search relevance, content categorization |
Think of it as: a component is "New" relative to the other components in the index, while a keyword like "forms" is an inherent property of the page's content. Both are available on page.tags and page.keywords in the sitemap for consumers to use however they like—for example, page.tags?.includes('New') to show a badge, or page.keywords?.includes('forms') to filter search results.
Pages are automatically labeled based on their audience and index flags. The label is derived from the audience value, with "Index" appended for pages that have child pages:
- Button - ([Outline](#button), [Contents](./button/page.mdx))
- InternalGrid - (Private, [Outline](#internal-grid), [Contents](./internal-grid/page.mdx))
- Components - (Index, [Outline](#components), [Contents](./components/page.mdx))
- Getting Started - (Public Index, [Outline](#getting-started), [Contents](./getting-started/page.mdx))
- Tutorial - (Introductory, [Outline](#tutorial), [Contents](./tutorial/page.mdx))
audience is read from metadata.other?.audience on each page (for example, export const metadata = { other: { audience: 'private' } })index is set automatically when a page has child pages (i.e., it serves as a section index)audience and no children have no labelYou can override a page's display title by editing the list item text in the index file. The list item title is independent of the heading title that appears in the detail section below it.
For example, if a page's H1 is "Button Component" but you want a shorter label in the list:
- Button - ([Outline](#button-component), [Contents](./button-component/page.mdx))
Here the list item says "Button" while the detail section heading remains "Button Component". The override is detected whenever the list item text differs from the heading text.
Override behavior:
syncPageIndex updates the index, an overridden title is kept—only the detail section heading and metadata are updated to match the page's actual titleuseSearch and the sitemap see the overridden title (via SitemapPage.title) rather than the original page titleFor components with API documentation, include parts or exports to make them searchable:
await syncPageIndex({
pagePath: './app/components/dialog/page.mdx',
metadata: {
slug: 'dialog',
path: './dialog/page.mdx',
title: 'Dialog',
description: 'A modal dialog component.',
// For multi-part components
parts: {
Root: { props: ['open', 'onOpenChange'], dataAttributes: ['data-state'] },
Trigger: { props: ['asChild'] },
Content: { props: ['side', 'align'], cssVariables: ['--dialog-width'] },
},
// Or for single exports
exports: {
Dialog: { props: ['open', 'onOpenChange', 'modal'] },
},
},
});
This metadata is used by useSearch to enable searching for specific props, data attributes, and CSS variables.
For links to external resources, use skipDetailSection:
await syncPageIndex({
pagePath: './app/resources/page.mdx',
metadata: {
slug: 'github',
path: 'https://github.com/mui/base-ui',
title: 'GitHub',
tags: ['External'],
skipDetailSection: true, // Don't generate a detail section
},
});
Parses a markdown index file and extracts page metadata:
import { markdownToMetadata } from '@mui/internal-docs-infra/pipeline/syncPageIndex';
const result = await markdownToMetadata(markdownContent);
// result.title - Index title
// result.pages - Array of PageMetadata
// result.description - Optional description
Merges new page metadata into existing markdown:
import { mergeMetadataMarkdown } from '@mui/internal-docs-infra/pipeline/syncPageIndex';
const updatedMarkdown = await mergeMetadataMarkdown(existingMarkdown, newMetadata, options);
The function uses proper-lockfile to prevent concurrent writes:
metadataList) are more efficient as they require only one lockNext.js route groups (directories in parentheses) are handled specially:
(public), (content), etc. are skipped when finding parent directoriesapp/(public)/(content)/components/button/page.mdx
↓
Updates: app/(public)/(content)/components/page.mdx
Title derived from: 'components' → 'Components'
Updates the parent directory’s index file with metadata from a page.
This function:
| Property | Type | Description |
|---|---|---|
| pagePath | | The path to the page file (e.g., ‘./app/components/button/page.mdx’) OR the path to the index file itself when using metadataList |
| metadata | | The metadata extracted from the page Either provide this for a single update, or metadataList for batch updates |
| metadataList | | Array of metadata for batch updates When provided, all metadata will be merged in a single file lock/write operation |
| indexTitle | | The title for the index file (e.g., ‘Components’) If not provided, will be derived from the parent directory name (e.g., ‘app/components/page.mdx’ -> ‘Components’) |
| indexFileName | | The name of the index file to update (e.g., ‘page.mdx’) Defaults to ‘page.mdx’ |
| lockOptions | | Lock options for proper-lockfile |
| baseDir | | The base directory to stop recursion at (e.g., ‘./app’) If not provided, will continue until reaching the root directory |
| updateParents | | Whether to update parent indexes recursively |
| include | | Path patterns to include when creating/updating indexes Only indexes within these paths will be created or modified Patterns are matched against the directory path relative to baseDir |
| exclude | | Path patterns to exclude when creating/updating indexes Indexes matching these patterns will not be created or modified Patterns are matched against the directory path relative to baseDir |
| onlyUpdateIndexes | | Only update existing indexes, don’t create new ones When true, will skip updating if the index file doesn’t already exist |
| markerDir | | Directory to write marker files when indexes are updated.
Path is relative to baseDir.
Set to false to disable marker file creation.
A marker file will be created at: |
| errorIfOutOfDate | | Throw an error if the index is out of date or missing. Useful for CI environments to ensure indexes are committed. |
| indexWrapperComponent | | Optional component name to wrap the autogenerated index content.
When provided, the content will be wrapped like: |
| preserveExistingTitleAndSlug | | If true, preserve existing page titles and slugs when they exist. New metadata titles/slugs will only be used if the existing page doesn’t have them. Useful when auto-generating metadata that shouldn’t override user-set values. |
Promise<void>type SyncPageIndexOptions = {
/**
* The path to the page file (e.g., './app/components/button/page.mdx')
* OR the path to the index file itself when using metadataList
*/
pagePath: string;
/**
* The metadata extracted from the page
* Either provide this for a single update, or metadataList for batch updates
*/
metadata?: PageMetadata;
/**
* Array of metadata for batch updates
* When provided, all metadata will be merged in a single file lock/write operation
*/
metadataList?: PageMetadata[];
/**
* The title for the index file (e.g., 'Components')
* If not provided, will be derived from the parent directory name
* (e.g., 'app/components/page.mdx' -> 'Components')
*/
indexTitle?: string;
/**
* The name of the index file to update (e.g., 'page.mdx')
* Defaults to 'page.mdx'
*/
indexFileName?: string;
/** Lock options for proper-lockfile */
lockOptions?: lockfile.LockOptions;
/**
* The base directory to stop recursion at (e.g., './app')
* If not provided, will continue until reaching the root directory
*/
baseDir?: string;
/**
* Whether to update parent indexes recursively
* @default false
*/
updateParents?: boolean;
/**
* Path patterns to include when creating/updating indexes
* Only indexes within these paths will be created or modified
* Patterns are matched against the directory path relative to baseDir
*/
include?: string[];
/**
* Path patterns to exclude when creating/updating indexes
* Indexes matching these patterns will not be created or modified
* Patterns are matched against the directory path relative to baseDir
*/
exclude?: string[];
/**
* Only update existing indexes, don't create new ones
* When true, will skip updating if the index file doesn't already exist
* @default false
*/
onlyUpdateIndexes?: boolean;
/**
* Directory to write marker files when indexes are updated.
* Path is relative to baseDir.
* Set to false to disable marker file creation.
* A marker file will be created at: `${markerDir}/${relativePath}/page.mdx`
* @default false
*/
markerDir?: string | false;
/**
* Throw an error if the index is out of date or missing.
* Useful for CI environments to ensure indexes are committed.
* @default false
*/
errorIfOutOfDate?: boolean;
/**
* Optional component name to wrap the autogenerated index content.
* When provided, the content will be wrapped like: `<ComponentName>...</ComponentName>`
* @example 'PagesIndex'
*/
indexWrapperComponent?: string;
/**
* If true, preserve existing page titles and slugs when they exist.
* New metadata titles/slugs will only be used if the existing page doesn't have them.
* Useful when auto-generating metadata that shouldn't override user-set values.
* @default false
*/
preserveExistingTitleAndSlug?: boolean;
}docs-infra validate - CLI command that validates indexesloadServerSitemap - Uses page indexes to build sitemapsloadServerPageIndex - Loads page metadata at runtime