Skip to main content

Site data and collections

The JSON behind your templates — global site data, data collections, the Content place, and pages generated one per entry.

Templates describe shape. Data describes content. Keeping the two apart is what lets you change a phone number in one place, add a service without touching a template, or run a blog with a single page file.

Your website's data lives in two kinds of JSON file, both under resources/data/.

Global site data

resources/data/site.json is a single object of facts about the business. Every template on your website — pages, layouts, and components alike — can read it as $site.

{
    "name": "Keystone Home Services",
    "tagline": "Professional home services, made simple.",
    "phone": "(555) 204-8890",
    "phone_href": "tel:+15552048890",
    "email": "hello@keystone.example",
    "address": "482 Alder Street, Springfield",
    "hours_note": "Emergency calls answered 24/7",
    "cta_label": "Get a free estimate"
}
<footer class="border-t border-line py-12 text-sm text-muted">
    <p>{{ $site->name }} — {{ $site->address }}</p>
    <p class="mt-2">
        <a href="{{ $site->phone_href }}">{{ $site->phone }}</a>
        ·
        <a href="mailto:{{ $site->email }}">{{ $site->email }}</a>
    </p>
</footer>

The keys are yours to choose. Add whatever the website repeats — a licence number, an opening hours note, a booking URL — and reference it everywhere instead of typing it out five times.

A key that does not exist is a compile error rather than a blank, which is a feature: a typo shows up at once instead of leaving a silent hole in the footer. Use ?? where a value is genuinely optional.

Data collections

A collection is a list. Anything your website shows more than one of — services, reviews, team members, FAQs, opening hours, blog posts, menu items — belongs in one.

Each file at resources/data/collections/NAME.json is a JSON array of flat objects, and it is available in every template as $NAME (a large collection can be a folder of entry files instead — see Large collections):

[
    {
        "name": "Plumbing",
        "blurb": "Leaks, clogs, water heaters, and full re-pipes.",
        "detail": "Faucets and fixtures, drain clearing, water heater repair and replacement."
    },
    {
        "name": "Electrical",
        "blurb": "Panels, outlets, lighting, and EV chargers, wired to code.",
        "detail": "Panel upgrades, troubleshooting, recessed lighting, EV charger installation."
    }
]

resources/data/collections/services.json becomes $services:

<dl class="mt-12 grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
    @foreach ($services as $service)
        <div class="border-t border-line py-6">
            <dt class="font-semibold">{{ $service->name }}</dt>
            <dd class="mt-2 text-muted">{{ $service->blurb }}</dd>
        </div>
    @endforeach
</dl>

Adding a fourth service is now a data edit. No template changes, and every page that loops over $services picks it up.

The rules

  • The file is an array, [ … ], not an object.
  • Every entry carries every key its siblings carry. A missing property is a compile error, not an empty string. If one service has no detail, give it "detail": "".
  • Entries are flat. Strings, numbers, and booleans — not nested objects. For structured content, either flatten it into more keys or use a second collection.
  • Names are lowercase letters, numbers, and underscores, starting with a letter, up to 40 characters. blog_posts is fine; blog-posts is not, because a dashed name cannot become a template variable.
  • site, slot, errors, and entries are reserved and cannot be used as collection names.

The Content place

You do not have to edit JSON by hand. The Content icon in the builder's left rail opens your collections at full size — a sidebar of collections with their entry counts, and the chosen one as a table.

  • Search filters entries as you type; click a column heading to sort (again to reverse, a third time to clear). Collections over fifty entries page.
  • Click a row to open it in an editor beside the table. Every column is a proper field for its type: a text box, a multi-line box, a switch for true/false, a number, an image picker, or a rich-text editor for HTML bodies (bold, headings, lists, quotes, links, images). Save changes writes only what you changed; Delete entry asks first.
  • Add entry creates one shaped like the last entry and opens it straight away.
  • Structure shows what the data says each column is — its type, whether any entry leaves it empty, and the type the collection's .yml declares — the first place to look when a template and its data disagree.
  • Create a collection with +; it starts with a single title field and its own .yml.

Press Esc to close the editor, and Esc again to go back to the site. The canvas re-renders after every change, so a page reading the collection is already up to date when you return.

To add a new field to a collection, edit the JSON file in the code editor and give every entry the new key. The fields are the schema, and the grid follows the file.

The companion .yml

Every collection carries a small file beside its JSON — resources/data/collections/services.yml — that names it and types its columns:

label: Services
icon: 🔧
fields:
    name: { type: text, label: Service }
    detail: { type: textarea, label: Detail, rows: 2 }
    icon: { type: image, label: Icon }
  • label and icon are how the collection appears in the Content place.
  • fields types each column: text, textarea, url, image, select, color, number, toggle, or richtext for an HTML body — and that type decides which control the entry editor shows. A column left out is still editable; the editor guesses from its value.

Max writes this file whenever it creates a collection, and + in the Content place seeds one too. Editable sections explains how a section's repeater borrows these types.

Content types

