Skip to main content

How to create a custom plugin for Docusaurus

· 22 min read
Nick Silva
College Rising Junior

Introduction#

One of Docusaurus's best features is its growing library of open-source plugins and themes. Before starting your plugin, check out the officially supported and community maintained plugins already available. Although there are already a lot of plugins on the market, there are still many that haven't been written. This guide should help take you along the path of development.

General overview of Docusaurus#

Docusaurus is a React-based documentation website platform supported by Facebook. There are a lot of documentation and website platforms already available, so why choose Docusaurus? On their homepage they claim that Docusaurus supports using markdown or mdx for writing pages, using custom React components, creating versioned websites for different releases, easy translation, and built-in content search. From a development perspective, Docusaurus allows the user to be as involved as they want. The most simple Docusaurus sites don't have to go beyond simple markdown repositories. What's great about Docusaurus, however, is the simplicity of using React to define custom components and plugins to fill those components with data.

Docusaurus is a static-site generator. It builds a single-page application with a fast client-side navigation, leveraging the full power of React to make your site interactive. It provides out-of-the-box documentation features, but can be used to create any kind of site (personal website, product, blog, marketing landing pages, etc). - Docusaurus docs

In a bit more detail, Docusaurus manages the routing, styling, Webpack configuration, basic components, and deployment of your website while also providing a useful lifecycle api for generating plugins. So, what are plugins? Plugins are tools that allow you to update details of the Docusaurus build time, add routes and pages, or add custom components. For example, you could design an plugin that fetches data and saves from an external api, use the Docusaurus api to create routes based off of the data, and then supply components that use that data for props.

Docusaurus from a user's perspective#

To get started with Docusaurus, the best place to start is their introduction documentation. The easiest way to quickly get started is to run:

npx @docusaurus/init@latest init my-website classic...cd my-websitenpx docusaurus start

As a user, there are a couple of file's and directories that are useful to get familiar with.

  • docusaurus.config.js: This is where all the main configuration for your Docusaurus site should go. In this file you're able to change the website routing, sitebar styling, title, and other aspects of the ui generally already handled for you.
  • /docs: The docs directory is where all of your documentation files should be placed. Whether md or mdx, by adding different files in docs, Docusaurus will generate pages in your site containing the new documentation. For more custom control of the sidebar as well as other aspects of the documentation ui, check out docusaurus.config.js.
  • /blog: The blog directory, like the docs one, serves to hold all of the md or mdx blog posts that you want to be hosted on your website.
  • /src: Now things start to get interesting. The src directory holds a bunch of cool places to customize your Docusaurus site. By default /css contains a global css script that will be used across all of your site. This can be useful to update colors, fonts, or styling specific components. pages holds index.js the default page of your website.
  • /static/img: All images used in your blog or documentation should find there home in /static/img. From here they can be referenced like ![My Image](/img/blog/my_image.png).

Note: for more information on any of these topics check out the Docusaurus official documentation.

Some general vocabulary to get started:#

  • Plugin: external code that implements Docusaurus's lifecycle APIs. Plugins are generally server-side and can be used to fetch data, generate routes and pages, or load external code into your project. One singular plugin could implement the lifecycle api and have both server-side and frontend components.
  • Theme: a term used to differentiate frontend specific plugins. These often contain React components and must furfill the theme lifecycle api.
  • Preset: a plugin that helps combine and configure different presets of plugins and themes.
  • Init: general term used to describe cli-based Docusaurus initialization packages.

Anatomy of a plugin#

Throughout the rest of this blog post, I will be referencing the plugin used to design this site (docusaurus-portfolio). At any time, feel free to check out the source if you think that would be useful.

In its most simple form, a plugin is a function that supports specific parts of the Docusaurus lifecycle api. So, before continuing here, I highly recommend reading through the official lifecycle api documentation to get a general feel of what you can accomplish through a plugin.

Managing multi-package projects#

