Introduction

✏️ What is Nibula #

Nibula is an open source static site generator built on top of Eleventy (11ty)

It has one clear mission: make the jump from small hand-written practice sites to a real project setup as gentle as possible.

🎯 Why choose Nibula #

Beginner friendly #

If you've only ever written HTML, CSS and a bit of JavaScript, moving to a framework usually feels like starting over. A new syntax, new rules, new folder structures, and a pile of documentation to get through before you can even see a page on screen.

Nibula is designed to avoid exactly that. You stay close to the three languages you already know, and the folder structure stays small enough to hold in your head — a handful of folders whose names say what's inside, instead of a convention you have to study before it makes sense.

Everything else is optional. Nunjucks adds loops and includes to your HTML, Markdown files can carry the same logic, data files can feed a whole page — but none of it is needed on day one. A page written in plain HTML works just as well, and you pick the rest up when you have a reason to.

Ready to publish #

The hard part of a first site usually isn't building it — it's everything that comes after. Meta tags, a sitemap, a robots.txt, the server config your host expects: four things nobody taught you, all at once, right when you thought you were done.

Nibula writes them while you work. Fill in two data files and the SEO tags, the sitemap and llms.txt build themselves from your pages. .htaccess and web.config are already in the output folder, ready to upload, and an nginx.conf is waiting if you have your own server.

When your pages are done, you're done — there's no second phase where you have to learn how to put a site online.

Nothing is hidden #

Every one of those files lives in the project, in the folder you'd expect, in the form you'd have written by hand. When you need to change something, you open it and change it — there's no configuration layer to learn first, and no generated code you're not supposed to touch.

Even the output folder is yours to move: one command points the whole build somewhere else — your local server's document root, for instance — and both the dev server and your own keep serving the same files as you save. See Nibula CLI.

A good fit for #

  • Showcase sites and portfolios — a handful of pages, stable content, built to be found
  • Small business sites — where a contact form on cheap shared hosting is the whole requirement, and the PHP backend is exactly that
  • Landing pages — one page, fast, with the meta tags already in place
  • Project documentation — like the page you're reading
  • The project you build to learn — the one after the tutorials, when you want something real without picking up React first
  • A small blog — up to a point: the assistant creates pages one at a time, which is fine for twenty posts and wrong for two hundred

🤔 When not to use it #

Nibula builds static pages. If your site needs users to log in, save something, or see different content depending on who they are, you want a framework with a server behind it — Next.js, Nuxt, Laravel.

It doesn't scale to hundreds of pages coming from a database or a CMS. The assistant creates pages one at a time, by hand, which is right for a site with ten or twenty of them and wrong for a catalogue.

If you already work with React, Vue or Angular, stay there. Nibula's whole point is being a first step for people who don't — for you it would be a step sideways.

Quick start

📋 Prerequisites #

Required #

  • Node.js — v18.0.0 or higher
  • Composer — latest version, only if you pick the PHP backend
  • Better Nunjucks — VS Code extension by Ed Heltzel, for syntax highlighting in .njk files
  • Material Icon Theme — VS Code extension by Philipp Kief

📦 Installation #

Install Nibula once, globally:

npm install -g nibula

This gives you the nib command, with nbl and nibula as aliases.

Global means you install it once on your machine, not once per project — from then on nib is available from any folder.

Once installed, these are the commands you'll use:

Command What it does
nib new Creates a new project
nib run Starts the dev server and rebuilds as you save
nib build Builds the site for publishing
nib clean Empties the output folder
nib cli Opens the assistant to add, rename or remove pages
nib update Updates Nibula itself
nib ver Prints the version in use

See Nibula CLI for what the assistant can do.

🚀 Project creation #

From the folder where you keep your websites, run:

nib new your-project

The scaffolder asks you three things:

Question Options
Language JavaScript or TypeScript
CSS framework Bootstrap, Bulma, Foundation, UIkit, or none
Backend Node.js or PHP

TypeScript adds types to JavaScript — pick it if you already know what that means, otherwise JavaScript is the safer start.

The CSS framework can be switched later by commenting a few lines.

The Backend is the choice worth getting right: Node and PHP offer the same API, but they run in very different places — see Deploy.

Dependencies are installed for you. If you pick Node, the Composer step is skipped entirely, so you don't need Composer on your machine at all.

Then move into the project and start the dev server:

cd your-project
nib run

Your site is now at localhost:8080, and it rebuilds every time you save a file.

Page structure

🧩 Composition #

The page settings #

Each page lives in routes/ as a .njk file.

---
title: "my-page"
permalink: "/"
layout: base.njk
---
Key Description
title The internal name of the page. Changing it breaks its styles and scripts, so leave it as it is
permalink The address the page will have on your site
layout The look the page is built into (see layouts)

The permalink needs a slash at both ends: /my-page/, not my-page

What a page is made of #

A page is made of three files, plus one entry in a shared data file.

File What it holds
routes/my-page.njk The page itself: its settings and its content
scss/pages/myPage.scss The styles that apply only to this page
js/pages/myPage.js The code that runs only on this page

