---
title: Navigation
description: 'Get a sidebar from your folders with no config, write navigation.json when you want to choose the order and groups, and keep old URLs working with redirects.'
url: 'https://larecipe.saleem.dev/3.x/writing/navigation'
version: 3.x
locale: en
last_updated: '2026-10-18'
---

> Docs index: https://larecipe.saleem.dev/3.x/llms.txt · MCP: https://larecipe.saleem.dev/mcp · Skill: https://larecipe.saleem.dev/skill.md

# Navigation

Every version has a sidebar. With no config, LaRecipe builds it from your folders and each page's frontmatter, so a new page shows up the moment you add the file.

When you want to choose the order and the groups yourself, write a `navigation.json` in the version folder (`docs/3.x/navigation.json`):

```json
{
  "$schema": "https://larecipe.saleem.dev/schemas/3.x/navigation.json",
  "groups": [
    { "group": "Get started", "pages": ["index", "installation"] },
    { "group": "Billing", "pages": ["billing/invoices", "billing/refunds"] }
  ]
}
```

Page entries are paths from the version root, without `.md`: `billing/refunds` is `docs/3.x/billing/refunds.md`, and `index` is the landing page. Pages appear in the order you list them, under the titles from their frontmatter. The `$schema` line lets your editor and your agent check the file as you type.

The same tree also sets the previous and next links at the bottom of each page, the sections of `llms.txt` and the outline in `skill.md`.

## Navigation from your folders

A version without a `navigation.json` gets its navigation from its files:

- The version's `index.md` comes first, then the other pages at the version root, then one group per folder.
- A folder's group is labelled with the title of its own page (`guides/index.md`, or a `guides.md` next to the folder). Without one, the folder name is used: `getting-started` becomes "Getting started".
- Pages sort by their frontmatter `order`, lowest first, then by title. Folders sort the same way, by their own page.
- Folders inside folders become nested groups, and pages with `hidden: true` are left out.

**An example, and the finer sorting rules**

This folder:

- docs/3.x/
  - index.md
  - configuration.md
  - installation.md
  - billing/
    - index.md
    - refunds.md
    - invoices.md

with `order: 1` on `installation.md` and `order: 2` on `configuration.md` gives this sidebar: the Introduction, Installation, Configuration, then a group labelled with the title of `billing/index.md`, holding that page first and then Invoices and Refunds, sorted by title.

- A folder's own page leads its group, and its `icon` and `order` stand for the whole folder.
- Pages without an `order` come after those with one.
- A numeric prefix in a folder name, such as `01-`, is dropped from its label.
- Hiding a folder's own page hides the whole folder.

When you outgrow this, `php artisan larecipe:navigation:freeze` writes the tree LaRecipe derives today into a `navigation.json`, so you edit from there instead of starting from nothing.

## Entries in navigation.json

Every list in the file holds the same four kinds of entry:

| Entry | Shape | Shows |
| --- | --- | --- |
| A page | `"billing/refunds"` | The page, under its own title |
| A page with its own label | `{ "page": "index", "label": "Introduction" }` | The page, under `label` |
| A group | `{ "group": "Billing", "collapsed": true, "pages": [...] }` | A labelled group; its `pages` can hold any entry, groups included |
| A link | `{ "label": "Changelog", "href": "https://example.com/changelog" }` | A link to anywhere, marked as external |

A page's `label` changes only its sidebar entry; the page keeps its own title everywhere else. A link's `href` must be an `http`, `https` or `mailto` URL, or a path on your site.

Top-level groups are always open, unless you add `"collapsed": true`. Nested groups fold, and start open unless they say `"collapsed": true`. A folded group still opens when it holds the page you are reading.

**This site's navigation.json**

