Type to search pages and sections.

Skip to content
Ginko

Bricks

Single and collection, all field types, routes, visibility, order, and tasks.

Single and collection

A brick describes one type of content. There are two kinds:

  • single is a single: exactly one entry. Examples: opening hours, imprint, contact details.
  • collection is a collection: many entries with the same fields. Examples: dishes, events, notices.

Both go under bricks in defineSite. The key is the name of the brick. It starts with a lowercase letter and contains only letters and digits, for example menu.

ts
import { collection, defineSite, f, single } from '@lupinum/ginko'

export default defineSite({
  site: 'restaurant',
  name: 'Gasthaus zur Linde',
  url: 'https://ginko-demo-restaurant.vercel.app',
  timeZone: 'Europe/Vienna',
  locales: ['de', 'en'],
  currency: 'EUR',
  pages: { home: { de: '/', en: '/en' }, menu: { de: '/speisekarte', en: '/en/menu' } },
  bricks: {
    contact: single({
      label: 'Address and contact',
      fields: {
        street: f.text('Street and number', { max: 80, translate: false }),
        phone: f.text('Phone', { format: 'tel' }),
      },
    }),
    menu: collection({
      label: 'Menu',
      fields: {
        name: f.text('Name', { max: 120 }),
        price: f.money('Price'),
      },
      max: 150,
      order: 'manual',
    }),
  },
})

Both functions take these settings:

SettingApplies toMeaning
labelbothName of the brick in the editor. Required.
describebothShort explanation in the editor, a text or a text per language like label. Bricks and components say what they are for with describe; a field has hint for the help text under it.
fieldsbothThe fields of the brick. Required.
sourceboth'files' reads the brick from Markdown files. Without it, the brick comes from the platform.
tasksbothTasks for the editor. See below.
layoutboth'tabs' shows field groups as tabs (see Groups and tabs).
maxcollectionLargest number of entries, 1 to 500. Required.
ordercollectionOrder of the entries. Required.
routecollectionAddress of the detail pages, per locale.
visiblecollectionWhen an entry is visible.
untranslatedcollectionWhat happens when a translation is missing: 'hide' (default with route) or 'fallback' (default without route). See Languages.
planscollectionEntries plan weeks of a single brick ahead.
statusbothOne status line on the Studio's Today page: { next: 'when' } (next entry by a date field), { count: true } (entries that end within 7 days; needs visible), { missing: 'image' } (entries without an optional field), { age: 'date' } (newest entry and its age), or on a single { hours: true } (today's opening hours). A line that needs doing is mailed with the weekly reminder.

Limits of one site: at most 30 bricks, and at most 60 fields per brick.

Field types

You build fields with f.<type>('Label', settings). The label is the name in the editor. The settings are an object.

These settings work for every type:

  • optional: true makes the field optional. Without it, a field is required.
  • hint is a help text in the editor. Like the label, it is a text or a text per language: { de: '…', en: '…' }.
  • showWhen shows the field in the editor only while another field next to it has a value: { field: 'closed', is: true } or { field: 'closed', not: true }. A missing value counts as "not". The stored value and the checks stay; only the editor hides the field. A required field with showWhen is required only while it shows: price: f.money('Amount', { showWhen: { field: 'pricing', is: 'price' } }) needs an amount for a fixed price and none otherwise. Its type in the data is therefore optional.
  • askAgainWhen on a choices field names the text field next to it that the choice answers for (a field of the same brick, or a column of the same list row). When that text changes in the main language and the choice does not, the Studio asks whether it is still right, and Save waits for the answer: allergens: f.choices('Allergens', { options, askAgainWhen: 'dish' }).
  • default is the start value, written like the field's value: a text, a number, { amount: 1250 } for money, an option value for a choice. For a translatable field, a plain text is the text of the main locale, and { de: 'Hallo', en: 'Hello' } sets one text per language; Ginko keeps only the site's locales. If no text in the main locale is left, the default is left out (for example, a preset written for other languages). f.markdown, f.image and f.file have no default.
  • translate: false turns translation off for a field. translate: 'manual' allows no automatic translation suggestions.

The column "Bundle value" shows what useGinko returns in the site.