In most cases, plugins will be npm packages that users can access and install from the command line. Moreover, if you want so support independent initialization and theme plugins, then your singular plugin may end up as 2-3 npm packages.

Using Lerna#

Lerna is a super useful npm package manager that helps seamlessly develop and release multiple packages. To get started with Lerna, install it using npm and then initialize a Lerna project.

npm install --global lerna...lerna init

This should walk you through generating a lerna.json file that is maintained at root. Now, whenever you want to publish your packages to the node package manager, simply commit all changes and then run lerna publish. For more detailed instructions and more commands available, check out the Lerna documentation.

Multi-package TypeScript#

TypeScript is a powerful tool for creating and maintaining large JS codebases. For those who don't know, TypeScript is a superset of JavaScript that includes static type definitions (i.e. it makes you specify all of your variable's types before you run the program, like Java or C). Although static typing doesn't seem like a big change, adding types improves documentation/ readability and helps catch simple errors before they happen. If you don't have much TypeScript experience, however, managing TypeScript configuration and learning some of the tricks of the language can be quite invested.

For those interested in using TypeScript, I, although far from a TS expert myself, have a few notes that might make things easier in this multi-package scenario.

  1. Specify project wide TypeScript config (tsconfig.json) in your root directory and then extend it in your packages. In my case, extending the root config looked like:

    {    "extends": "../../tsconfig.json",    "compilerOptions": {        ...    },    ...}
  2. Set up default exclude config to avoid messy mistakes. In your root tsconfig, make sure to specify what places you want your TS compiler to exclude so that you don't end up accidentally compiling or overwriting files that you wanted ignored. For example, your exclude property may have: "**/tests/**" or "**/node_modules/**".

  3. Make your root config as strict as possible and only loosen TS constraints as needed. TypeScript has a lot of options to increase and decrease the thoroughness of its linting. Take advantage of this by specifying strict defaults and then loosen them in package-specific contexts as needed. Some options you might want to set include: strict, noUnusedLocals, noImplicitReturns, and noImplicitAny.

  4. If you're working on a plugin that contains React components there are two things you need to consider: 1) should you write your components in TypeScript, and 2) how can you set up TS for working with React. There is no good answer to the first question, TS offers the same benefits in React programming as it does to normal JS. The only catch is that with complicated and fast moving projects, spending the intensive time to support TSX components often becomes more tedious than useful. Then, if you decide to use default jsx, there are a couple of things you want to make sure you have set up. First, make sure allowJs is set to true so that TS won't flag when it sees your js files. Then, make sure jsx is set to react to tell TypeScript that you're using React for your project.

  5. Lastly, make sure you add all your needed Node package for supporting TypeScript. Some of these you will definitely need include: @docusaurus/types, @types/node, and typescript.

These are just a few things to get you started on using TypeScript to write a plugin, if you're interested in learning more definitely check out the source code for docusaurus-portfolio-plugin and docusaurus-plugin-content-blog.

Docusaurus dependencies#

One trouble I ran into while working on the Docusaurus platform was trying to understand its massive dependency tree. docusaurus is the main Docusaurus package but this isn't really what you want for developing your website. @docusaurus/core contains all of the main dependencies and functions of Docusaurus and can be seen as the 'trunk' of its dependencies. docusaurus-init is an officially supported plugin used to quickly develop a Docusaurus project. @docusaurus/utils is a collection of utility functions used for Docusaurus and is also the first package that we are interested in for developing our plugin. Docusaurus also has a second utility package, @docusaurus/utils-validation, that contains useful functions for validating your plugins context and options fields. Next, we have the family of Docusaurus plugins: @docusaurus/plugin-... which drive the features of Docusaurus and the bulk of which are contained in the core package.

The last packages of note are @docusaurus/types, @types/node if you plan to write your plugin in TypeScript.

Other general recommendations#

