Configuration
import { defineConfig } from 'copy-ink/config'
export default defineConfig({ /* … */ })
defineConfig is an identity function that preserves literal types, so
collection names, field keys and locales flow through to the generated types and
editor autocomplete. Every key is optional.
Top level
| Key | Type | Default | Description |
|---|---|---|---|
contentDir | string | 'content' | Content root, relative to the project root |
publicDir | string | 'public' | Next's static directory |
defaultLocale | string | 'en' | Locale used when a route has no locale prefix |
locales | string[] | [defaultLocale] | Every declared locale. Must contain defaultLocale |
autoMetadata | boolean | false | Marks the project metadata-driven — see Page metadata |
missingField | 'empty' | 'key' | 'error' | 'empty' | What a field with no value renders |
localeFallback | 'default' | 'empty' | 'default' | What a missing translation falls back to |
apiPath | string | '/api/copy-ink' | Mount point of the route handler |
adminPath | string | '/admin' | Sign-in page path |
images | ImagesConfig | — | See below |
auth | AuthConfig | — | See below |
backend | BackendConfig | — | See below |
collections | Record<string, CollectionSchema> | {} | See below |
contentDir and publicDir are normalised — a leading or trailing / is
stripped. apiPath and adminPath are normalised the other way: they always
start with / and never end with one.
missingField
| Value | Production | Editor |
|---|---|---|
'empty' | renders nothing | labelled placeholder |
'key' | renders the field path | labelled placeholder |
'error' | throws MISSING_FIELD | throws |
'key' is useful during a content migration, when seeing hero.subtitle on the
page is more informative than seeing a gap. 'error' also makes
<Copy.Item> throw UNKNOWN_ITEM for an item that does not exist.
images
images: {
allowedHosts: ['cdn.example.com', '*.imgix.net'],
maxUploadBytes: 8 * 1024 * 1024,
}
| Key | Type | Default | Description |
|---|---|---|---|
allowedHosts | string[] | [] | Hosts a client may paste a remote image URL from. A leading *. matches one level of subdomain |
maxUploadBytes | number | 8388608 (8 MB) | Uploads above this are rejected at upload time |
allowedHosts is written to .copy-ink/image-hosts.json by copy-ink types,
for next.config to consume. See Images.
auth
auth: {
provider: 'github',
allowlist: ['jane', 'client@example.com'],
}
| Key | Type | Default | Description |
|---|---|---|---|
provider | 'github' | 'github' | Identity provider |
allowlist | string[] | [] | GitHub logins or email addresses permitted to edit |
The allowlist is checked at callback time and again on every editing request, so removing someone takes effect on their next action, not their next sign-in.
An empty allowlist permits nobody. The local auth provider bypasses the check entirely, because there is no identity to check.
backend
backend: {
type: 'github',
repo: 'you/your-site',
branch: 'main',
}
| Key | Type | Default | Description |
|---|---|---|---|
type | 'github' | 'local' | 'github' | Where writes go |
repo | string | — | owner/repo. Required when type is 'github' |
branch | string | 'main' | Branch that receives publish commits |
strategy | 'direct' | 'direct' | v1 supports direct commits only; branch-and-PR lands in v2 |
type: 'local' writes to your working tree and, outside production, signs you
in automatically. See Auth and backends.
collections
collections: {
posts: {
label: 'Blog posts',
path: '_collections/posts',
slugFrom: 'title',
orderBy: { field: 'date', direction: 'desc' },
fields: { /* … */ },
// route: '/blog/[slug]', // only when the scan cannot work it out
},
}
| Key | Type | Default | Description |
|---|---|---|---|
label | string | prettified name | Shown in the editor's collection list |
path | string | — | Item directory, relative to the locale's content root |
slugFrom | string | — | Field whose value seeds a new item's slug |
route | string | derived | Route rendering one item, e.g. /blog/[slug] |
orderBy | { field: string; direction: 'asc' | 'desc' } | — | Default sort |
fields | Record<string, FieldSchema> | — | The schema |
slugFrom must name a field in fields; orderBy.field must too. Both are
validated at startup and reported by copy-ink check.
route is what lets the editor open one item from the Content panel.
Normally you never write it: copy-ink types finds the route by seeing which
page file names the collection. Set it when the scan cannot tell — a route
built from more than one dynamic segment, say — or to override what it found.
It must start with / and carry exactly one dynamic segment, since it
addresses a single item.
Field types
Every field shares four optional keys:
| Key | Type | Description |
|---|---|---|
label | string | Editor label. Defaults to a prettified key name |
required | boolean | Blocks item creation while empty |
editable | boolean | false locks the field in the editor while leaving it readable by code |
description | string | Helper text under the input |
Plus, per type:
type | Extra keys | Stored as |
|---|---|---|
'text' | multiline, maxLength | string |
'richtext' | — | Markdown string |
'image' | — | typed node with _type: image |
'date' | — | ISO date |
'number' | min, max, step | number |
'boolean' | — | boolean |
'select' | options: (string | { label, value })[] | string |
fields: {
title: { type: 'text', required: true, maxLength: 70 },
body: { type: 'richtext', description: 'The post itself.' },
status: { type: 'select', options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Live', value: 'live' },
] },
price: { type: 'text', editable: false },
}
Validation
The config is validated when setupCopyInk runs and by copy-ink check.
Errors throw CopyInkError('CONFIG_INVALID') with the offending key named.
Common ones:
defaultLocaleis not inlocales- a collection's
slugFromororderBy.fieldis not one of itsfields backend.type: 'github'with norepo- an
allowedHostsentry that is not a hostname - a root-level
metakey used as content