The route uses the name as you typed it; the stylesheet and script use its camelCase form. The assistant handles the conversion for you.

That entry lives in data/pages.json (see Each page SEO):

"myPage": {
  "seo": {
    "title": "My Page",
    "description": "Description",
    "keywords": "",
    "noindex": false,
    "canonical": ""
  },
  "cdn": {
    "css": [],
    "js": []
  }
}

You can create, delete or rename them by hand, or let the Nibula CLI do it for you — including that record, the easiest one to forget.

Inside your page file (routes/my-page.njk) you write only the HTML content of that page — it goes automatically inside the main tag. Everything around it comes from the layout.

You can write that content directly in the page, or split it into components — separate files you write once and pull into any page with a single line.

🧱 Layouts #

A layout is the shell a page is rendered into. Your page provides the content; the layout provides everything around it — the head, the header, the footer, the stylesheets and the scripts.

Layouts live in src/frontend/layouts/, and every page picks one in its settings:

---
title: "homepage"
permalink: "/"
layout: base.njk
---

What base.njk gives you #

base.njk is the default, and it's the one every new page starts with. It writes for you:

You can pick a different one with the Nibula CLI when you create the page — if you have more than one

Part What it does
head The page title, description, canonical link and social preview tags, taken from your data files
favicon The three icon tags, pointing at assets/brand/
CSS The stylesheet of the current page, plus any CDN links you listed for it
header and footer Included from components/global/, so they're identical on every page
JS The script of the current page, the framework bundle, and any CDN scripts

The page's own stylesheet and script are found by title: a page whose title is myPage loads css/pages/myPage.css and js/pages/myPage.js. The same name is the key of its record in data/pages.json — that's why the title shouldn't be changed by hand.

Loading something from a CDN #

If a page needs a library that isn't in your project — a chart library, a font, an icon set — list its URL in that page's record in data/pages.json:

"myPage": {
  "seo": { ... },
  "cdn": {
    "css": [
      "https://cdn.example.com/lib.min.css",
      "https://cdn.another.com/lib.min.css"
      ],
    "js": [
      "https://cdn.example.com/lib.min.js",
      "https://cdn.another.com/lib.min.js"
      ]
  }
}

The layout adds them to that page only, so a library used on one page isn't downloaded on all the others.

If you want to add a CDN to all pages, you should add a link into the layout

Making your own #

You may need a full-screen login that starts straight with the content, a different header on a group of pages, or a CDN loaded on all of them instead of one at a time.

A custom layout is worth it when something changes for some pages. If it changes for all of them, edit base.njk directly.

Copy layouts/base.njk, give the copy a name of your own, and change only what those pages need.

Custom layouts examples #

No header and no footer. For a page that fills the screen on its own, create layouts/straight-content.njk and remove the two global includes:

<body>
    <!-- Drop this -->
    {% include "global/header.njk" ignore missing %}

    <main>
        {{ content | safe }}
    </main>

    <!-- Drop this -->
    {% include "global/footer.njk" ignore missing %}
</body>

Those two come from components/global/ — see components

A different header. Point the include at another component instead:

{% include "global/landing-header.njk" ignore missing %}

A library on some pages only. Add the link in the head, next to the others — no need to repeat it in each page's CDN list:

<link rel="stylesheet" href="https://cdn.example.com/lib.min.css">

Everything you leave alone — the SEO tags, the stylesheet and script links — keeps working without you touching it.

Then point a page at your layout:

---
title: "login"
permalink: "/login/"
layout: straight-content.njk
---

🖱️ Nibula CLI #

Options #

The assistant writes the three files and the pages.json record for you, keeping the names in sync. Run it from anywhere inside a project:

nib cli
Option What it does
Create page Writes the three files and the record, asking which layout to use if you have more than one
Remove page Deletes them all, after asking for confirmation
Rename page Renames the files, updates the front matter, and moves the record

homepage (index.njk) and 404 are protected — the assistant won't touch them

Renaming leaves the contents of the record alone: your SEO title, description and CDN links stay as you wrote them, and so do the components you included.

A new page arrives empty apart from its settings and two commented examples — one for a component, one for a markdown file.

Components

What is a component #

A component is a piece of a page you write once and use wherever you need it — a header, a card, a section of text. It's just a file in src/frontend/components/, and nothing has to be registered anywhere.

There are two kinds, depending on what's inside.

Create a component #

To create a component (.njk or .md), just create a new file under src/frontend/components/ and fill it with your content.

You can create your custom subfolders and organize the components path as you prefer.

src/frontend/components/
├── global/
│   ├── header.njk
│   └── footer.njk
├── legal/
│   ├── privacy-policy.md
│   └── . . .
├── cards/
│   ├── feature-card.njk
│   └── . . .
└── . . .

What is Nunjucks #

Nunjucks (.njk) is basically an HTML file that can do a few more things like if statements, for loops, includes of other components and access to your data files.

