The Plugin Lifecycle: How Content Becomes Routes
Prerequisites
- ›Understanding of the Docusaurus monorepo structure and server/client split (Article 1)
- ›TypeScript generics and interface hierarchies
- ›Basic Webpack plugin concepts
The Plugin Lifecycle: How Content Becomes Routes
In Article 1, we saw that loadSite() calls loadPlugins() to run the plugin lifecycle. That function is where raw content — Markdown files, blog posts, pages — gets transformed into React routes accessible in the browser. It's the beating heart of Docusaurus.
The plugin lifecycle has four phases, executed in strict order: initialization, loadContent(), contentLoaded(), and allContentLoaded(). Each phase enriches the plugin with more data, and the type system tracks this progression. This article traces the complete journey from plugin configuration to route generation.
Plugin Resolution and Initialization (Phase 1)
Plugin configuration can take many forms. A user might write any of these in docusaurus.config.js:
plugins: [
'@docusaurus/plugin-content-docs', // string
['@docusaurus/plugin-content-blog', {path: 'blog'}], // [string, options]
function myPlugin(context, options) { ... }, // function
[myPlugin, {someOption: true}], // [function, options]
false, // disabled
]
The normalization logic in configs.ts#L55-L114 handles all these formats, converting them into a uniform NormalizedPluginConfig shape with a plugin function, options object, and an entryPath for resolving relative paths.
flowchart TD
STR["'@docusaurus/plugin-docs'"] --> NORM[normalizePluginConfig]
TUPLE["['plugin', options]"] --> NORM
FN["function() {...}"] --> NORM
FALSE["false / null"] -->|filtered out| SKIP[Skipped]
NORM --> NPC[NormalizedPluginConfig]
NPC --> INIT[initializePlugin]
INIT -->|plugin returns object| IP[InitializedPlugin]
INIT -->|plugin returns null| SELF_DISABLE[Self-disabled, filtered out]
The plugin ordering in configs.ts#L157-L163 is significant: preset plugins come first, then preset themes, then standalone plugins, then standalone themes. This order matters for theme alias shadowing — later entries take priority.
Initialization in init.ts#L124-L177 calls the plugin constructor with the LoadContext and validated options. A plugin can self-disable by returning null — this is the official pattern for plugins that should deactivate based on context (e.g., a sitemap plugin that's only active in production). Returning undefined throws an error; this distinguishes intentional disabling from bugs.
The initialized plugin gets enriched with options, version, and path fields. The version detection at init.ts#L79-L89 checks whether the plugin is from an npm package, a project file, a local file without a package.json, or a synthetic internal plugin.
Tip: If a plugin's
validateOptionsfunction exists, it runs before the constructor. This is where Joi schemas enforce option correctness — a pattern used extensively by the docs, blog, and pages plugins.
Content Loading (Phase 2: loadContent)
Once all plugins are initialized, executeAllPluginsContentLoading() in plugins.ts#L139-L151 runs all plugins' loadContent() methods in parallel:
sequenceDiagram
participant LP as loadPlugins()
participant DOCS as docs plugin
participant BLOG as blog plugin
participant PAGES as pages plugin
LP->>DOCS: loadContent()
LP->>BLOG: loadContent()
LP->>PAGES: loadContent()
Note over LP: All run in parallel (Promise.all)
DOCS-->>LP: docs metadata + sidebar data
BLOG-->>LP: blog posts + tags
PAGES-->>LP: page metadata
LP->>LP: Translate content (if locale requires it)
Each plugin reads data from the filesystem during loadContent(). The docs plugin, for example, scans markdown files across all configured versions, parses front matter, and builds doc metadata. The return value is typed as Content — it can be anything the plugin wants to pass to its contentLoaded() phase.
After content loading, if the current locale requires translation, the system calls translatePluginContent() at plugins.ts#L35-L71. This loads translation files from i18n/<locale>/, passes them through plugin.translateContent(), and also translates the theme config slice owned by that plugin. Note the side-effect: translated theme config is merged directly into context.siteConfig.themeConfig via Object.assign.
Route Creation and the Actions System (Phase 3: contentLoaded)
After loading content, each plugin's contentLoaded() receives an actions object with three methods. These are the only ways a plugin can affect the final output. The implementation lives in actions.ts#L28-L103:
addRoute(config) — Registers a React route. The route config includes a path, a component (a theme component alias like @theme/DocItem), and modules (data modules loaded alongside the component). Trailing slash normalization is applied automatically based on the site config.
createData(name, data) — Writes a JSON or string file to .docusaurus/<pluginName>/<pluginId>/. Returns the absolute file path, which can then be used as a module reference in addRoute(). Data is namespaced by plugin name and ID to prevent collisions.
setGlobalData(data) — Sets data accessible from any component via useGlobalData() or usePluginData(). Unlike route-specific data, global data is available on every page.
flowchart TD
CL[contentLoaded] --> AR[addRoute]
CL --> CD[createData]
CL --> SGD[setGlobalData]
AR --> |route config| ROUTES[Routes array]
CD --> |JSON file| DOT[.docusaurus/pluginName/pluginId/]
SGD --> |data| GD[globalData]
DOT --> |module path used in| AR
Each route also gets an implicit context module injected at actions.ts#L78-L83. This __plugin.json file contains {name, id} and is accessible in theme components via useRouteContext() — it's how components know which plugin created the current route.
Cross-Plugin Communication (Phase 4: allContentLoaded)
After all plugins have loaded content and created routes, allContentLoaded() runs. This phase enables plugins to read other plugins' content through the allContent parameter.
The orchestration at plugins.ts#L191-L228 aggregates all content keyed by pluginName and pluginId, then provides each plugin with a fresh actions object. Routes and global data from this phase are merged with those from contentLoaded().
sequenceDiagram
participant LP as loadPlugins
participant AGG as aggregateAllContent
participant P1 as Plugin A
participant P2 as Plugin B
LP->>AGG: Collect all plugins' content
AGG-->>LP: allContent map
LP->>P1: allContentLoaded({allContent, actions})
LP->>P2: allContentLoaded({allContent, actions})
Note over P1,P2: Plugins can read each other's content
P1-->>LP: Additional routes/globalData
P2-->>LP: Additional routes/globalData
LP->>LP: mergeResults()
This phase is less commonly used but enables powerful integrations — a plugin could generate cross-references between docs and blog posts, or build a unified search index across all content types.
Presets: Plugin Bundles
Most Docusaurus sites use preset-classic, which bundles the essential plugins and themes into a single configuration entry. The preset function at preset-classic/src/index.ts#L26-L115 is simple: it returns {themes, plugins} arrays based on the preset options.
graph TD
PC[preset-classic] --> TC[theme-classic]
PC -->|if algolia configured| TSA[theme-search-algolia]
PC -->|if docs !== false| DOCS[plugin-content-docs]
PC -->|if blog !== false| BLOG[plugin-content-blog]
PC -->|if pages !== false| PAGES[plugin-content-pages]
PC -->|debug mode| DEBUG[plugin-debug]
PC -->|if sitemap !== false| SM[plugin-sitemap]
PC -->|if svgr !== false| SVGR[plugin-svgr]
PC -->|if gtag option| GTAG[plugin-google-gtag]
The conditional inclusion pattern is worth noting: passing docs: false disables the docs plugin entirely. This is how blog-only sites work — they disable docs and rely solely on the blog plugin.
Preset expansion happens in presets.ts#L25-L66. The preset function is called with (context, presetOptions), and its returned plugins and themes are flattened into the main plugin list — before standalone plugins and themes, giving them lower priority for theme alias resolution.
Synthetic Plugins and the Bootstrap Layer
After all user plugins are initialized, two hardcoded "synthetic" plugins are appended at plugins.ts#L274-L277:
docusaurus-bootstrap-plugin (synthetic.ts#L22-L70) injects site-level client modules, scripts, and stylesheets declared in docusaurus.config.js. It converts stylesheets and scripts config arrays into HTML tags via injectHtmlTags().
docusaurus-mdx-fallback-plugin (synthetic.ts#L78-L130) adds a webpack MDX loader for .md and .mdx files that are not inside any content plugin's directory. This enables importing standalone markdown files (like README.md from the repo root) as React components. It inspects existing webpack rules to exclude paths already handled by content plugins — a pragmatic hack that works by checking rule.include arrays.
Tip: Both synthetic plugins set
version: {type: 'synthetic'}to distinguish them from user plugins in the site metadata.
Plugin Type Hierarchy
The type system models the lifecycle progression through three interfaces in plugin.d.ts:
graph TD
P["Plugin<Content><br/>name, loadContent, contentLoaded,<br/>allContentLoaded, configureWebpack, ..."] --> IP["InitializedPlugin<br/>+ options, version, path"]
IP --> LP["LoadedPlugin<br/>+ content, globalData,<br/>routes, defaultCodeTranslations"]
Plugin<Content> (line 125) — The raw plugin interface returned by the constructor. Has name, lifecycle methods, and optional hooks.
InitializedPlugin (line 196) — After initialization adds options (validated), version (detected), and path (resolved directory).
LoadedPlugin (line 203) — After content loading adds content, globalData, routes, and defaultCodeTranslations.
This progressive enrichment means you can always tell what lifecycle phase has completed by looking at the type. A function that receives LoadedPlugin is guaranteed to have content and routes available.
Putting It Together: The Full Flow
The complete loadPlugins() orchestration at plugins.ts#L264-L297 ties all four phases together: init plugins → append synthetics → loadContent() + contentLoaded() for all plugins in parallel → allContentLoaded() for cross-plugin data → merge routes and global data.
The result flows back to loadSite(), which passes it through code generation. The routes become @generated/routes, the global data becomes @generated/globalData.json, and the client React app has everything it needs to render.
In the next article, we'll follow these routes into the build pipeline — where webpack (or Rspack) compiles the client and server bundles, and the SSG engine renders every page to static HTML.