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
NitroPluginand sets anameandversion, which Nitro prints when it loads the plugin. @hook(...)subscribes a method to an event. Each hooked method gets adatadict and should return it.
The hooks
| Hook | When it runs | data keys |
|---|---|---|
nitro.init | Once, when Nitro loads your plugins | config |
nitro.pre_build | nitro build only, before anything is generated | config, build_dir, minify, optimize |
nitro.pre_generate | Before pages are rendered, in builds and under nitro dev | config, source_dir, build_dir |
nitro.post_generate | After each page is rendered, before it's written | config, page_path, output |
nitro.post_build | nitro build only, after every file is written | config, 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].pyfile are written without callingpost_generate, so your blog posts won't get the change. To reach every page, usepost_buildinstead, as in the next example. - Production HTML is minified first. In
nitro build,outputis already minified, and the minifier drops optional tags like</body>and</head>. A plugin that looks for</body>works undernitro devand 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.