That's what lets you write a header once and have it on every page, or build a list of cards from a JSON file instead of copying the same markup ten times.

Nunjucks component examples #

components/feature-card.njk

<div class="pricing-card">
    <h3>Professional</h3>
    <p>€19 / month</p>
    <ul>
        <li>Unlimited projects</li>
        <li>Priority support</li>
        <li>Custom domain</li>
    </ul>
    <a class="btn" href="/signup/">Subscribe</a>
</div>

Writing every item by hand is fine here, but it stops being fine when the list grows or when the same values appear on more than one page. This is where .njk files help: you list your values once and loop over them.

components/feature-card.njk

{% set features = ["Unlimited projects", "Priority support", "Custom domain"] %}

<div class="pricing-card">
    <h3>Professional</h3>
    <p>€19 / month</p>
    <ul>
        {% for feature in features %}
            <li>{{ feature }}</li>
        {% endfor %}
    </ul>
    <a class="btn" href="/signup/">Subscribe</a>
</div>

With three items, writing them out by hand is shorter. Loops earn their keep when the list grows, or when the same values feed more than one page

You don't have to use this. Writing the content out by hand works just as well, and you can come back to loops when you need them

What is Markdown #

Markdown (.md) is your best choice of component if you just need to write text like documentation or an article.

They are easy to write and read even in raw form. You can create headings, lists, links, tables etc with just 1 symbol instead of HTML tags

Markdown component examples #

components/example.md

## h2 title

A paragraph of plain text. You can make a word **bold** or _italic_.

- List element
- Another one

### h3 title