```json title="docs/3.x/navigation.json"
{
  "$schema": "https://larecipe.saleem.dev/schemas/3.x/navigation.json",
  "groups": [
    { "group": "Introduction", "pages": [{ "page": "index", "label": "Introduction" }] },
    {
      "group": "Get started",
      "pages": ["get-started/installation", "get-started/your-first-docs", "get-started/configuration"]
    },
    {
      "group": "Writing",
      "pages": [
        "get-started/project-structure",
        "writing/markdown",
        "writing/components",
        "writing/code-blocks",
        "writing/navigation",
        "writing/versions",
        "writing/languages"
      ]
    },
    {
      "group": "Keeping docs current",
      "pages": [
        "keeping-current/how-it-works",
        "keeping-current/agent-instructions",
        "keeping-current/pre-push-hook",
        "keeping-current/skills",
        "keeping-current/review-modes"
      ]
    },
    {
      "group": "For agents",
      "pages": ["agents/surfaces", "agents/mcp", "agents/ask-ai"]
    },
    {
      "group": "Customize",
      "pages": ["customize/theme", "customize/custom-components", "customize/insert-points"]
    },
    {
      "group": "Operate",
      "pages": ["operate/deployment", "operate/validation", "operate/search", "operate/analytics"]
    },
    {
      "group": "Advanced",
      "collapsed": true,
      "pages": [
        "writing/live-references",
        "writing/gate-blocks",
        "keeping-current/ci-check",
        "keeping-current/sync",
        "operate/static-export",
        "sources/repository",
        "sources/database",
        "sources/docs-migrations",
        "sources/switching",
        "operate/caching",
        "operate/security"
      ]
    },
    {
      "group": "Reference",
      "pages": [
        "reference/commands",
        "reference/configuration",
        "reference/frontmatter",
        "reference/manifest",
        "reference/components",
        "reference/events",
        "reference/mcp-tools"
      ]
    },
    { "group": "Upgrade", "pages": ["upgrade"] }
  ]
}
```

**Tabs, bare lists and other shapes**

The file can take three shapes. Use the smallest one that fits.

**Groups**, the common case and the shape this site uses: one list of groups and pages, as above.

**Tabs**: several labelled sections, each with its own groups. The sidebar shows each tab's label above its groups.

```json
{
  "tabs": [
    {
      "label": "Guides",
      "groups": [
        { "group": "Get started", "pages": ["index", "installation"] }
      ]
    },
    {
      "label": "API",
      "groups": [
        { "group": "Resources", "pages": ["api/orders", "api/customers"] }
      ]
    }
  ]
}
```

**A bare list**, for a small set: pages and groups with nothing around them.

```json
["index", "installation", "configuration", "faq"]
```

Inside `{ "groups": [...] }` or a tab, you may also write `"pages": [...]` for entries that sit outside any group. When both are present, the `groups` entries come first.

The schema also accepts a tab with `href` instead of groups, as a link in place of a section. The current reader UI shows such a tab's label without the link, so use a link entry inside a group for now.

**Nested and collapsed groups**

```json
{
  "groups": [
    {
      "group": "Billing",
      "pages": [
        "billing",
        "billing/invoices",
        { "group": "Webhooks", "collapsed": true, "pages": ["billing/webhooks/events", "billing/webhooks/retries"] }
      ]
    }
  ]
}
```

A group may be named with `label` instead of `group`.

**Icons, badges and what each reader sees**