A collection you keep adding entries to — blog posts, products, case studies, a changelog — follows one fixed shape so the builder can offer an entry editor for it:

  • the detail page at pages/posts/[posts.slug].blade.php (folder named after the collection, routed on slug) and a listing at pages/posts/index.blade.php;
  • entries with the standard columns first: title, slug, date, description, image, link, and content (the HTML body) — posts add readTime, products add price and available;
  • a .yml with content: { type: richtext };
  • drafts, when you have them, in resources/data/drafts/posts.json — never compiled, never published.

Ask Max for "a blog" or "a product catalogue" and it builds exactly this.

Large collections: one file per entry

A collection is normally one file. Once it passes 100 entries — a big blog, a long catalogue, every post of a website you brought across — it is stored as a folder instead, with one JSON object per entry named after its slug:

resources/data/collections/posts/first-post.json
resources/data/collections/posts/spring-checklist.json
resources/data/collections/posts.yml

Nothing else changes. $posts is the same variable in every template, the listing and the dynamic page work unchanged, and one posts.yml still types the columns. A folder collection reads newest first — by date, then by slug — so a listing that loops over $posts is already in order.

  • Max and the website importer switch to a folder past 100 entries on their own; you never have to. The Content place shows which form a collection uses in its header.
  • Editing an entry in the Content place rewrites only that entry's file; changing its slug renames the file; deleting the row deletes the file.
  • Keep one form per collection. If a website holds both posts.json and a posts/ folder, the folder is used and the build reports the duplicate until you remove one — the two are never merged.

Every change made in the Content place goes through the same save path as any other edit, so it lands in revision history and recompiles the website immediately.

Dynamic pages

A collection can generate pages, not just fill them. Name a page file [collection.field].blade.php and the build produces one URL per entry, matching the last segment of the address against that field.

resources/views/pages/post/[post.slug].blade.php
resources/data/collections/post.json          (or post/<slug>.json, one file per entry)

With a post.json of:

[
    {
        "slug": "zen-mornings",
        "title": "Five ways to start the day",
        "date": "March 4, 2026",
        "excerpt": "Small routines, compounding.",
        "body": "<p>The first hour sets the rest…</p>"
    },
    {
        "slug": "spring-checklist",
        "title": "Your spring maintenance checklist",
        "date": "March 18, 2026",
        "excerpt": "Twelve things worth an hour.",
        "body": "<p>Gutters first…</p>"
    }
]

the build serves /post/zen-mornings and /post/spring-checklist.

Inside a dynamic page, two variables matter:

  • $post — the single entry this URL is for, not the array. It is named after the collection.
  • $entries — the whole collection, for "more posts" lists and sidebars. The name is reserved for exactly this.
{{-- resources/views/pages/post/[post.slug].blade.php --}}
<x-layouts.main title="{{ $post->title }}" description="{{ $post->excerpt }}">
    <article class="mx-auto max-w-2xl px-6 py-20">
        <p class="text-sm text-muted">{{ $post->date }}</p>
        <h1 class="mt-2 text-4xl font-bold">{{ $post->title }}</h1>
        <div class="mt-8 space-y-6">{!! $post->body !!}</div>
    </article>

    <aside class="mx-auto max-w-2xl border-t border-line px-6 py-12">
        <h2 class="font-semibold">More posts</h2>
        <ul class="mt-4 space-y-2">
            @foreach ($entries as $entry)
                <li><a href="/post/{{ $entry->slug }}">{{ $entry->title }}</a></li>
            @endforeach
        </ul>
    </aside>
</x-layouts.main>

This is how you run a blog

An index page that lists the collection, one dynamic page that renders an entry, and that is the whole blog. Note the difference in what $post means: on an ordinary page it is the collection — the full array — and only inside the dynamic page does it narrow to the single entry that URL is for.

{{-- resources/views/pages/blog.blade.php --}}
<x-layouts.main title="Blog" description="News and advice.">
    <section class="mx-auto max-w-2xl px-6 py-20">
        <h1 class="text-4xl font-bold">Blog</h1>
        @foreach ($post as $entry)
            <article class="mt-10 border-t border-line pt-8">
                <p class="text-sm text-muted">{{ $entry->date }}</p>
                <h2 class="mt-1 text-2xl font-semibold">
                    <a href="/post/{{ $entry->slug }}">{{ $entry->title }}</a>
                </h2>
                <p class="mt-2 text-muted">{{ $entry->excerpt }}</p>
            </article>
        @endforeach
    </section>
</x-layouts.main>

Publishing a new post means adding one entry — in the Content place or in the JSON file — and never creating another page file. Ask the assistant to "write a post about spring gutter maintenance and add it to the blog" and that is precisely what it does.

Things worth knowing

  • The matched field should be URL-friendly: lowercase, dashes, no slashes. An entry whose field is empty or contains a / is skipped rather than producing a broken address.
  • The field must be unique across entries, or two posts fight over one URL.
  • A dynamic page whose collection has no entries cannot generate anything, and the build says so, naming both places an entry can live. Add at least one entry.
  • The folder the page sits in becomes the URL prefix: pages/post/[post.slug].blade.php serves /post/…, while pages/[post.slug].blade.php serves /… straight off the root.

Where to go next