One more paragraph, with a [link](https://github.com/rhaastrake/nibula) in it.

A table

| Column | Another |
| ------ | ------- |
| Value  | Value   |
| Value  | Value   |
Result

h2 title #

A paragraph of plain text. You can make a word bold or italic.

  • List element
  • Another one

h3 title #

One more paragraph, with a link in it.

A table

Column Another
Value Value
Value Value

From your SCSS, target the tags inside .markdown-body to reach every Markdown file at once, and it's how you set things like heading colours or table borders across the whole site:

.markdown-body {
  .red-color {
    color: red;
    . . .
  }
  . . .
}

Plain Markdown gives you no way to set an id or a class, and no logic. Nibula preconfigures both for you: Ids and classes in curly braces at the end of a line, so your SCSS can reach a specific piece of text:

## Title {id="title-id" class="highlight"}

Go to [the title](#title-id)
Result

Title #

Go to the title

Headings already get an id from their text. Writing one yourself replaces it with a custom one

Nibula also preconfigures Nunjucks inside Markdown, so the same if, for and variables you use in a .njk file work in a .md file too:

components/plans.md

## Our plans

{% set plans = ["Free", "Pro", "Team"] %}

{% for plan in plans %}
- {{ plan }}
{% endfor %}
Result

Our plans #

  • Free
  • Pro
  • Team

Values from a data file work too, but only if you pass them when you include the file — a .md doesn't see your data on its own

Include a component #

A component does nothing until a page asks for it. You write that line in a page, in a layout, or in another component — a card can pull in a button, a section can pull in three cards.

The two kinds are included differently, because .njk is already a template while .md has to be turned into HTML first.

Include njk #

{% include "feature-card.njk" ignore missing %}

Paths start from src/frontend/components/, so you never write that part.

If the component is in a subfolder, include the path too — for example cards/feature-card.njk

ignore missing tells the build to skip the include instead of stopping when the file isn't there

The component sees the same values the page sees, so `` works without passing anything

Include markdown #

A .md file needs renderFile, which converts it and drops the result in place. This very page is built that way, mixing .njk components and .md files.

<div class="markdown-body">
    {% renderFile "your-file.md" | componentsPath, { site: site }, "njk,md" %}
</div>

Five parts to that line:

Part What it does
markdown-body The wrapper that applies the Markdown styling
renderFile Turns the file into HTML and puts it in the page
componentsPath Fills in the src/frontend/components/ part of the path
{ site: site } Hands the file the data it needs
"njk,md" Tells Eleventy the file is Nunjucks + Markdown

The curly braces list what the Markdown is allowed to see: on the left the name the file will use, on the right the data you're passing. So { site: site } lets you write `` inside the file — see data files.

Pass as many as the file needs: { site: site, pages: pages }. Without them, a `` written inside the Markdown comes out empty

Subfolders work the same way as .njk files — legal/privacy-policy.md

Styling

🎨 What is SCSS #

SCSS is basically the CSS you already know, with variables and nesting added. If you write it the way you're used to, it still works as you'd expect.

The browser doesn't read .scss directly: your files are compiled into one CSS file when the site builds. Nibula handles that, so there's nothing to set up.

Structure #

Every page has its own SCSS file, named after the page in camel case. It's created for you under src/frontend/scss/pages/ when you add a page with the [Nibula CLI] (#nibula-cli).

For example my-page.njk (page file) is automatically linked to myPage.scss

You can write your rules there without touching anything else. They apply to that page only, which keeps things clear and keeps each page's CSS small.

scss/pages/myPage.scss

@use "../root" as root; // Usage: root.$value-name

@import "../global";

.page-element {
    background-color: root.$custom-color;
}

Every page should declare @use '../root' as root; and @import '../global';, but what does them mean?

root is simply a container with all your custom named values:

scss/_root.scss

$header-height: 10vh;
$header-padding-y: 15px;
$header-padding-x: 15px;

$header-footer-color: #11474b;

global instead is a pre-existing module with the rules that should be applied to every page including module imports to write them only once

Heres some modules that Nibula already creates for you:

File What it styles
_typography.scss Fonts and text rules
_header.scss The header styles
_footer.scss The footer styles
_markdown.scss Markdown files style

_markdown.scss Should not be touched

The leading underscore marks a file meant to be imported rather than compiled into a stylesheet of its own

Modules #

You can create your custom SCSS module under src/frontend/scss/modules/, with an underscore in front of the name.

Remember to declare @use "../root" as root; to access the values that you named and want to reuse

modules/_exampleModule.scss

@use "../root" as root;

.default-card {
  padding: 1rem;
  border-radius: 6px;
  background-color: root.$primary-color;
}

Subfolders work too. One level deeper the path changes: @use "../../root" as root

@import of global should not be here, but in the pages

Once you created your module, just @import in global or in the page file where you need that module to be applied

Order matters: a rule imported later wins over an earlier one with the same specificity. That's why the CSS framework comes first and your own modules after

CSS frameworks #

CSS frameworks is a library of pre-existing styles already built for you. You chose one between **Bootstrap, Bulma, Foundation and UIkit that Nibula already pre-installed for you right when you created your project!

You're free to avoid them if you want to stay simple, but here's the linked documentation for each one (Nothing to install)

Bootstrap | Bulma | Foundation | UIkit |

They're imported in scss/_globlal.scss between root and and your other modules. Your modules will be imported later to have priority in the style

Sometimes a full CSS framework can be uneccessary heavely for your pages, expecially if you use only few things of that. It can be filtered by all the micro CSS modules by just commenting their imports in scss/modules/frameworks/_FRAMEWORK.scss.

scss/modules/frameworks/_bootstrap.scss

@import "bootstrap/scss/alert";            // Alerts
@import "bootstrap/scss/badge";            // Badges
@import "bootstrap/scss/breadcrumb";       // Breadcrumbs
. . .

You can also switch between them or just set none just by commenting/uncomenting few lines in 3 different files:

scss/_global.scss

. . .
@import "modules/frameworks/bootstrap";
// @import "modules/frameworks/bulma";
// @import "modules/frameworks/foundation";
// @import "modules/frameworks/uikit";
. . .

frontend/layouts/base.njk (or your custom layout file)

{# Bootstrap JS #}
<script src="{{ '/js/bootstrap.bundle.min.js' | url }}" defer></script>

{# Foundation JS #}
{# <script src="{{ '/js/foundation.min.js' | url }}" defer></script> #}

{# UIkit JS #}
{# <script src="{{ '/js/uikit.min.js' | url }}" defer></script> #}
{# <script src="{{ '/js/uikit-icons.min.js' | url }}" defer></script> #}

{# Bulma — no JS needed #}

.eleventy.js

eleventyConfig.addPassthroughCopy({
// Bootstrap
"node_modules/bootstrap/dist/js/bootstrap.bundle.min.js":
    "js/bootstrap.bundle.min.js",
"node_modules/bootstrap-icons/font/fonts": "css/fonts",

// Foundation
// "node_modules/foundation-sites/dist/js/foundation.min.js": "js/foundation.min.js",

// UIkit
// "node_modules/uikit/dist/js/uikit.min.js": "js/uikit.min.js",
// "node_modules/uikit/dist/js/uikit-icons.min.js": "js/uikit-icons.min.js",

// Bulma — CSS only, no JS passthrough needed
});

Javascript

Examples use JavaScript. Everything applies to TypeScript too, except that imports leave the extension out and the project has an extra tsconfig.json

Javascript or TypeScript #

When creating a project, Nibula asks you whether to use JavaScript or TypeScript. The difference is that TypeScript makes you say what kind of value each thing holds: a string, a number, the return of the function. It checks that you're consistent while you write, so a whole class of mistakes shows up in your editor instead of in the browser.

It's more to learn, though. If you're not sure, start with JavaScript. You can always write the next project in TypeScript.

Structure #

The JavaScript structure is much the same as the SCSS one, but imports work a little differently.

Every page has its own file, named after the page in camel case, created for you under src/frontend/js/pages/ when you add a page with the Nibula CLI.

Example: my-page.njk (page file) is automatically linked to myPage.js

src/frontend/js/
├── global.js
├── modules/
└── pages/

pages/ holds one file per page, modules/ the pieces you reuse, and global.js what runs everywhere.

Each page file imports global.js, so you write the shared behaviour once:

js/pages/myPage.js

import '../global.js';

// import { initExampleModule } from '../modules/exampleModule.js';

// initExampleModule();

// Page logic here

Unlike SCSS, there's no central file to register anything in. Each page pulls in what it needs

Modules #

A module is a file holding one piece of behaviour: a menu, a slider, a form check. Add it under src/frontend/js/modules/.

modules/exampleModule.js

export function initExampleModule() {
    alert("Example module is working!");
}

The export is what makes the function reachable from outside the file. Anything you don't export stays private to the module.

Wrap the behaviour in an init function rather than running it as soon as the file loads. That way the page decides when it starts

Importing a module makes its functions available. Calling the init function is what actually starts it, so you need both lines.

In a page file, for behaviour only that page needs:

js/pages/myPage.js

import '../global.js';

import { initExampleModule } from '../modules/exampleModule.js';

initExampleModule();

In global.js, for behaviour every page needs:

import { initBurgerMenu } from "./modules/burgerMenu.js";

initBurgerMenu();

Content & Data

Two folders hold the things your site needs but that aren't markup: assets/ and data/. The difference is when they're used.

assets/ is copied into the out folder as it is, so the browser can reach its files while the page is running. Images, fonts, icons, downloadable files, and JSON your JavaScript wants to fetch.

data/ is read while the site builds and then left behind. Its JSON files feed your components and Markdown, and never reach the out folder, so nothing in there is downloadable or reachable by JavaScript.

Assets management #

Everything the browser has to load goes in src/frontend/assets/, in whatever structure you like. The folder is copied keeping that structure, so the path you write is the path it has.

src/frontend/assets/
├── brand/
│   ├── favicon.svg
│   ├── logo.svg
│   └── ...
├── images/
└── ...

You can create your own custom folders

JSON the browser can read #

A JSON file in assets/ can be fetched by your JavaScript while the page is running:

assets/data/products.json

[
  { "name": "Free", "price": 0 },
  { "name": "Pro", "price": 19 }
]

js/modules/plans.js

const response = await fetch("/assets/data/products.json");
const products = await response.json();

That's the choice to make when the data changes without rebuilding the site, or when you only need it after the visitor does something.

Anything in assets/ is public. Never put keys, credentials or anything private in there

Data files #

src/frontend/data/ holds the JSON files your templates read while the site builds.

Two are there from the start: site.json with your site-wide settings, and pages.json with the SEO of each page.

Any other .json you add becomes available on its own, with the name of the file:

data/plans.json

[
  { "name": "Free", "price": "0" },
  { "name": "Pro", "price": "19" }
]

frontend/components/plans.njk

<ul>
  {% for plan in plans %}
    <li>{{ plan.name }} — €{{ plan.price }}</li>
  {% endfor %}
</ul>

No import, no configuration, no restart. The variable is named after the file, so plans.json becomes plans.

In a Markdown file #

A .md file can use the same values, but it doesn't see them on its own. Pass them when you include the markdown component:

{% renderFile "example.md" | componentsPath, { plans: plans }, "md" %}

Building the values into the page is faster for the visitor: the content is already there when the page arrives, and search engines can read it. Fetching is what you want when the data changes on its own, or when it's too much to put in every page.

Keep data/ where it is. It was moved out of assets/ in an early version precisely so it wouldn't be published

SEO

What is SEO #

SEO is everything that helps search engines understand what every page of your site is about, so they can show it to the right people.

Someone looking for computer repair in their city searches for the service, not for your site name, they don't know it yet.

Most of it is content: a clear title, text that answers what someone searched for. The rest is a set of tags in the head tag of every page the title, the description, the image shown when the page is shared. Those tags are what Nibula writes for you.

You fill in two data files, and the layout turns them into tags, page by page.

File What it holds
data/site.json The values shared by the whole site, used whenever a page doesn't set its own
data/pages.json One record per page, with its own title, description and the rest

Each page SEO #

Every page has a record in data/pages.json, created for you when you add a page with the Nibula CLI:

"myPage": {
  "seo": {
    "title": "My Page",
    "description": "Description",
    "keywords": "",
    "noindex": false,
    "canonical": ""
  },
  "cdn": {
    "css": [],
    "js": []
  }
}
Key What it does
title What appears in the browser tab and as the clickable line in search results
description The paragraph under that line. Search engines often rewrite it, but it's your best shot
keywords Ignored by search engines since years, but it stays your own data: useful if you build an internal search or related pages
noindex true keeps the page out of search results
canonical The address search engines should treat as the real one, when the same content is reachable from more than one URL

Leave a field empty and the value from site.json is used instead. An empty canonical falls back to the page's own address, which is what you want almost always.

The key is the page's title from its front matter, and that's how the layout finds the record. Change one without the other and the page loses its tags

Best practices #

Write one title per page, and make it specific. Pricing · Acme tells a reader more than Home. Keep it under about 60 characters, or search engines cut it off.

Write the description for a person, not a robot. It doesn't affect your ranking directly, but it's what convinces someone to click. Around 150 characters.

Don't repeat the same description on every page. Two identical descriptions tell a search engine the two pages are interchangeable.

Fill in site.json before you publish. Every page falls back to those values, so a site with Site description still in there ships that text everywhere.

Use noindex for the pages that shouldn't be found — a thank-you page after a form, a staging copy, a page you keep only for a link you sent someone.

The url in site.json has to be the real one. It builds the canonical link and the addresses in your sitemap. Get it wrong and you're pointing search engines at a site that doesn't exist.

Sitemap generation #

From those same two files Nibula also generates, at every build, three things that help machines read your site:

What Who reads it What it is
sitemap.xml Search engines The list of your pages, so they're all found without following links
llms.txt AI crawlers The same list in plain text, with a short line about the site
JSON-LD Both A small block inside every page describing what the page is

The JSON-LD lives in src/frontend/layouts/base.njk and needs nothing from you. Pages marked noindex and the 404 page are left out of the sitemap.

You never edit sitemap.xml directly: add a page with the CLI and it appears, remove one and it goes. What you can change is in src/frontend/indexing/sitemap.njk:

<changefreq>weekly</changefreq>
<priority>0.8</priority>

changefreq is how often the page changes: daily for a news section, weekly for a blog, monthly or yearly for pages you rarely touch. priority goes from 0.0 to 1.0 and says which pages matter most to you, with the homepage usually at 1.0. Both are hints, and search engines are free to ignore them, so don't lose time over it.

In src/frontend/indexing/llms.njk you can add a couple of lines about what your site does and which pages matter, right under the title. It's a young format and few crawlers read it yet, but it costs one minute.

All three are built from pages.json, so a page without a record is missing from them

Robots & Crawlers #

A crawler is the program a search engine sends around the web to read pages. robots.txt sits at the root of your site and tells it what it may visit.

Nibula generates it from src/frontend/indexing/robots.njk, pointing at your sitemap and allowing everything:

Sitemap: https://yoursite.com/sitemap.xml

User-agent: *
Disallow:

That's the right default for a public site. To keep a section out, edit robots.njk and add one line per path under User-agent: *:

Disallow: /private/

robots.txt asks crawlers not to visit. It doesn't stop anyone: the page is still reachable by whoever has the address. To keep a page out of search results, use noindex

Backend

Node.js or PHP #

If you picked a backend when you created the project, you already have a small API working under /api. It's the same API written twice, once in Node.js and once in PHP: same folders, same way of writing an endpoint. Only the language changes.

PHP runs once per request and then exits, so it works on any cheap hosting.

Node is itself a program that stays running, so it needs a machine you control.

PHP Node
Runs on shared hosting ✅ ❌
Needs something always running No Yes
Extra packages Composer npm

If you're unsure, pick PHP: it runs everywhere. What each one needs on the server is in Deploy.

Examples below are in Node, whose syntax is the shorter one to read. The logic is the same in PHP: same names, same folders, same order of things.

Structure #

Everything lives in src/backend:

src/backend/
├── _core/           the engine, you don't touch this
├── api/
│   ├── public/      anyone can call these
│   └── protected/   only with an API key
├── database/
│   ├── Database.js / .php
│   └── migrations/  your .sql files
└── example.config.js / .php

You work in api. A file's path is its URL: api/public/contact.js answers at /api/contact, and subfolders work the same way.

_core receives every request, checks the API key when needed, and calls your file. Nothing to configure.

The config file #

example.config.js (or .php) is a template with no real values in it. Copy it to config.js and fill that one in:

config.js is ignored by git, so your passwords stay on your machine and never end up online. The example file stays where it is, as the list of what a new copy needs.

module.exports = {
    GENERAL_API_KEY: 'DEFAULT_KEY',

    CUSTOM_ENDPOINT_KEYS: {
        'subfolder/example-protected': 'custom-key',
    },

    GENERAL_ALLOWED_ORIGINS: [
        '*',
    ],

    CUSTOM_ENDPOINT_ORIGINS: {
        'subfolder/example-protected': ['https://app.example.com'],
    },

    DB_HOST: '127.0.0.1',
    DB_NAME: 'example_db',
    DB_USER: 'root',
    DB_PASS: '',

    APP_ENV: 'production',
};

GENERAL_API_KEY is the password every protected endpoint asks for. A client sends it in the X-Api-Key header, and without it the request is refused. Change it before you publish: DEFAULT_KEY is written in the documentation of every Nibula site.

CUSTOM_ENDPOINT_KEYS gives one endpoint a key of its own, so you can hand a single client access to a single endpoint without giving away the general key. The name is the path under protected, without the extension.

GENERAL_ALLOWED_ORIGINS is the list of sites allowed to call your API from a browser. * means anyone, which is fine while you're building and on a public API. When the API is only for your own site, put your address there instead:

GENERAL_ALLOWED_ORIGINS: [
    'https://example.com',
],

CUSTOM_ENDPOINT_ORIGINS does the same for a single endpoint.

DB_ are the four values your database needs: where it is, its name, the user and the password. Leave them as they are if you have no database.

APP_ENV decides what an error says. On production the client only sees Internal server error; on anything else it sees the real message, useful while you're working, too talkative once the site is online.

Every one of these values is read when the backend starts, so restart it after you change the file

Endpoints logic #

An endpoint is one file that answers the request and stops:

module.exports = ({ method, requestParams, Response }) => {
    if (method !== 'GET') {
        Response.error('Method not allowed', 405);
    }

    Response.success({
        message: 'It works',
        params: requestParams,
    });
};

The same file in PHP:

<?php
require_once CORE_PATH . '/modules/Response.php';

if ($method !== 'GET') {
    Response::error('Method not allowed', 405);
}

Response::success([
    'message' => 'It works',
    'params'  => $requestParams,
]);

The request arrives already unpacked:

Name What it holds
method GET, POST, and so on
query What comes after the ?: ?page=2 gives page: '2'
body The JSON the client sent, already parsed
requestParams The extra pieces of the path
headers The request headers
config Everything from your config file

Response.success(data, code) and Response.error(message, code) send the answer and stop the request: nothing after them runs.

Extra pieces of the path. With only users.js in place, a call to /api/users/42 still runs users.js, and 42 arrives in requestParams.

Never trust what a client sends: check every value before you use it, above all before it reaches the database

Database #

Database.getInstance() opens the connection with the values from your config, and reuses it for every call.

const Database = require('../../database/Database');

const db = Database.getInstance();
const [rows] = await db.execute('SELECT * FROM users WHERE id = ?', [id]);
$db = Database::getInstance();
$stmt = $db->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([$id]);
$rows = $stmt->fetchAll();

Note the ? with the value passed separately. That's what keeps somebody from typing SQL into your form and having it run. Never build a query by gluing strings together.

In database/migrations you keep the .sql files that create your tables: you run them on the server once, and they stay as the written history of your database.

With Node the database needs one extra package, see below. With PHP it works out of the box

Node service #

Only the Node backend needs this: somebody has to start the program, and start it again when the server reboots.

While you're working, npm run serve does it for you. On the server you hand it to systemd, the part of Linux that keeps programs running. The whole procedure is in Deploy.

Deploy

How to deploy #

Publishing means copying the out folder onto a web server. What you can use depends on the backend you picked when you created the project.

PHP runs once per visit: a request arrives, PHP answers, PHP exits. Nothing stays running, so there's nothing to look after. That's why it works on cheap hosting.

Node is itself a server: a program that has to stay running in the background while the web server forwards /api requests to it. Somebody has to start it and restart it if the machine reboots.

That's why shared hosting can't run the Node backend. On plans like Aruba or cPanel you upload files into a folder and that's all you can do. If that's your host, choose PHP.

Shared hosting is a plan where you share a server with other customers and only get a folder to upload into. A VPS is a whole machine that's yours, where you can install and run whatever you want

What you need #

Backend Requirements
PHP A host with PHP support, and Composer to install the backend's dependencies
Node Node.js 18 or newer, and command-line access to start the backend
None Nothing at all: any static hosting will do

Hosting options #

Target Frontend PHP backend Node backend
Shared hosting (Apache) ✅ ✅ ❌ nothing can stay running
Windows hosting (IIS) ✅ ✅ ⚠️ only with the ARR module
Your own VPS (Nginx) ✅ ✅ ✅
Static hosting (Netlify, Vercel, GitHub Pages) ✅ ❌ ❌

Static hosting serves files and nothing else. Perfect if your site has no /api endpoints, unusable if it does.

Editing the server config #

Every server needs a file telling it how to behave: which page to show on a 404, which files to keep private, where to send /api requests. Nibula writes these for you.

.htaccess and web.config are already inside out, ready to upload. To change a rule, edit the original in src/frontend/hosting/. Anything you change directly inside out is erased the next time you build.

nginx.conf is different. It sits in your project root and is not copied into out, because Nginx doesn't read config files from the site folder. You install it on the server yourself.

Apache / IIS #

Upload the contents of out, not the folder itself, into your web root. Depending on the host it's called htdocs, public_html or www.

That's it. The config file is already in there and the server reads it automatically: folder listings are off, errors show your /404.html, sensitive files are blocked, the backend source is sealed off, and /api/* is routed.

Where /api goes #

On Apache, if the server has mod_proxy the request is forwarded to Node. If it doesn't, which is the usual case on shared hosting, /api goes to PHP instead. To force PHP on a server that does have mod_proxy, comment out the mod_proxy block in .htaccess.

On IIS, /api goes to PHP by default. To use Node instead, uncomment the ApiToNode rule in web.config and delete ApiToPhp. This only works if the server has the ARR and URL Rewrite modules with proxying turned on, which means a server you control.

If you chose Node and your host has no ARR, don't upload the backend folder. Without it the static site works fine and /api/* shows your 404.html. Upload it and every /api request returns an empty 404 instead

Nginx #

Nginx doesn't read config files from the site folder: the whole site is described in one file kept elsewhere on the machine. Nibula ships that file as nginx.conf in your project root, and it handles both backends with no edits. /api goes to Node, and if Node isn't running the request falls through to PHP automatically.

Upload the contents of out to /var/www/SITE_FOLDER, then work through the steps below.

Fill in the placeholders #

Placeholder What to put there
YOUR_DOMAIN Your domain, twice: once for port 80 and once for 443
SITE_FOLDER The folder name you used under /var/www/
YOUR_CERTIFICATE The folder name under /etc/letsencrypt/live/
SITE_NAME The file name you'll use in the commands below

Install what's missing #

sudo apt update
sudo apt install -y nginx certbot python3-certbot-nginx

With the PHP backend, also:

sudo apt install -y php-fpm

Get an HTTPS certificate #

This is what makes your site load over https://. Certificates are free and last 90 days; the second command renews them automatically.

sudo certbot certonly --nginx --cert-name SITE_NAME -d YOUR_DOMAIN
sudo systemctl enable --now certbot.timer

Install the site file #

Go to the folder where Nginx keeps site files, create the file, and paste in the contents of your edited nginx.conf:

cd /etc/nginx/sites-available
sudo nano SITE_NAME

Save with CTRL + O, ENTER, CTRL + X. Then switch the site on and apply it:

sudo cp SITE_NAME ../sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

If nginx -t reports an error, fix it before reloading. Nginx keeps running on the old config until the reload succeeds, so your site stays up

If something goes wrong #

What you see What it usually means
cannot load certificate ... Permission denied You ran nginx -t without sudo
cannot load certificate ... No such file Wrong certificate name
no alternative certificate subject name The certificate doesn't cover this domain
unknown directive "http2" Nginx older than 1.25, check with nginx -v
NXDOMAIN or a timeout during certbot Your domain's DNS isn't pointing at this server yet
A blank 500 instead of your 404 page 404.html is missing from the upload
502 on /api, Node backend The Node program isn't running
502 on /api, PHP backend Wrong php-fpm socket path in the config

Starting the Node backend #

Only for the Node backend. After uploading out, install its dependencies:

cd /var/www/SITE_FOLDER/backend
npm install

Make sure config.js exists in out/backend/. If it doesn't, copy example.config.js to config.js and fill in your values.

Try it out first. screen lets you start a program, walk away, and come back to it later:

screen -S node-backend
sudo node /var/www/SITE_FOLDER/backend/_core/index.js
curl http://127.0.0.1:3000/api/example-public

If curl prints a JSON response, the backend is alive. Press CTRL + A then D to leave the screen without stopping the program; screen -r node-backend brings you back.

Then set it up for real. screen is for testing: the program dies if the server reboots. For a live site use systemd, the part of Linux that manages background programs. It starts your backend at boot and restarts it if it crashes.

Create /etc/systemd/system/backend-node.service:

[Unit]
Description=Nibula Node backend
After=network.target

[Service]
Type=simple
User=www-data
WorkingDirectory=/var/www/SITE_FOLDER/backend
ExecStart=/usr/bin/node /var/www/SITE_FOLDER/backend/_core/index.js
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

Adjust WorkingDirectory, and check the path to node with which node. Then:

sudo systemctl daemon-reload
sudo systemctl enable --now backend-node
sudo systemctl status backend-node

The last command should say active (running).

Settings the Node backend reads #

These go in the systemd file, on their own lines, as Environment=KEY=value. All three have sensible defaults.

Setting Default What it does
PORT 3000 The port Node listens on, must match the one in nginx.conf
HOST 127.0.0.1 Keep this as it is: it means local only, so Node is reachable through the web server and not directly from the internet
DOCUMENT_ROOT detected Where 404.html lives, the folder above backend/

Error detail is not set here. That's the APP_ENV key inside config.js or config.php, see Backend

About

⚙️ About Eleventy (11ty) #

Eleventy is the engine underneath. It takes your templates and your data and turns them into plain HTML files, once, when you build — which is why the result is just a folder you can upload anywhere.

Nibula is a layer on top, not a replacement. Eleventy gives you the build; Nibula adds the folder structure, the SCSS and JavaScript pipeline, the page assistant, the SEO files and the server configs. Everything Eleventy can do, your project can do — including the parts Nibula doesn't set up for you.

.eleventy.js sits in your project root, and it's the same configuration file you'd find in any Eleventy project. When you need something beyond what Nibula wires up — a new filter, a collection, a different output folder — the Eleventy documentation applies directly, with no translation needed.

⭐ Support Nibula #

Thanks for giving Nibula a try.

It's a one-person project, so every bit of feedback counts more than you'd think: if something breaks, if the documentation isn't clear, or if a feature is missing, open an issue.

If Nibula was useful to you, leaving a ⭐ on the repository is the easiest way to help other people find it.

You can also support the project by buying me a coffee on PayPal ☕🫡

Roadmap #

.

.

.

.

.

.