FieldSettingsBundle valueTranslatable
f.textmax (up to 500, or 2000 with format: 'url', default 200), format: 'url', 'email', or 'tel'stringyes, no with format
f.longtextmax (up to 5000, default 2000)stringyes
f.markdownprofile: 'inline', 'basic', 'rich' or 'page', max (default 50000). No defaultMarkdown treeyes
f.numbermin, max, step (all three required), unitnumberno
f.moneymin, max (in minor units, e.g. cents; no negative amounts), currency{ amount, currency }no
f.booleannonetrue or falseno
f.datenone'2026-10-10'no
f.timeafter: another time field of the brick that this time must be later than'18:30'no
f.daterangenone{ from, to }, both as datesno
f.choiceoptions (list of options, up to 50)the value of the optionno
f.choicesoptions, maxlist of valuesno
f.listitemLabel, fields, max (1 to 50, required), min, uniquelist of rows, each with _idper column
f.imageaspect{ url, alt, width, height, focus?, aspect? }only alt
f.fileaccept: ['pdf'], maxBytes (up to 10 MB, 10 * 1024 * 1024){ url, name, size }no
f.slugfrom (required)stringyes
f.refto: a collection brick (required)the ID of one entryno
f.refsto (required), maxlist of entry IDsno

Notes on single types:

  • Markdown profiles set what a text may contain. inline: one paragraph with bold, italic, strikethrough, code and links. basic: also headings h2–h4, lists and task lists (- [ ] item), quotes, dividers and footnotes. rich: also tables, images, code blocks, math and mermaid diagrams. page: also h1, h5, h6 and components (::callout). Raw HTML always stays text. Attributes in curly braces take title, lang, dir and aria-* (**neu**{lang="de"}), plus what the element needs, such as target="_blank" on a link or width and height on an image. A component takes the props its registry declares. Anything else, such as class, data-*, style, id or onclick, is refused. Only Markdown and its plugins set classes (task lists, footnotes, code): with a class of its own, a text could take a CSS rule of the site and, for example, lay an invisible link over the whole page.
  • Money is stored in minor units, e.g. whole cents (a currency without them, such as JPY, uses whole units). The amount 12.50 EUR is { amount: 1250, currency: 'EUR' }. Amounts cannot be negative. Without currency, the field uses the currency of the site. Use formatMoney to show the value; formatMoney(price, language, { wholeWithoutMinor: true }) leaves out the cents of a whole amount (€390 instead of €390.00).
  • Date and time are strings. A date is YYYY-MM-DD, a time is HH:MM. A date counts in the time zone of the site. With f.time('Ends at', { after: 'from' }) the end must be later than the start from; the platform also refuses an equal time.
  • Country and public holidays: country in the site config is the site's country (ISO 3166-1, e.g. 'AT'). It is the country of JSON-LD addresses that have none, and it picks the public holidays of the opening hours: Ginko knows AT, DE (nationwide days only) and SG; another country counts no public holidays (ginko check warns). calendar overrides it: 'AT', 'DE', 'SG', 'none', or your own function (year) => holidays. The schema stores the result as holidays, so the Studio and the reminder mail count the same holidays as the site. Pages read country with useRuntimeConfig().public.ginko.country.
  • Choices: f.choice and f.choices take the options as options in the second argument, for example f.choice(label, { options: ['a', 'b'], default: 'a' }). An option is a string or { value, label }. The bundle holds only the value. The label is editor text: one text, or one per locale like every Label. It is not in the bundle, so show your own texts per locale in the site.
  • Lists: f.list(label, { itemLabel, fields: columns, ...settings }). itemLabel names one row, for example "Day". min is the smallest number of rows. unique names a required column whose value must differ in every row. A list cannot contain a list.
  • Images and files are uploaded in the editor on the platform. The bundle holds the URL. For images, Ginko also makes WebP copies 480, 960 and 1600 px wide (only those smaller than the image), at upload on the platform and at build for files. <GinkoImage> offers them to the browser with srcset, so phones load small files.
  • Image description (alt) is required in the main locale. A field can have its own text; without one, the description from the Files library applies. The bundle holds the result. When the owner set a focus point, the image value also has focus: { x, y } (0 to 1), and <GinkoImage> keeps that point in the crop.
  • Frame (aspect) says in which shape the website shows the photo: one ratio (f.image('Photo', { aspect: '3:4' })), or named frames when the photo appears in several places ({ aspect: { card: '3:2', header: '21:9' } }). The Studio shows the photo in every frame, and the owner sets the focus point right in the field. It warns when a frame cuts off much of the photo or the photo is too small for it. <GinkoImage> crops the same way (frame="card" picks a named frame). A frame changes no stored value: it may come, change or go in any release.
  • Slug is the address of an entry. See below.
  • Platform-managed properties. The stored types Field and Brick also have pos and retired. The platform sets them; f.*, single and collection do not take them. To retire a field, use retired in the site config (see Platform and CLI).

