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
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' },
},
},
}
| Key | Meaning |
|---|---|
label | Shown in the editor's collection list. Defaults to a prettified name |
path | Directory holding the items, relative to the locale's content root |
slugFrom | Field whose value seeds a new item's slug |
orderBy | Default sort for <Copy.List> and getList |
fields | The schema — see field types |
Conventionally path is _collections/<name>, which keeps items out of the
route-mapped part of the tree.
One item
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
<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 orderingempty— a fallback rendered when nothing is visiblelocale— 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.
_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:
_orderon the item — set by drag-and-drop in the editor.orderBypassed to<Copy.List>/getList.orderByon 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.