Skip to main content

Collections

A collection is repeating content: blog posts, case studies, team members, testimonials. Each item is one YAML file, and the schema in your config is what the editor builds its "new item" form from.

Declare it

copy-ink.config.ts
collections: {
posts: {
label: 'Blog posts',
path: '_collections/posts',
slugFrom: 'title',
orderBy: { field: 'date', direction: 'desc' },
fields: {
title: { type: 'text', required: true },
date: { type: 'date', required: true },
cover: { type: 'image' },
excerpt: { type: 'text', multiline: true },
body: { type: 'richtext' },
},
},
}
KeyMeaning
labelShown in the editor's collection list. Defaults to a prettified name
pathDirectory holding the items, relative to the locale's content root
slugFromField whose value seeds a new item's slug
orderByDefault sort for <Copy.List> and getList
fieldsThe schema — see field types

Conventionally path is _collections/<name>, which keeps items out of the route-mapped part of the tree.

One item

app/blog/[slug]/page.tsx
import { Copy } from 'copy-ink'

export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params

return (
<Copy.Item collection="posts" item={slug}>
<h1><Copy field="title" /></h1>
<Copy.Rich field="body" />
</Copy.Item>
)
}

Everything inside <Copy.Item> resolves against that item. A missing item renders nothing, unless missingField: 'error' is set, in which case it throws UNKNOWN_ITEM.

A list

app/blog/page.tsx
<Copy.List collection="posts" limit={10} orderBy="date" direction="desc">
{(item) => (
<article key={item.slug}>
<Copy field="title" />
<Copy.Image field="cover" />
</article>
)}
</Copy.List>

The child is a function receiving (item, index). Each row is scoped to its own item, so bare <Copy field="…"> inside resolves there.

<Copy.List> also takes:

  • filter(item) => boolean, run after ordering
  • empty — a fallback rendered when nothing is visible
  • locale — read another locale's items
<Copy.List
collection="posts"
filter={(item) => item.data.featured === true}
empty={<p>Nothing published yet.</p>}
>
{(item) => <Card key={item.slug} slug={item.slug} />}
</Copy.List>

Static params

import { getSlugs } from 'copy-ink/server'

export async function generateStaticParams() {
return (await getSlugs('posts')).map((slug) => ({ slug }))
}

Drafts

An item with _draft: true is hidden from <Copy.List> and getList outside editor mode, so it never publishes by accident. In editor mode it is listed, so the client can find it and publish it.

content/_collections/posts/hello-world.yml
_draft: true
title: "Hello world"
date: 2026-05-01

The editor exposes this as hide/publish on the item, so a client never has to know the key exists.

Ordering

Three layers, in priority order:

  1. _order on the item — set by drag-and-drop in the editor.
  2. orderBy passed to <Copy.List> / getList.
  3. orderBy on the collection schema.

Opening an item

The editor's Content panel lists a collection's items with an Open button, which needs to know the route that renders one. copy-ink types works that out from your JSX — a page file that names the collection and sits under a dynamic segment is the item route:

app/blog/[slug]/page.tsx -> /blog/[slug]

Nothing to configure, but it does mean the button only appears after types has run. When the scan cannot tell — more than one dynamic segment in the route — declare it instead:

posts: { path: '_collections/posts', slugFrom: 'title', route: '/blog/[slug]', fields: { /* … */ } }

An explicit route always wins over the scan.

Slugs

New items get a slug generated from the slugFrom field, checked for uniqueness, and editable before the first save. After that the slug is the item's identity — the file name and the URL — so the editor does not offer to change it. Renaming is a developer operation: move the file and add a redirect.

Item creation is developer-free

Everything the editor needs to create an item comes from the schema: which fields exist, which are required, what type each input is, what the slug is seeded from. You never write a form.

What a client cannot do is create pages. Collections are the only place new content appears; pages are dev-authored. That is a deliberate boundary — see Limits and roadmap.