An example with a list and a choice:

ts
const weekdays = [
  { value: 'mo', label: 'Monday' },
  { value: 'tu', label: 'Tuesday' },
  { value: 'we', label: 'Wednesday' },
] as const

const lunch = single({
  label: 'Lunch menu',
  fields: {
    weekOf: f.date('Week of'),
    days: f.list('Days', {
      itemLabel: 'Day',
      fields: {
        day: f.choice('Day', { options: weekdays }),
        dish: f.text('Dish', { max: 200 }),
        price: f.money('Price', { optional: true }),
      },
      min: 3, max: 3, unique: 'day',
    }),
  },
})

In the site, the field day has the type 'mo' | 'tu' | 'we' | UnknownChoice. UnknownChoice stands for a value that a later release adds. Handle it with a fallback text.

Groups and tabs

A long form is easier to fill in parts. Give fields a group; the Studio shows each group under its heading. With layout: 'tabs' on the brick, the groups become tabs on wide screens. Phones always show headings. Fields without a group come first. Groups only change the form: the data stays the same.

ts
legal: single({
  label: 'Legal',
  layout: 'tabs',
  fields: {
    imprint: f.markdown('Imprint', { group: 'Imprint' }),
    privacy: f.markdown('Privacy', { group: 'Privacy' }),
  },
}),

After a refused save, a tab with an error shows a red dot and opens first.

Routes and slugs

A collection gets detail pages when it has a route. The route names one path per locale, with [slug]:

ts
events: collection({
  label: 'Events',
  fields: {
    title: f.text('Title', { max: 120 }),
    slug: f.slug('Address', { from: 'title' }),
    when: f.daterange('When'),
  },
  max: 30,
  order: { field: 'when', dir: 'asc' },
  route: { de: '/veranstaltungen/[slug]', en: '/en/events/[slug]' },
})

These rules apply to a route:

  • You must give a path for every locale in locales. Each path starts with / and contains [slug].
  • The path already includes the locale prefix, for example /en/events/[slug].
  • The brick needs an f.slug field. Its option from names the text field that the slug comes from.
  • Only collections have slugs. A single with a slug field is an error.

The slug is made once, when an entry is created. Ginko writes ä, ö, ü, and ß as ae, oe, ue, and ss, uses lowercase letters, and joins words with -. A duplicate slug gets -2, -3, and so on. After that, the slug stays fixed. Each locale has its own slug. For file bricks, the slug comes from the frontmatter or the file name (see "Content from files").

You write the page for the route yourself. The page "Reading content" shows how it reads the entry. The page "Languages" shows how the paths fit together per locale.

Visibility

Use visible to show and hide entries by date:

  • { mode: 'always' } shows everything. This is the default.
  • { mode: 'until', field: 'until' } shows an entry until the date in the field until has passed. The field is a date or a daterange; for a daterange the end date (to) counts. An empty field always shows the entry. With days: 6 it stays six days longer, for example a week from its first day.

A date counts for the whole day, in the time zone of the site. useGinko filters by this rule. The detail page of an entry that is not visible returns 404. This entry is also missing from the sitemap.

With deleteExpired: true in visible, the platform deletes entries that are not visible when a new entry needs room. A daily job also deletes entries that have been invisible for 30 days. Deleted entries can be restored for 30 days.

With plans: { brick: 'lunch', field: 'weekOf' }, the entries of a collection plan weeks of the single brick lunch ahead: weekOf (a date field of the collection) holds the first day of the week, and a planned week counts as entered for the weekly reminder.

