npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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-nav

Enable 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 link field wins where it was set, and relative paths are matched back against your sources patterns to become real internal references. What can't be matched stays a path and is listed.
  • additionalFields are 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 verify

The 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.realdata

License

MIT