Skip to content

Developing Nibble

Architecture

How a request becomes a page, where the code lives, and why it is arranged this way.

Rails 8 serves both the public site and the control panel, through Inertia and Vue 3 rendered on the server. SQLite holds the content, the cache and the job queue. Uploads go to S3 behind a CDN in production.

That is a deliberately short list of moving parts. A Nibble site needs one machine and one file, and a developer needs to understand one application rather than a front end and a back end that disagree.

Where things live

Everything under lib/nibble/ is Nibble’s. Everything else is the site’s.

Path Holds
lib/nibble/ the engine, autoloaded as Nibble::
lib/nibble/app/ controllers, models, jobs, mailers, services and views — laid out as Rails lays out app/
lib/nibble/frontend/ nibble-admin/ (the control panel), nibble/ (the theme runtime), entrypoints/, ssr/
lib/nibble/core_schema/ the baseline schema, first of the three layers
themes/crumbs/ the theme Nibble ships, and the starter a site’s own is copied from
app/, schema/, site/, content/, config/nibble.yml, db/migrate the site’s, and never written by an upgrade

The layout does not change any names: Admin::EntriesController is exactly that, in the place Rails expects to find it, just rooted somewhere a site’s own app/ can sit beside rather than inside.

A site’s views are looked in first, which is what makes overriding one a matter of putting a file in the right place.

How a public request becomes a page

  1. NibbleRedirectsMiddleware — redirects are checked before anything else, from a table, cached. A moved page costs one lookup, not a full render.
  2. Fixed routes/up, /assets/:uuid/…, POST /forms/:handle, /robots.txt, /sitemap.xml.
  3. The catch-all, SiteController#show, asks Nibble::Routing.resolve what this path is. It answers with an entry, a term, a redirect, or nothing.
  4. Nibble::PageProps runs the view’s query sidecar through Nibble::Query and Nibble::Presenter, gathering exactly what the page asked for.
  5. Inertia renders theme/<view> from the active theme, on the server.
  6. Nibble::PageCache stores the response against the tags Nibble::Dependencies collected along the way, echoed to a CDN as Surrogate-Key.

Step 6 is why queries live in a file rather than in the view: because Nibble ran them, it knows what the page depended on, and publishing any of those clears exactly the pages affected and no others.

The content model

Entries, terms, globals and navigation are rows. Their fields live in a data JSON column, validated against a blueprint built from the schema. A new field is not a migration.

  • Nibble::Lifecycle owns everything that happens to a record: drafts, revisions, scheduling, workflow, trash and publishing. Nothing writes content around it.
  • Nibble::Uris keeps URIs current, and records a 301 when a live record’s URI changes.
  • Nibble::Events publishes through an outbox. The subscribers registered in Nibble.boot! update relations, URIs, the audit log, notifications, the page cache, the search index and webhooks.

The outbox matters: a publish does not do eight things inline and fail halfway. It records what happened, and the subscribers follow.

The modules

Schema, Field/Fields/Fieldtype, Records::*, Lifecycle, Query, Presenter, Routing, PageProps, PageCache, Search, Seo, Sitemaps, Assets, Forms, Outbound, Webhooks, Access, Packages, ContentMigrations, Release, Releases, Eject, Install, Upgrade, Check.

Each is one idea. If you are looking for where something happens, the name is usually the answer.

The control panel

The control panel is the same application: Rails controllers under Admin::, rendering Vue pages through Inertia. It is not a separate front end talking to an API, which is why a new fieldtype is one Ruby class and one Vue component rather than a contract negotiated across a network.

A site can replace any control panel screen by putting its own file at the same path under site/pages/. See Extending Nibble.

Things deliberately not here

No Redis, no Elasticsearch, no separate front-end application, no GraphQL layer. Search is SQLite’s FTS5, the cache and queue are SQLite tables, and the front end is the same application. Each of those is a thing you do not have to run, monitor or pay for — and each is a choice that would be defensible the other way, on a much larger site.