Order

order sets the order of the entries in the bundle:

  • 'manual': owners arrange the entries by hand on the platform. For files, the number in the file name sets the order.
  • { field: 'when', dir: 'asc' } sorts by a field, ascending ('asc') or descending ('desc'). A daterange field sorts by from, then by to.

Tasks

Tasks (tasks) are shortcuts in the editor for a frequent chore. A task does not change what is in the data. It sets which fields a staff member sees and may change.

ts
tasks: [
  { id: 'closedToday', label: 'Closed today', kind: 'edit', fields: ['closedOn', 'closedNote'], prefill: { closedOn: '@today' } },
  { id: 'add', label: 'Add a dish', kind: 'create', fields: ['name', 'price'] },
  { id: 'remove', label: 'Remove a dish', kind: 'remove' },
]
  • kind: 'edit' changes the fields in fields. With pick: true, the person first picks an entry.
  • kind: 'create' adds a new entry. With fields the task asks for and writes only those fields (plus its prefill and fields with a default); every required field without a default must be in fields or prefill. A field with a default that the task does not list takes only its default value. The release check refuses the slug, a planned next/from field and a retired field in fields. Without fields it asks for every field except the slug and the plan of a planned change.
  • kind: 'remove' deletes an entry.
  • needsApproval: true makes a staff change through this task go live only after an owner or admin approves it.
  • prefill fills fields of the brick in the form in advance. The platform resolves the tokens '@today', '@workWeek', and '@nextWorkWeek' in the time zone of the site. A token needs a date field; any other value is written like the field's value and checked like it, so a markdown field takes Markdown text. '@workWeek' is the first day of the work week, '@nextWorkWeek' the first day of the week after it. The first day of the week comes from the main language of the site (languages, for example Monday for de-AT, Sunday for en-US) and you can override it with firstDayOfWeek (1 to 7, 1 = Monday) in the site config. At the weekend (the days after the last workday) the work week is already the coming week, so a weekly plan made on Saturday is for the week ahead.
  • A date field that holds a week start (the field of plans, else the field a task presets to '@workWeek' or '@nextWorkWeek') takes only the first day of a week. The edit task that presets a field to '@workWeek' (the weekly task) always covers the current week: the platform refuses another week there, also when a proposal from an earlier week is approved.

Staff members (role staff) may only do what matches a task that the owner has granted to them. You can change tasks at any time. They are not part of the data contract.

Tasks only make sense for platform bricks. The platform never sees bricks with source: 'files', because nobody edits them in the editor.

Planned change

With planned, a brick plans a change of one field ahead, for example a price that goes up on 1 November:

ts
planned: { field: 'price', next: 'nextPrice', from: 'priceFrom' }

From the date in priceFrom on, the value in nextPrice applies instead of price. next has the type of field, from is a date field, and both are optional: without a plan they stay empty. ginko check reports a task that changes field but does not show next and from. So nobody changes a price without seeing the planned one.

The platform stores a plan only with its value and its date together. From the date on, a task with these fields opens on the new value, and saving it removes the spent plan. A task that creates a new entry does not ask for a plan. Whoever saves only the plan reads the day it applies from in the message, not “in about a minute”. Your site reads which value applies on a day with valueOn(brick, data, 'price', today) (in the gastro preset with priceOn); a fact (see below) applies the plan itself.

Facts in texts

A price, a phone number or opening times often appear in running text too. So they never go stale there, facts names the fields of single bricks that a text may quote:

ts
facts: ['praxis.price', 'praxis.times', 'site.phone']

Ginko adds the inline component fact for them. The text holds :fact{name="praxis.price"}, the Studio shows a chip with the field's name ("Price per session"). The page, the search, the Markdown twin and llms-full.txt show the value in effect today: a planned change from its date on, money as "€ 98", a date in long form, multiple choices and one-column lists as a list ("11:00, 12:15 and 13:30"), an email address or phone number (format: 'email' or 'tel') as a link.

Allowed are text, number (with unit), money, date, time, choice, choices and lists with exactly one column. ginko check reports a path that does not name such a field of a single brick. In your own code, useGinkoFact('praxis.price') reads the same text; a site that shows fact differently gives <GinkoMarkdown> its own component under components.