strapi-plugin-mega-nav
v0.6.1
Published
Smart navigation for Strapi — a visual mega-menu editor with true internal links, restart-free field config, native i18n, and a built-in importer for strapi-plugin-navigation data.
Maintainers
Readme
Strapi Mega Nav
A navigation plugin that lets editors see the menu they are building. Drag and drop the tree, watch the mega-menu panel redraw as you type, and link to a content entry instead of typing its URL — so the menu follows the entry when its slug changes.
Built for sites whose header is a real mega-menu: several panel layouts, four
levels deep, rich editorial content per item, more than one locale. It ships an
importer for strapi-plugin-navigation
data and a render endpoint compatible with its payload, so an existing site can
switch over without touching its front-end code.
Why another navigation plugin
Menus tend to be modelled as rows of items, each holding a hand-typed path. That model leaks in ways that only show up in production:
| Symptom | Where it comes from |
|---|---|
| Paths like /null/null/jobs or /2/entreprises/… | The path is concatenated from ancestors, and wrappers contribute empty or numeric segments |
| Editors type EXTERNAL + a relative path for internal links | The "internal" type computes a path they can't control, so they route around it |
| A renamed entry silently 404s from the menu | The URL was copied into the menu, not referenced |
| Custom field config needs a server restart | The config is seeded into the database, then the file is ignored |
| Every custom field offered at every depth | Nothing knows that highlight only means something on a column |
| A layout silently renders as something else | Nothing checks the tree shape the layout needs |
This plugin takes the other branch on each of those.
What you get
A visual editor
One page holds the whole thing: a navigation and locale switcher, the tree, and the item form. The schematic preview above the tree redraws as you edit, using your real titles, images and CTAs inside a wireframe of the chosen layout.
It is deliberately not a screenshot of your site — it is an honest approximation whose one exact behaviour is the degradation rule: a layout that needs grouped children, dropped on a flat tree, previews as the fallback with a red banner saying so. That mismatch is invisible in a CMS and expensive on a site.
The tree is a flattened drag-and-drop list: drag vertically to reorder, horizontally to change depth, with the drop indicator turning red when a move would exceed what the layout renders. Every move also exists as a menu command (Move up/down, Indent/Outdent) — which is the keyboard and screen-reader path, and the fallback when a drag feels fiddly.
Real internal links
An item points at { uid, documentId } — a reference, not a URL. The address is
computed at render time from the entry itself, so renaming a page updates the
menu everywhere it appears. Editors pick the entry from an autocomplete over the
allowed content types, which shows each candidate's resolved path and flags the
unpublished ones.
Four link kinds, and no fifth escape hatch:
| Kind | Meaning |
|---|---|
| internal | A content entry, optionally with a query string and hash |
| external | An absolute URL |
| path | A hand-typed internal path — allowed, and reported by the health check |
| none | A heading that structures the menu without linking anywhere |
A reference whose target was deleted or unpublished degrades to a heading and
keeps its children, rather than rendering a dead link. GET /health lists every
such case, plus dead media and every path escape hatch, so drift is visible
before a visitor finds it.
CTA links are links too
A field of type url — a promo panel's CTA, typically — takes either a literal
URL or a reference to an entry, picked from the same autocomplete. It matters
most for translation: a literal /nos-offres copied into the English menu stays
French forever, because a URL is deliberately not sent to a translator. A
reference has no language — the render resolves it in the locale being served,
and follows the entry's slug.
It serializes as a plain string either way, so nothing downstream changes. An unresolvable reference emits no value at all rather than a dead CTA.
Linking what was typed by hand
Link typed targets… in the editor scans the menu on screen for path links,
relative external links and literal url field values, recognises the ones
that match a source (the URL patterns run backwards, and pathField sources are
matched on the whole path), and offers to convert them.
It proposes, it never converts on its own. When two sources could both claim a path, the pair is listed as ambiguous and left alone: a link silently re-pointed at the wrong entry is exactly the failure nobody notices until it is live. Accepting is one undo step, and edits the working copy — you still have to save.
Knowing which locale lags
A strip above the editor gives, per locale: whether a tree exists at all, how many nodes the reference locale has that it lacks, and how many titles are still character-for-character the source's. Node ids are stable across locales, so it is a real comparison — reordering a menu is not reported as drift. Treat the identical-title count as a proxy: a proper noun is legitimately identical everywhere.
Fields that are data, not config
Custom item fields live in the plugin store and are edited from Settings → Mega Nav → Fields. A change applies to the next request — there is no config file to edit, no restart, and no "the database overrides the file" drift, because there is exactly one authority.
Ten fields ship as the default schema: presentation, description, icon,
image, imagePosition, ctaLabel, ctaUrl, tagline, highlight,
offerBrand. Types available: string, text, boolean, select, media,
url, number.
Each field also carries a translatable flag used by the translation pass —
absent, it follows the type: prose yes, identifiers no.
Deleting a field that holds data asks you to choose: disable it (hidden from the form, values preserved) or delete and purge (values stripped from every tree, behind a typed confirmation). Nothing disappears by accident.
Because fields and layouts live in the store rather than a file, both screens offer Export / Import JSON: build the schema on staging, download it, import it in production. Imports go through the same validation as a manual edit, so a hand-tweaked file is rejected with a reason instead of corrupting the store.
Layouts that describe themselves
A layout is metadata: which levels exist, what each level is called, how many
children it expects, which fields feed which zone of the panel, and which
wireframe approximates it. Thirteen layouts ship as defaults — simple, list,
cards, featured, bento, split, banner, preview, columns, teams,
plus directory (a dense index for long sets, no promo), tabs (horizontal
tabs over a grid of links) and brands (tiles tinted by each item's accent
colour) — and they are editable from Settings → Mega Nav → Layouts, with a
reset.
That screen is also a gallery: each layout shows a thumbnail rendered from
its own levels and fields, so an editor can see what bento or split mean
before choosing one, and the picker on a level-1 item previews the layout it is
about to apply. The sample content is derived from the spec, so a layout you add
yourself gets a thumbnail with no code.
That metadata is what drives the item form: at each depth you get the fields the layout actually uses, with the hint that explains what they do there. Anything else sits in an "Other fields" accordion, flagged when it holds a value the current layout ignores — so switching layouts never silently eats content.
It also drives the lint, which reports a layout that will degrade, a level that expects a link but got a heading, a required field left empty, a child count outside the declared range, and any broken reference. Findings appear as a badge on the row, a marker in the preview, and a clickable list.
i18n without a parallel menu
One navigation document, one variant per locale, linked natively. Node ids are stable across locales, which is what makes Copy from locale able to sync the structure while keeping the translations already done on the target.
Internal references need no translation at all: document ids are shared across locales in Strapi v5, so the render resolves each reference in the requested locale — the French menu gets the French slug, the English one the English slug, from the same reference.
Machine-translated menus
Because the links take care of themselves, translating a menu is only about the prose. Copy and translate copies the structure from a source locale and sends every label through a translation provider in a single batched call — one request for the whole menu, so labels are translated in the context of their siblings rather than one by one.
What gets translated is decided per field, under Settings → Fields: prose is,
identifiers are not. An icon id, a CTA URL, a layout key or a lookup key like
offerBrand would break if rewritten, so they are copied verbatim; the default
follows the field type and can be overridden field by field.
By default the pass only fills the gaps — a label already translated on the target locale is kept, so a reviewed wording survives a re-run. Tick the box to retranslate everything.
Two things are reported when it finishes, because both would otherwise be found by a visitor: labels the provider returned unusable (they keep their source text), and linked entries that have no version in the target locale — those resolve to nothing and render as plain headings until the entry itself is translated.
Credentials live in Settings → Mega Nav → Translation: pick a provider —
Google (Gemini), OpenAI, Anthropic or Mistral — a model, and a key. The key is
stored server-side and never returned to the browser; a Test button
round-trips one word so you can prove it works before running a menu through it.
Resolution order, first match wins: saved in the admin, then config/plugins.ts
(ai: { provider, model, apiKey }), then the environment (MEGA_NAV_AI_KEY, or
the provider's usual variable such as GEMINI_API_KEY or OPENAI_API_KEY).
Install
npm install strapi-plugin-mega-navEnable it and declare which content types can be linked to:
// config/plugins.ts
export default ({ env }) => ({
"mega-nav": {
enabled: true,
config: {
sources: [
// `pathField` when the entry stores its own full path…
{ uid: "api::page.page", titleField: "title", pathField: "path" },
// …`pattern` when the URL is composed. Tokens read the entry;
// `{locale}` reads the requested locale.
{ uid: "api::article.article", titleField: "Title", pattern: "/blog/{Slug}" },
{
uid: "api::team.team",
titleField: "name",
pattern: "/teams/{slug}",
// Exposed under `related` in the render, for a front that wants to
// read the entry's own data instead of duplicating it in the menu.
related: { fields: ["entity", "color"], populate: ["logo", "heroImage"] },
},
],
maxDepth: 4, // tree depth cap (default 4)
cache: { ttl: 60 }, // render cache, seconds (default 60)
dropBrokenLinks: false, // false: a dead reference becomes a heading
emitLegacyLinkField: true, // mirror the resolved href into additionalFields.link (v1)
claimLegacyRoute: false, // also serve /api/navigation/render/:slug
purge: { // tell downstream caches a menu changed
urls: [env("NAV_PURGE_URL")],
secret: env("NAV_PURGE_SECRET"),
},
},
},
});Restart Strapi, then open Navigation in the admin menu. Item fields and layouts are not configured here — they are edited from Settings, live.
sources is the one thing that belongs in a file: it is bound to content types,
which only change with a deploy.
Render API
GET /api/mega-nav/render/<slug-or-documentId>| Parameter | Values | Default |
|---|---|---|
| locale | a locale code | the default locale |
| format | v1, v2 | v1 |
| status | published, draft | published |
Published renders are public (auth: false). status=draft renders the working
copy for a preview environment and requires a Strapi API token (Authorization:
Bearer …). A draft request without a valid token returns 401. Only navigations
flagged visible are served.
Published renders are cached in-process for cache.ttl seconds, and the whole
cache is dropped whenever a navigation, a source entry or an upload is written —
so an editor's publish is visible on the next request rather than after a TTL.
format=v1 — compatible payload
Reproduces the public shape of strapi-plugin-navigation's TREE render, so a
front built for it keeps working:
[
{
"id": 3021025536, // stable numeric hash of the node id
"title": "Find a job",
"type": "WRAPPER", // INTERNAL | EXTERNAL | WRAPPER
"path": "/jobs?family=Logistics", // resolved, per item — never concatenated
"externalPath": "https://…", // EXTERNAL only
"items": [ /* … */ ],
"additionalFields": {
"presentation": "columns",
"highlight": true, // a real boolean
"image": { // absolute URL, with formats and dimensions
"url": "https://…/promo.png",
"alternativeText": "…",
"width": 1200, "height": 630,
"formats": { "small": { "url": "https://…", "width": 500 } }
}
},
"related": { "__type": "api::team.team", "entity": "…", "logo": { "url": "…" } }
}
]Same field names, minus the defects: the path is built per item from its own
entry, absent on headings and never polluted with /null or numeric segments;
booleans are booleans; media URLs are absolute and carry formats. Unknown keys
found in legacy data are passed through untouched. type=TREE is accepted and
ignored, so existing query strings keep working.
format=v2 — the clean shape
[
{
"id": "01J…", // the stable node id
"title": "All offers",
"link": { "kind": "internal", "href": "/jobs", "query": "family=Logistics" },
"fields": { "icon": "briefcase" }, // typed, flat, pruned per level
"children": []
}
]A typed link object instead of the type/path/externalPath trio, fields
flattened and pruned to the levels they apply to. Worth adopting when you touch
the front anyway; v1 stays supported.
Purging downstream caches
The plugin's own cache is dropped the moment a menu can change. A cache in front
of it — a framework's SWR store, a CDN — has no way to learn that, and can only
wait out its TTL. Configure purge.urls and the plugin will POST to them at
exactly that moment:
// POST <each purge url>
// headers: content-type: application/json, x-mega-nav-secret: <purge.secret>
{
"plugin": "mega-nav",
"at": "2026-08-27T09:12:44.031Z",
"reasons": [
{ "action": "publish", "uid": "plugin::mega-nav.navigation" },
{ "action": "update", "uid": "api::page.page" }
]
}reasons is for your logs. Every notification means the same thing — drop what
you cached for this site's menus — so a receiver never has to parse it, and
should not derive cache keys from it.
| Option | Default | |
|---|---|---|
| urls | — | absolute http(s) URLs. Absent or empty disables purging |
| secret | — | sent as x-mega-nav-secret. Verify it: an unauthenticated purge route is a free cache-flush button |
| headers | {} | extra headers, merged last |
| timeoutMs | 5000 | per target |
| debounceMs | 500 | trailing window that coalesces a burst |
Two properties worth knowing. A bulk publish busts the cache once per document; the debounce turns that burst into one call per target rather than one per row. And a purge is best-effort — an unreachable target is logged and dropped, never propagated, because an unreachable CDN is a menu that is stale for a few minutes, not a content save that should fail.
A malformed purge config throws at boot. A typo in a URL should stop a deploy,
not silently never purge.
Caveat worth planning around: the plugin's cache lives in the Strapi
process. Behind more than one instance, each has its own, and a write only busts
the one that served it. Outbound purge has the mirror-image limit — the POST
reaches whichever instance the load balancer picks. If you run several of
either, keep a short cache.ttl as the floor and treat purge as the fast path.
Rendering it — starter templates
examples/ holds copy-paste starting points for React,
Next.js (App Router), shadcn/ui, Nuxt and Astro, plus a
zero-dependency core file with the types, the fetch and
the link helpers that all of them share.
Each one fetches from a server context — a Next server component, a Nuxt server
route, Astro frontmatter — so an API token never reaches the browser, maps
presentation to a panel component, and mirrors the degradation rule so a
grouped layout on a flat tree renders as a usable list instead of empty columns.
Migrating from strapi-plugin-navigation
Settings → Mega Nav → Migration reads the old plugin's tables directly — it works whether the plugin is still installed or already removed, and both can coexist during the transition (different tables, different API prefix).
Four steps: detection lists what was found, options let you allow overwriting navigations that already exist here, simulation runs the entire pipeline — reads, link normalization, media checks, locale pairing — without writing anything, and import replays exactly what the simulation reported, behind a typed confirmation when it would replace existing content.
What it does with the old data:
- Links are normalized. The old free-text
linkfield wins where it was set, and relative paths are matched back against yoursourcespatterns to become real internal references. What can't be matched stays apathand is listed. additionalFieldsare decoded. Media were stored as stringified objects and booleans as"true"/"false"; both are restored to real values, and media are re-linked after checking the file still exists.- Unknown keys are kept. Orphan keys from a removed field are preserved in the tree and reported, instead of being dropped.
- Locales are paired by tree position, so a node keeps one id across locales and the copy-from-locale workflow works on migrated content. Anything unpaired is counted.
- Duplicate morph rows pointing at the draft and published row of the same entry are deduplicated.
The report — per navigation and locale: items, link kinds, reverse matches, media re-linked or missing, unknown keys, unpaired nodes — is shown, exportable as JSON, and stored. Re-running is idempotent: an existing slug is skipped unless you opted into overwriting.
Once you are satisfied, point your front at /api/mega-nav/render/…, or set
claimLegacyRoute: true to also answer on /api/navigation/render/… and change
nothing at all — then uninstall the old plugin.
Admin API
Every route requires an authenticated admin plus one permission, registered under
Settings → Roles → Plugins → Mega Nav: read, update, settings, migrate.
| Method | Path | Permission |
|---|---|---|
| GET | /mega-nav/navigations | read |
| POST | /mega-nav/navigations | update |
| GET · PUT · DELETE | /mega-nav/navigations/:documentId | read · update · update |
| POST | /mega-nav/navigations/:documentId/publish | update |
| POST | /mega-nav/navigations/:documentId/copy-locale | update |
| GET · PUT | /mega-nav/fields | read · settings |
| POST | /mega-nav/fields/:name/purge | settings |
| GET · PUT | /mega-nav/layouts | read · settings |
| POST | /mega-nav/layouts/reset | settings |
| GET · PUT | /mega-nav/ai | read · settings |
| POST | /mega-nav/ai/test | settings |
| GET | /mega-nav/sources · /mega-nav/sources/:uid/entries | read |
| POST | /mega-nav/entries/resolve | read |
| GET | /mega-nav/health | read |
| POST | /mega-nav/migration/scan · /mega-nav/migration/run | migrate |
PUT /navigations/:documentId takes the whole tree and accepts the updatedAt
you loaded; a mismatch answers 409 rather than overwriting a colleague's work,
and the editor offers to reload or overwrite.
Data model
One document per menu, one localized JSON tree per locale:
interface NavNode {
id: string // stable across locales — the i18n pairing key
title: string
link:
| { kind: "internal"; uid: string; documentId: string; query?: string; hash?: string }
| { kind: "external"; url: string }
| { kind: "path"; path: string }
| { kind: "none" }
fields: Record<string, string | number | boolean | { media: { id: number; documentId: string } }>
hidden?: boolean // kept in the tree, excluded from the render
children: NavNode[]
}Storing the tree as one value — rather than rows joined by parent links — is what makes a drag atomic, a publish all-or-nothing, and orphan rows impossible. The costs are paid where they arise: references are resolved in batch at render time, which takes two to four queries for a whole menu regardless of its size.
The content type is plugin::mega-nav.navigation (table mega_nav_navigations),
hidden from the Content Manager and the Content-Type Builder on purpose — the
plugin ships its own editor, and raw JSON is an invitation to corrupt it.
How it degrades
| Situation | Behaviour |
|---|---|
| A referenced entry is unpublished or deleted | The item becomes a heading and keeps its children; listed in /health |
| A referenced media is gone | The field renders empty rather than a broken image |
| A source is removed from the config | Items pointing at it become headings |
| A layout is used on a tree it can't render | The front's fallback applies, and the editor showed it in red beforehand |
| A field is disabled | Hidden from the form, values preserved and still rendered |
| The plugin is uninstalled | The tree stays readable JSON in one column |
Compatibility
Strapi v5. Node >= 18. Requires @strapi/plugin-i18n for localized
navigations.
Development
npm install
npm test # vitest, on the pure logic: tree, links, render, migration, lint
npm run build
npm run verifyThe migration's normalizer has a probe that runs against a real dataset when you point it at one:
MEGANAV_REAL_DATA=/path/to/exported/json npx vitest run normalize.realdataLicense
MIT