Well, with most of the background done we should be able to start getting into writing some code; before we do that, however, I have a few final pieces of advice to give.

  1. When developing a plugin, there isn't a simple and fast way to test updates to your package. I often found myself removing and reinstalling the package (locally of course). This solution worked but also vastly slowed down the development process. Two commands I recommend getting used to:
    1. yarn / npm clear
    2. yarn install --check-files
  2. Always check the source of official Docusaurus plugins if you have any questions or are looking for best practices.
  3. Join the Docusaurus Discord community to ask questions and find other interested developers.

Creating a plugin#

General file structure#

To start, it's useful to set up the project's file structure in a way conducive to producting coding.

  1. Initialize every plugin with a README. Although all of the plugins exist in a monorepo, they are all individually hosted on npm and therefore should have specific documentation.
  2. All plugins should also have their own package.json to specify version number and plugin-specific dependencies. Also, if you're using TypeScript, you should create a more specific tsconfig file that details more specific instructions for the repo.
  3. Lastly, you should keep all of the plugins code in src/index.js. Having a source directory helps specify what code should be compiled and clarifies the project's structure. This directory can also have helper modules, tests, and type definitions.

General plugin structure (index.js)#

The easiest way to organize your plugin is in one main function that takes as parameters context (Docusaurus load context) and options (the options that you specify for our plugin). Then this function exports an object that includes the plugins name and the parts of the lifecycle that they want to satisfy. In practice your file might look like,