`icon` takes a [Lucide](https://lucide.dev/icons/) icon name on pages, groups, links and tabs. LaRecipe passes it to the reader UI, but the default sidebar does not draw navigation icons yet.

A page's `badge` frontmatter shows next to its entry. Pages that changed in the last day carry a small dot until the reader opens them; set the window with `ui.fresh_days` in [the configuration](https://larecipe.saleem.dev/3.x/reference/configuration.md#ui).

The navigation is the same file for everyone, but each reader sees only what they may read. Private pages are left out for readers who cannot open them, and a group with nothing left to show disappears. Entries for pages that do not exist are left out too, and `larecipe:validate` reports them.

## Hidden pages

`hidden: true` in a page's frontmatter makes it routable but unlisted: it has a URL and a Markdown twin, but it stays out of the derived navigation, search, the sitemap and the docs index in `AGENTS.md`. Use it for pages you link to but do not want in the sidebar. A hidden page is not private: anyone with its URL can read it. For pages only some readers may see, use `visibility: private` ([gate blocks and private docs](https://larecipe.saleem.dev/3.x/writing/gate-blocks.md)).

With a `navigation.json`, the file decides what is listed. A page you leave out of it gets a `pages.orphan` warning from `larecipe:validate`, because readers can only reach it by URL. Set `hidden: true` on it to say that is intended.

## Redirects

Pages move. Two features keep the old URLs working, and both redirect only when no page exists at the old URL.

List a page's old paths in its own frontmatter, so the redirect moves with the page:

```yaml
---
title: Webhooks
redirect_from:
  - hooks
  - guides/hooks
---
```

For URLs no page owns any more, list them in the set's `larecipe.json`:

```json
{
  "redirects": [
    { "from": "/docs/3.x/setup", "to": "/docs/3.x/installation" }
  ]
}
```

**How redirect_from matches**

Write each old path as the page path inside the version, as you would in `navigation.json`. A request for the old path redirects with a `301` to this page, in the same version and language, and a request for the old Markdown twin (`hooks.md`) redirects to the new twin. The full old URL path, such as `/docs/3.x/hooks`, matches as well.

LaRecipe looks for `redirect_from` among the pages of the version and language that was requested, and only among the pages the reader may see, so an old URL never reveals a private page. A page copied into a new version keeps its `redirect_from`, but a translation needs its own to redirect its old URL.

**How redirects in larecipe.json match**

`from` is the full path of the old URL: the set's prefix, then the version, then the page (with the language segment for a translation). `to` is any path or URL, and `status` defaults to `301` (`302`, `307` and `308` work too).

On a versioned set, a `from` without a version applies in every version. `{ "from": "/docs/setup", "to": "/docs/installation" }` sends `/docs/3.x/setup` to `/docs/3.x/installation` and `/docs/2.x/setup` to `/docs/2.x/installation`: a `to` inside the set that names no version stays in the reader's version. Write the version in `from` to redirect one version only.

`larecipe:validate` reports redirects that loop (`redirects.loop`) and redirects from a URL a page still lives at (`redirects.shadowed`). To redirect URLs outside the docs, or a whole old prefix, use your app's routes. [Upgrade from v2](https://larecipe.saleem.dev/3.x/upgrade.md) shows how this site keeps its LaRecipe 2 URLs working.

## Details

**Navigation in each language**

Each language uses the version's navigation, with the translated title of every page that has a translation. To change the order or translate the group labels, give the language its own file next to its pages, such as `docs/3.x/ar/navigation.json`. LaRecipe looks there first, then falls back to the version's file. See [languages](https://larecipe.saleem.dev/3.x/writing/languages.md).

**Freeze the derived navigation**

`larecipe:navigation:freeze` writes the default version's file (`--docs-version=2.x` for another, `--locale=ar` for a language's own file, `--set=` for another doc set), with the `$schema` pointer and the groups in the order readers see them now. A version that already has a `navigation.json` is left alone. It works on repository sets; a database set changes its navigation with the `update_navigation` MCP tool. Read live from this app:

**Command** `larecipe:navigation:freeze`: Write the derived navigation to navigation.json

```bash
php artisan larecipe:navigation:freeze [--set [SET]] [--docs-version [DOCS-VERSION]] [--locale [LOCALE]] [--dry-run]
```

Options:

| Option | Description | Default |
| --- | --- | --- |
| `--set=SET` | The doc set (default: the default set) |  |
| `--docs-version=DOCS-VERSION` | The version (default: the default version) |  |
| `--locale=LOCALE` | The locale (default: the default locale) |  |
| `--dry-run` | Show what would change without writing anything |  |

**What larecipe:validate checks**

| Rule | Level | When |
| --- | --- | --- |
| `navigation.schema` | error | `navigation.json` is not valid JSON, or not a shape the schema allows |
| `navigation.missing-page` | error | An entry names a page that does not exist, with the closest path as a fix |
| `pages.orphan` | warning | The version has a `navigation.json`, and a page is neither in it nor `hidden` |
| `links.unsafe` | error | A link entry whose `href` is not `http`, `https`, `mailto` or a path on your site |

Derived navigation lists every page by construction, so it never has orphans.

## Next

- [Versions](https://larecipe.saleem.dev/3.x/writing/versions.md): one `navigation.json` per version.
- [Languages](https://larecipe.saleem.dev/3.x/writing/languages.md): navigation per language.
- [Frontmatter reference](https://larecipe.saleem.dev/3.x/reference/frontmatter.md): `order`, `icon`, `badge`, `hidden` and `redirect_from`.
