Writing a plugin

Hook into every build to change pages, write extra files or stop a build that isn't ready.

A plugin is a Python class that Nitro calls at set points in a build. Use one when you want something to happen on every build without touching each page: inject a script into every page, write an extra file next to your HTML, or stop a production build that isn't configured properly.

Your first plugin

Plugins live in src/plugins/. Create src/plugins/check_config.py:

import sys

from nitro.plugins import NitroPlugin, hook


class Plugin(NitroPlugin):
    name = "check-config"
    version = "1.0.0"

    @hook("nitro.pre_build")
    def check_base_url(self, data):
        if "localhost" in data["config"].base_url:
            sys.exit("Set base_url in nitro.config.py to your real domain first.")
        return data

Then list it in nitro.config.py by its filename, without the .py:

from nitro import Config

config = Config(
    site_name="My Site",
    base_url="http://localhost:3000",
    plugins=["check_config"],
)

Run nitro build and it stops with your message, because base_url still points at localhost. Change it to your domain and the build goes through.

Three things make this a plugin:

  • The class is called Plugin. Nitro imports the file and looks for that exact name.
  • It subclasses NitroPlugin and sets a name and version, which Nitro prints when it loads the plugin.
  • @hook(...) subscribes a method to an event. Each hooked method gets a data dict and should return it.

The hooks

HookWhen it runsdata keys
nitro.initOnce, when Nitro loads your pluginsconfig
nitro.pre_buildnitro build only, before anything is generatedconfig, build_dir, minify, optimize
nitro.pre_generateBefore pages are rendered, in builds and under nitro devconfig, source_dir, build_dir
nitro.post_generateAfter each page is rendered, before it's writtenconfig, page_path, output
nitro.post_buildnitro build only, after every file is writtenconfig, build_dir, minify, optimize, stats

config is the Config object from nitro.config.py, so data["config"].base_url and data["config"].site_name are available everywhere. build_dir and source_dir are strings.

During nitro build they run in this order: init, pre_build, pre_generate, post_generate once per page, then post_build.

The NitroPlugin docstring also lists nitro.process_data and nitro.add_commands. Nitro doesn't call either of them yet, so a method hooked to them never runs.

Change every page's HTML

nitro.post_generate hands you each page's finished HTML in data["output"]. Change it and return data, and Nitro writes your version.

This plugin adds loading="lazy" to every image, so the browser only fetches images as they scroll into view:

from nitro.plugins import NitroPlugin, hook


class Plugin(NitroPlugin):
    name = "lazy-images"
    version = "1.0.0"

    @hook("nitro.post_generate")
    def lazy_load(self, data):
        data["output"] = data["output"].replace("<img ", '<img loading="lazy" ')
        return data

Two limits to know before you rely on it:

  • It skips dynamic routes. Pages generated by a [slug].py file are written without calling post_generate, so your blog posts won't get the change. To reach every page, use post_build instead, as in the next example.
  • Production HTML is minified first. In nitro build, output is already minified, and the minifier drops optional tags like </body> and </head>. A plugin that looks for </body> works under nitro dev and quietly does nothing in production.

Add a script to every page

For anything that has to reach every page, wait for nitro.post_build and edit the files in build_dir. This plugin adds the Plausible analytics script:

from pathlib import Path

from nitro import env
from nitro.plugins import NitroPlugin, hook


class Plugin(NitroPlugin):
    name = "analytics"
    version = "1.0.0"

    @hook("nitro.post_build")
    def add_script(self, data):
        domain = env.get("PLAUSIBLE_DOMAIN")
        if not domain:
            return data

        snippet = (
            f'<script defer data-domain="{domain}" '
            'src="https://plausible.io/js/script.js"></script>'
        )
        for page in Path(data["build_dir"]).rglob("*.html"):
            html = page.read_text(encoding="utf-8")
            if "</body>" in html:
                html = html.replace("</body>", snippet + "</body>", 1)
            else:
                html += snippet
            page.write_text(html, encoding="utf-8")
        return data

It handles both shapes of HTML: it inserts the script before </body> when the tag is there, and appends it to the end of the file when the minifier has removed it. Browsers treat anything after the last element as part of the body.

The domain comes from an environment variable, so the script only appears in builds where you set one:

PLAUSIBLE_DOMAIN=example.com nitro build

nitro.env also reads a .env file in your project root if you have python-dotenv installed. Because the plugin hooks post_build, nitro dev never adds the script, so your own visits during development don't count.

Stop a build that's gone wrong

Nitro ignores exceptions raised inside a hook. It logs them and carries on, and in practice you won't see any output at all. A plugin that raises when something fails leaves you with a build that reports success and is missing whatever the plugin was meant to do.

To stop the build, call sys.exit() with a message, as check_config.py does:

sys.exit("Set base_url in nitro.config.py to your real domain first.")

Nitro prints the message and exits with code 1, which also fails a CI job. The same applies when your plugin runs another program. Check its exit code yourself rather than relying on check=True, which raises:

result = subprocess.run(["npx", "@tailwindcss/cli", "-i", "tailwind.css", "-o", "src/styles/tailwind.css"])
if result.returncode != 0:
    sys.exit("Tailwind failed to compile, so the build was stopped.")

Run hooks in order

When several hooks subscribe to the same event, the one with the higher priority runs first. The default is 50:

@hook("nitro.post_generate", priority=90)
def runs_first(self, data):
    return data


@hook("nitro.post_generate", priority=10)
def runs_last(self, data):
    return data

Each hook receives the data the previous one returned. If a hook returns None, the next one gets the previous data unchanged.

Share a plugin as a package

The names in plugins=[...] are import paths. Nitro tries to import each one as a normal Python module first, and only falls back to src/plugins/<name>.py when that fails. So a plugin you install with pip works the same way, as long as the module it names has a Plugin class:

config = Config(
    plugins=["nitro_plausible.plugin"],
)

Give local plugins distinctive names. Nitro registers a local plugin under its filename, so src/plugins/json.py replaces Python's own json module for the rest of the build, and the build crashes with an error like module 'json' has no attribute 'dumps'.

Things to watch for

Restart nitro dev after editing a plugin

Nitro loads plugins once, when it starts. Changes to a plugin file take effect the next time you run nitro dev or nitro build.

Edits to a single page skip post_generate in dev

When you save a page under nitro dev, Nitro re-renders just that page without calling post_generate. Whatever your plugin adds disappears from that page until the next full rebuild. Restart nitro dev to see it again, or check with nitro build.

A plugin that can't load doesn't stop the build

If Nitro can't find or import a plugin, it prints Plugin not found and builds without it. Watch for that line, especially in CI logs.