import ... from ...;
// Other helper functions outside of the main function
export default function plugin(context: LoadContext, options: PluginOptions) {  return {    name: 'my-docusaurus-plugin',
    // Example function    async loadContent() {        ...    },
    // Example function 2    async contentLoaded({ content: pluginData, actions }) {        ...    },  };}

Shared lifecycle apis#

Our next big step in developing our plugin is satisfying the Docusaurus lifecycle APIs. These functions allow our program to interface with the Docusaurus build and frontend lifecycles to update and improve user experiences and add additional functionality.

Validating JSON objects with Joi#

One of the common aspects of the Docusaurus API that you must satisfy is user option validation. For themes this means supplying validateThemeConfig and for plugins validateOptions. For more simple projects, you can satisfy these functions by checking if the user options (passed as JSON objects) are valid and returning the result as a boolean. For larger projects, this solution becomes more cumbersome.

Docusaurus recommends using Joi to validate your plugin's options. Joi is an npm module that allows for easy definition and validation of JSON object structure. It basically gives you the ability to rigorously type check JSON to make sure that user given options are valid.

Plugin lifecycle apis#

One of the main functions of a plugin to load / fetch data and then use that data to generate some aspect of your documentation site. Unlike themes, which have the more simple goal of supplying components to your Docusaurus project, plugins are only limited by your own creativity (under the constraints of Docusaurus's lifecycle of course). For this section, we will simply be going over how to load data from an external source and then use that data to generate custom pages on our site.

There are already a ton of really cool Docusaurus plugins so I definitely recommend checking those out first. Specifically, docusaurus-plugin-remote-content is a sick plugin that allows external data to be collected by your site easily.

To get started with a plugin, you need to find a data source you're interested in. This could be an API or some data you require the users to source locally.

The two main functions we need to satisfy are loadContent() and contentLoaded().

// Uses ./api to fetch data from the Github apiasync loadContent() {  let { pluginOptions, dataOptions } = options;  pluginOptions = pluginOptions ?? {};  dataOptions = dataOptions ?? {};
  const data = await getData(...dataOptions);  return { data, pluginOptions };}
// Uses rendered data to generate react componentsasync contentLoaded({ content: myData, actions }) {  if (!myData) {    return;  }
  const {    path: sitePath,    pageTitle,    pageDescription,  } = options;
  const { addRoute, createData } = actions;
  const dataPath = await createData('data.json', JSON.stringify(myData));
  addRoute({    path: addLeadingSlash(sitePath),    component: "@theme/MyComponent",    modules: {      props: dataPath,    },    exact: true,  });},

Theme lifecycle apis#

The second main function of a plugin (or in this case a theme) is to supply components to be used in your website. These components can then be used by other lifecycle apis to generate fully-featured pages or can be simply accessed in your site's mdx pages.

To get started with a theme, create a directory inside your plugin (e.g. src/theme). Then, in this directory, add subdirectories for all of the React components for your app. By the end you should have something like src/theme/MyComponent/....

Then, all we need to do is satisfy the simple theme lifecycle APIs. For a theme in it's most simple form, there are only two functions you need to satisfy: getThemePath() and validateThemeConfig(). Of the other functions, we'll also go over getSwizzleComponentsList() which allows users to modify your components easily.

  • getThemePath: This function simply provides the path to the theme components you're supplying to Docusaurus.
  • validateThemeConfig: In order to guarantee the plugin runs correctly, we need a way to verify that the user is supplying the correct parameters (if needed). validateThemeConfig satisfies this by checking that the provided options are valid.
  • GetSwizzleComponentsList: In Docusaurus, customizing core components is called swizzling. This is an incredibly useful feature that gives users more ability to add their own unique designs to their site. To support swizzling our custom components, we have to supply a list of their names to Docusaurus.

In the end, your theme plugin should look something like this:

const path = require('path');const { validateThemeConfig } = require('./validateThemeConfig');
const swizzleAllowedComponents = ['MyComponent', 'MyComponent2'];
function theme(context, options) {  return {    name: 'my-docusaurus-plugin',
    getThemePath() {      return path.resolve(__dirname, './theme');    },
    getSwizzleComponentList() {      return swizzleAllowedComponents;    },  };}
theme.validateThemeConfig = validateThemeConfig;
module.exports = theme;

Where you specify validateThemeConfig in a seperate file.

const { Joi } = require('@docusaurus/utils-validation');
const DEFAULT_CONFIG = {  exampleOption: 'example',};exports.DEFAULT_CONFIG = DEFAULT_CONFIG;
const Schema = Joi.object({  portfolio: Joi.object({    exampleOption: Joi.string().default(DEFAULT_CONFIG).optional(),  }),});exports.Schema = Schema;
function validateThemeConfig({ themeConfig, validate }) {  return validate(Schema, themeConfig);}
module.exports = validateThemeConfig;

Initialization#

Another great Docusaurus feature is the ease at which you can quickly develop a site using the officially supported initialization package. With three commands you can have a site running locally and deployed in only two to three more. However, this configuration doesn't include custom plugins or themes. The good news is that we can write a thin wrapper around docusaurus-init to allow users to gain access to our plugin as easily as they can default Docusaurus.

What are we actually doing?#

Simply, an initialization package is a function that takes user input / parameters from the command line and uses them to build a Docusaurus site with your plugins on top. So, we use the CLI to gather data, run docusaurus-init, install our plugins, then, based on the original parameters, we overwrite and change configuration files.

Basic file structure and supplying template files#

To start, we want to begin this package just like our previous plugins. We'll need an individual package.json, README.md, and tsconfig.json, as well as a ./src directory for keeping our source code. Unlike our other packages, however, we also need to specify a directory ./bin and in it a second index.js file. This is the main executable that we will access through npx.

Some packages we need (or would like to have) to require are:

  1. @docusaurus/init: The default Docusaurus initialization package that creates a bare-bones template that we will be building on.
  2. commander & prompts : Two NPM packages that will allow us to easily collect user data from the CLI.
  3. chalk: A lightweight package that allows us to color our output.
  4. fs-extra: FS with some extra features that make transfering and updating files easier.

Finally, we need to make the templates that we will let our user's initialize to. I recommend creating a folder ./templates with subdirectories ./templates/example-template for all of your different options. I also recommend including a README inside of this to give some more details on the templates included.

What do templates look like?#

To best understand what a template looks like, check out a default Docusaurus app (here's a link to one). Anything specified here, we can change and update to be tailored to our plugin. For example, we might want to add different static assets, so we can add ./templates/example-template/static/img. Some other common ways you might want to change the default configuration include: changing the default blog and docs pages, changing the custom css, or adding local components (not really recommended).

One way we definitely want to change it is to include our plugin in docusaurus.config.js. In the authored-classic template from docusaurus-portfolio-plugin, I do this by rewriting the existing docusaurus.config.js file with a custom one. The new config then includes the package:

  themes: ['docusaurus-portfolio-theme'],  plugins: [    [      'docusaurus-portfolio-plugin',      {        username: '<GITHUB-USERNAME>',        pageTitle: 'My Site',        pageDescription: 'About me.',        userOptions: {},        repoOptions: {          type: 'all',          sort: 'updated',          direction: 'desc',        },      },    ],  ],

The key to understanding templates is to understand how Docusaurus websites work and how you think your plugin should exist in the Docusaurus lifecycle. Feel free to be as creative or conservative as you like!

Configuring a CLI program#

Next, we want to write a CLI program that allows us to collect parameters from the user that we can then use to generate the site. Some example parameters might include:

  • The template to be used
  • Websites / links to data to be collected
  • Other design / styling decisions you want to leave to the user

To start, we'll want to generate the basic outline of our project in ./bin/index.js. To see how the people at Docusaurus do it themselves, check out this link. Most of the code is templated from there.

To start we'll want to import our package as well as chalk and commander to start our CLI app.

const chalk = require('chalk');const program = require('commander');const { default: init } = require('../dist');

Next, we'll start utilizing commander to run the app. First, we need to specify the general format of how our commands will be used.

program  .version(require('../package.json').version)  .usage('<command> [options]');

Then, we need to pass it specific commands that we want our CLI to be able to furfill and we pass the parameters of those commands to our package. That sounded confusing but I promise it's not. Check out the commented code sample below from docusaurus-portfolio-plugin to get a better idea of how it works.

// Commander programprogram  // Specify the command and parameters required by your plugin  .command('init [siteName] [template] [username]')  // Give the program a simple description  .description('Initialize website with docusaurus-portfolio.')  // Define the way that the program will respond after being called  .action((siteName, template, username) => {    // Check out below for details but wrapCommand just catches errors when calling our plugin    wrapCommand(init)(siteName, template, username);  });

Finally, we just have to go through a few hoops to catch more errors and insure our utility works for most cases.

// Catch non 'init' commandsprogram.arguments('<command>').action((cmd) => {  program.outputHelp();  console.log(`  ${chalk.red(`\n  Unknown command ${chalk.yellow(cmd)}.`)}`);  console.log();});
// Make sure the CLI input is cleanprogram.parse(process.argv);
if (!process.argv.slice(2).length) {  program.outputHelp();}
// Wrap our plugin to catch errorsfunction wrapCommand(fn) {  return (...args) =>    fn(...args).catch((err) => {      console.error(chalk.red(err.stack));      process.exitCode = 1;    });}

Now we should be able to run our utility with npx ./bin/index.js init .... This will crash currently because we haven't finished the plugin, so let's do that.

Expanding the CLI#

The next step is pretty easy. We need to:

  1. prompt the user to input information if not already given in the original cli call,
  2. validate the user's inputs,
  3. send docusaurus-init,
  4. cd into the new directory,
  5. copy and update our template files,
  6. and update the Docusaurus routing for our newly added files.

Here's a big block of commented code used in docusaurus-portfolio-init that should start you in the right direction.

export default async function init(  siteName?: string,  template?: string,  username?: string,): Promise<void> {  // Start initialization.  console.log(chalk.cyan('running docusaurus-portfolio-init'));  console.log();
  // Prompt if siteName is not passed from CLI.  if (!siteName) {    const prompt = await prompts({      type: 'text',      name: 'name',      message: 'What should we name this site?',      initial: 'website',    });    siteName = prompt.name;  }  if (!siteName) {    throw Error(chalk.red('A site name is required'));  }
  // Run @docusaurus/init.  try {    execSync(      `npx @docusaurus/init@latest init --use-npm ${siteName} classic `,      { stdio: 'inherit' },    );  } catch (error) {    throw Error(chalk.red('docusarus init failed'));  }
  // Install plugin  console.log(chalk.cyan('Installing docusaurus-portfolio'));  try {    execSync(      `cd ${siteName} && npm install --save docusaurus-portfolio-plugin docusaurus-portfolio-theme`,      { stdio: 'inherit' },    );  } catch (err) {    throw Error(chalk.red('Installation of plugin failed.'));  }
  // Prompt if template is not passed from CLI.  if (!template) {    const prompt = await prompts({      type: 'text',      name: 'name',      message:        'Which template do you want to use (portfolio-classic / authored-classic)?',      initial: 'portfolio-classic',    });    template = prompt.name;  }  if (!template) {    throw Error(chalk.red('A template is required.'));  }
  // Prompt if usename is not passed from CLI.  if (!username) {    const prompt = await prompts({      type: 'text',      name: 'username',      message: 'What is your GitHub UserName?',      initial: 'example',    });    username = prompt.username;  }  if (!username) {    throw Error(chalk.red('A username is required.'));  }
  console.log();  console.log(chalk.cyan('adding portfolio config...'));
  // Delete default main page for portfolio site.  if (template === 'portfolio-classic') {    try {      await fs.rmdir(`${siteName}/src`, {        recursive: true,      });      await fs.rmdir(`${siteName}/blog`, {        recursive: true,      });      await fs.rmdir(`${siteName}/docs`, {        recursive: true,      });    } catch (error) {      console.log(chalk.red('Deleting files failed.'));      throw error;    }  }
  // Copy template files to project  if (    (template && template === 'portfolio-classic') ||    template === 'authored-classic'  ) {    try {      await fs.copy(        path.resolve(__dirname, `../templates/${template}/`),        `${siteName}/`,      );    } catch (error) {      console.log(        `Copying Docusaurus template ${chalk.cyan(template)} failed!`,      );      throw error;    }
    // Update about me page references    try {      await updateConfig(path.join(siteName, 'docs/about.mdx'), username);    } catch (error) {      console.log(chalk.red('Failed to update about me file.'));      throw error;    }  } else {    throw Error(chalk.red('A valid template is required.'));  }
  // Update docusaurus.config.js info.  try {    await updateConfig(path.join(siteName, 'docusaurus.config.js'), username);  } catch (error) {    console.log(chalk.red('Failed to update configuration file.'));    throw error;  }
  console.log();  console.log(chalk.green('Configuration successful!'));  console.log('We recommend that you begin by typing:');  console.log();  console.log(chalk.cyan('  cd'), siteName);  console.log(`  ${chalk.cyan(`yarn start / npm run start`)}`);}

Automatically updating config files to user input#

Most likely, you'll want to update some part of the configuration under some information passed by the user. The following code should provide some guidance and an example on how to do that in your program using regex replace.

In docusaurus-portfolio, this is achieved by reading in and constructing a file object using fs then replacing the default tag you've placed with something custom provided by the user. For example, the templaces in docusaurus-portfolio use the token string <GITHUB-USERNAME> as a placeholder for the user's provided GitHub username. Then, we search for this string and replace it with the user provided value and re-write the file.

async function updateConfig(configPath: string, username: string) {  const file = await fs.readFile(configPath, 'utf-8');  const newfile = file.replace(/<GITHUB-USERNAME>/g, username);  await fs.outputFile(configPath, newfile);  return;}

Conclusion#

I hope you enjoyed this rather lengthy introduction to developing a plugin for Docusaurus. There definitely is a lot more to learn. Feel free to reach out with any questions about development or docusaurus-portfolio-plugin and check out my website using it.