# Alinea CMS Docs

### Introduction (/docs)
Alinea is an open source headless CMS written in Typescript. It stores content in flat files in your repository so they can be checked into Git. This means you can roll back to a previous version, compare changes, and track who made changes. Content is bundled with deploys so it can be retrieved without network roundtrips.

Image: dashboard (/dashboard)

### Configuration (/docs/configuration)
All configuration can be managed from the `cms.ts` file. This file is created in the project root (or your `src` folder if it exists) during `alinea init`.

File: cms.ts
```
import {createCMS} from 'alinea/next'

export const cms = createCMS({
  baseUrl: {
    development: 'http://localhost:3000',
    production: 'https://alineacms.com'
  },
  enableDrafts: true,
  preview: true,
  schema: {...},
  workspaces: {...}
})
```

### `baseUrl`

The URL of the frontend where Alinea is used.

### `enableDrafts`

Allows content editors to save and preview unpublished changes before publishing.

### `preview`

Display an iframe with live previews on the side of the editor.

### `schema`

Describe the structure of your content using a [collection of Types](/docs/configuration/schema).

### `workspaces`

Content can be bundled in separate [Workspaces](/docs/configuration/workspaces). Defining at least one is required.

### `syncInterval`

Optionally set the interval in seconds at which the frontend will poll for updates.

## Good to know

### Dealing with errors

Your config file is read and executed during the `alinea dev` and `alinea build` [CLI](/docs/reference/cli) commands. If anything goes wrong, you might see an error such as:

```shellscript
Error: Fail
    at file:///home/alineacms/alinea/node_modules/@alinea/generated/config.js?1706175675574:419:7
    at ModuleJob.run (node:internal/modules/esm/module_job:194:25)
```

To debug these situations Alinea compiles your config file with an included source map. To enable node to read the source map and report correct positions you can enable the Node.js `--enable-source-maps` flag. You can add it to the scripts in package.json:

```tsx
{
  "scripts": {
    "dev": "NODE_OPTIONS=--enable-source-maps alinea dev -- next dev",
    "build": "NODE_OPTIONS=--enable-source-maps alinea build -- next build"
  }
}
```

Note (info): If you're developing on Windows you can use [cross-env](https://www.npmjs.com/package/cross-env) to achieve the same.

The error will now point to the right file:

```shellscript
Error: Fail
    at <anonymous> (/home/alinea/apps/dev/cms.ts:278:7)
    at ModuleJob.run (node:internal/modules/esm/module_job:194:25)
```

### Fields (/docs/configuration/fields)
Fields make data editable. Alinea ships with a lot of field types but can easily be expanded with [custom fields](/docs/configuration/fields/custom-fields).

```
import {Config, Field} from 'alinea'
import {FieldOptions, ScalarField, WithoutLabel} from 'alinea/core'
import {InputLabel, useField} from 'alinea/dashboard'

interface RangeFieldOptions extends FieldOptions<number> {
  min?: number
  max?: number
}

class RangeField extends ScalarField<number, RangeFieldOptions> {
}

// The constructor function is used to create fields in our schema
// later on. It is usually passed a label and options.
export function range(label: string, options: WithoutLabel<RangeFieldOptions> = {}): RangeField {
  return new RangeField({
    options: {label, ...options},
    view: RangeInput
  })
}

interface RangeInputProps {
  field: RangeField
}

// To view our field we can create a React component. 
// This component can call the useInput hook to receive the
// current value and a method to update it.
function RangeInput({field}: RangeInputProps) {
  const {value, mutator, options} = useField(field)
  const {min = 0, max = 10} = options
  return (
    <InputLabel {...options}>
      <input 
        type="range" 
        min={min} max={max} 
        value={value} 
        onChange={e => mutator(Number(e.target.value))} 
      />
    </InputLabel>
  )
}

export default Config.type('Kitchen sink', {
  fields: {
    ...Field.tabs(
      Field.tab('Basic fields', {
        fields: {
          title: Field.text('Text field'),
          path: Field.path('Path field', {
            help: 'Creates a slug of the value of another field'
          }),
          richText: Field.richText('Rich text field'),
          select: Field.select('Select field', {
            options: {
              a: 'Option a',
              b: 'Option b'
            }
          }),
          number: Field.number('Number field', {
            minValue: 0,
            maxValue: 10
          }),
          check: Field.check('Check field', {label: 'Check me please'}),
          date: Field.date('Date field'),
          code: Field.code('Code field')
        }
      }),
      Field.tab('Link fields', {
        fields: {
          externalLink: Field.url('External link'),
          entry: Field.entry('Internal link'),
          linkMultiple: Field.link.multiple('Mixed links, multiple'),
          image: Field.entry('Image link'),
          file: Field.entry('File link')
        }
      }),

      Field.tab('List fields', {
        fields: {
          list: Field.list('My list field', {
            schema: {
              Text: Config.type('Text', {
                fields: {
                  title: Field.text('Item title'),
                  text: Field.richText('Item body text')
                }
              }),
              Image: Config.type('Image', {
                fields: {
                  image: Field.image('Image')
                }
              })
            }
          })  
        }
      }),
      Field.tab('Inline fields', {
        fields: {
          street: Field.text('Street', {width: 0.6, inline: true, multiline: true}),
          streetNr: Field.text('Number', {width: 0.2, inline: true}),
          box: Field.text('Box', {width: 0.2, inline: true}),
          zip: Field.text('Zipcode', {width: 0.2, inline: true}),
          city: Field.text('City', {width: 0.4, inline: true}),
          country: Field.text('Country', {
            width: 0.4,
            inline: true
          })
        }
      }),
      Field.tab('Custom fields', {
        fields: {
          range: range('Range field')  
        }
      })
    )
  }
})
```

## Configuration

While every field will have unique properties, there are a few properties that are generally available.

### `initialValue`

Prefill the fields value.

### `hidden`

Hide this field in the dashboard but keep its value intact.

### `readOnly`

Mark field data as read-only.

### `help`

Display a help text next to the fields label.

### `inline`

Show a minimal version of the field. In most cases this will mean the input label will be hidden, and the label will show up as a placeholder instead.

### `width`

Setting a width value will scale the fields width down, use a number between 0 and 1. This allows you to compose the dashboard UI better based on the content of the fields.

### `shared`

Fields can be persisted over all languages if your content is [localised](/docs/reference/internationalization) by setting the `shared` option to `true`. When the entry is published the field data is copied to other locales. This is currently only supported on the root level, not on nested fields.

```
import {Config, Field} from 'alinea'

const Type = Config.type('Persist', {
  fields: {
    // Persist field data over all locales
    sharedField: Field.text('Shared text', {shared: true})
  }
})
```

### `required`

The `required` option will make sure the field value is not empty when saving when set to `true`.

### `validate`

The `validate` option can be used to validate the field value using a custom function. The function should return `true` if the value is valid, `false` if it is not valid and a string if it is not valid and a message should be shown to the user.

```
import {Config, Field} from 'alinea'

Field.text('Hello field', {
  help: 'This field only accepts "hello" as a value',
  validate(value) {
    if (value !== 'hello') return 'Only "hello" is allowed!'
  }
})
```

## Conditional configuration

All field configuration can be adjusted based on the value of other fields. After defining fields in a [Type](/docs/configuration/schema/type) a tracker function can be set up. The tracker function takes a reference to a field and a subscription function. In the subscription function field values can be retrieved and new options returned.

### Example

```
import {Config, Field} from 'alinea'

const Example = Config.type('Conditional example', {
  fields: {
    textField: Field.text('Text field'),
    readOnly: Field.check('Make read-only'),
    hidden: Field.check('Hide field')
  }
})

Config.track.options(Example.textField, get => {
  const textField = get(Example.textField)
  const readOnly = get(Example.readOnly)
  const hidden = get(Example.hidden)
  return {
    readOnly, 
    hidden,
    help: `Text has ${textField.length} characters`
  }
})
```

```
import {Config, Field} from 'alinea'

const Example = Config.type('Conditional example', {
  fields: {
    textField: Field.text('Text field'),
    readOnly: Field.check('Make read-only'),
    hidden: Field.check('Hide field')
  }
})

Config.track.options(Example.textField, get => {
  const textField = get(Example.textField)
  const readOnly = get(Example.readOnly)
  const hidden = get(Example.hidden)
  return {
    readOnly, 
    hidden,
    help: `Text has ${textField.length} characters`
  }
})

export default Example
```

### Check (/docs/configuration/fields/check)
A check field is used to input boolean data.

```
import {Field} from 'alinea'

Field.check('Checkbox without label')
Field.check('Label ipsum dolor sit amet', {
  description: 'Checkbox with label & description'
})
```

```
import {Config, Field} from 'alinea'

export default Config.type('Check field', {
  fields: {
    check: Field.check('Checkbox without label'),
    checkDescription: Field.check('Label ipsum dolor sit amet', {
      description: 'Checkbox with label & description'
    })
  }
})
```

### Code (/docs/configuration/fields/code)
A code field is used to input code.

```
import {Field} from 'alinea'

Field.code('My code field', {
  help: 'Paste your code here'
})
```

```
import {Config, Field} from 'alinea'

export default Config.type('Code field', {
  fields: {
    code: Field.code('My code field', {
      help: 'Paste your code here'
    })
  }
})
```

### Custom fields (/docs/configuration/fields/custom-fields)
It's possible to create custom fields. A field needs a constructor function that users call to create instances of it in their configuration.

## Range field example

Let's create a custom field to demonstrate.

File: fields/Range.ts
```
import {Field} from 'alinea'

export type RangeField = Field.Create<number, {
  min?: number
  max?: number
}>

// The constructor function is used to create fields in our schema
// later on. It is usually passed a label and options.
export function range(label: string, options: Field.Options<RangeField> = {}): RangeField {
  return Field.create({
    label,
    options,
    // Point this
    view: '@/fields/RangeField.view'
  })
}
```

File: fields/Range.view.tsx
```
import {InputLabel, useField} from 'alinea/dashboard'
import {RangeField} from './Range'

interface RangeViewProps {
  field: RangeField
}

// To view our field we can create a React component. 
// This component can call the useField hook to receive the
// current value and a method to update it.
export default function RangeView({field}: RangeViewProps) {
  const {value, mutator, options} = useField(field)
  const {min = 0, max = 10} = options
  return (
    <InputLabel {...options}>
      <input 
        type="range" 
        min={min} max={max} 
        value={value} 
        onChange={e => mutator(Number(e.target.value))} 
      />
    </InputLabel>
  )
}
```

To use the field in your types later call the constructor function:

```
import {Config} from 'alinea'
import {range} from './RangeField'

Config.type('My type', {
  fields: {
    // ...
    myRangeField: range('A range field', {
      min: 0, 
      max: 20
    })
  }
})
```

```
import {Field, Config} from 'alinea'
import {InputLabel, useField} from 'alinea/dashboard'

export type RangeField = Field.Create<number, {
  min?: number
  max?: number
}>

// The constructor function is used to create fields in our schema
// later on. It is usually passed a label and options.
export function range(label: string, options: Field.Options<RangeField> = {}): RangeField {
  return Field.create({
    label,
    options,
    // Point this
    view({field}) {
      const {value, mutator, options} = useField(field)
      const {min = 0, max = 10} = options
      return (
        <InputLabel {...options}>
          <input 
            type="range" 
            min={min} max={max} 
            value={value} 
            onChange={e => mutator(Number(e.target.value))} 
          />
        </InputLabel>
      )
    }
  })
}

export default Config.type('Custom fields', {
  fields: {
    range: range('A range field', {min: 0, max: 20})
  }
})
```

### Date & time (/docs/configuration/fields/date)
A date field is used to input a date.
A time field is used to input a time.

```
import {Field} from 'alinea'

Field.date('Date field')
Field.time('Time field')
```

```
import {Config, Field} from 'alinea'

export default Config.type('Date/time field', {
  fields: {
    date: Field.date('Date field'),
    time: Field.time('Time field')
  }
})
```

## Configuration

### `initialValue`

Prefills the field’s value. For example, you can prefill it with today’s date.

```
const today = new Date().toISOString().split('T')[0];
Field.date('Date (initialValue: today)')
```

```
import {Config, Field} from 'alinea'

const today = new Date().toISOString().split('T')[0];

export default Config.type('Date (initialValue)', {
  fields: {
    date: Field.date('Date (initialValue: today)', {initialValue: today}),
  }
})
```

### Entry (/docs/configuration/fields/entry)
The entry field can be used to link to an internal page.

```
import {Field} from 'alinea'

Field.entry('Single entry link')

Field.entry.multiple('Multiple entry links')
```

## Configuration

### `condition`

Limit the pages shown in the explorer to this condition. Conditions can be built using fields in the same way as described in the [querying content](/docs/content/query#querying-specific-pages) chapter.

```
import {Field} from 'alinea'

Field.entry('Link to author', {
  condition: {
    _type: 'Author'
  }
})

Field.entry('Link to author or writer', {
  condition: {
    _type: {
      in: ['Author', 'Writer']
    }
  }
})
```

### `defaultView`

Preset the UI to show rows or thumbnails (possible values: "row", "thumb")

### `inline`

Show a minimal version of the field. The field label is hidden.

### `location`

Defines the location (workspace and root) where the explorer will be located.

```
import {Field} from 'alinea'

Field.entry('Link to author', {
  location: {
    root: 'pages',
    workspace: 'main'
  }
})
```

### `pickChildren`

Choose from a flat list of direct children of the currently edited entry.

```
import {Field} from 'alinea'

Field.entry('Author of this book', {
  condition: {
    _type: 'Author'
  },
  pickChildren: true
})
```

### `max`

Limit the amount of rows in case of multiple links.

```
import {Field} from 'alinea'

Field.entry.multiple('Select up to 3 authors', {
  max: 3
})
```

### File (/docs/configuration/fields/file)
The file field can be used to link to a file.

```
import {Field} from 'alinea'

Field.file('Single file link')

Field.file.multiple('Multiple file links')
```

```
import {Config, Field} from 'alinea'

export default Config.type('File field', {
  fields: {
    file: Field.file('Single file link'),
    fileMultiple: Field.file.multiple('Multiple file links')
  }
})
```

## Configuration

### `max`

Limit the amount of rows in case of multiple links.

### Image (/docs/configuration/fields/image)
The image field can be used to select an image.

```
import {Field} from 'alinea'

Field.image('Single image link')

Field.image.multiple('Multiple image links')
```

```
import {Config, Field} from 'alinea'

export default Config.type('Image field', {
  fields: {
    image: Field.image('Single image link'),
    imageMultiple: Field.image.multiple('Multiple image links')
  }
})
```

## Configuration

### `max`

Limit the amount of rows in case of multiple links.

### `fields`

Defines nested sub-fields for the original object, allowing you to attach additional structured data.

```
import {Config, Field} from 'alinea'

Field.image('Image', {
  fields: {alt: Field.text('Alt text')}
})
```

### Link (/docs/configuration/fields/link)
The link field can be used to create one or multiple references to other entries or external resources (like a webpage or an email address). By default, the user can choose between internal pages, external urls or uploaded files. If you want to limit to selection to just one of those options it's possible to declare the field as either an [Entry field](/docs/configuration/fields/entry), [Url field](/docs/configuration/fields/url), [File field](/docs/configuration/fields/file), or [Image field](/docs/configuration/fields/image).

```
import {Field} from 'alinea'

Field.link('Single link')

Field.link.multiple('Multiple links')
```

```
import {Config, Field} from 'alinea'

export default Config.type('Link field', {
  fields: {
    link: Field.link('Single link'),
    linkMultiple: Field.link.multiple('Multiple links')
  }
})
```

## Configuration

### `max`

Limit the amount of rows in case of multiple links.

### `fields`

Defines nested sub-fields for the original object, allowing you to attach additional structured data.

```
import {Config, Field} from 'alinea'

Field.link('Link', {
  fields: {label: Field.text('Link label')}
})
```

### List (/docs/configuration/fields/list)
A list field contains blocks of fields. Every block is configured using a specific type. These can be created using the schema and type functions as seen before.

```
import {Config, Field} from 'alinea'

Field.list('List', {
  schema: {
    Item: Config.type('Item', {
      fields: {
        title: Field.text('Title'),
        text: Field.richText('Text')
      }
    })
  }
})

Field.list('List mixed', {
  schema: {
    Text: Config.type('Text', {
      fields: {
        title: Field.text('Item title'),
        text: Field.richText('Item body text')
      }
    }),
    Image: Config.type('Image', {
      fields: {
        image: Field.image('Image')
      }
    })
  }
})
```

```
import {Config, Field} from 'alinea'

export default Config.type("List field", {
  fields: {
    list: Field.list("List", {
      schema: {
        Item: Config.type("Item", {
          fields: {
            title: Field.text("Title"),
            text: Field.richText("Text"),
          }
        })
      }
    })
    listMixed: Field.list("List mixed", {
      schema: {
        Text: Config.type("Text", {
          fields: {
            title: Field.text("Title"),
            text: Field.richText("Text"),
          }
        })
        Image: Config.type("Image", {
          fields: {
            image: Field.image("Image")
          }
        })
      }
    })
  }
})
```

### Number (/docs/configuration/fields/number)
A number field is used to input numeric data.

```
import {Field} from 'alinea'

Field.number('My number field', {
  minValue: 0,
  maxValue: 10,
  step: 1
})
```

```
import {Field} from 'alinea'

export default Field.number('My number field', {
  minValue: 0,
  maxValue: 10,
  step: 1
})
```

## Configuration

### `step`

Specifies the interval between legal numbers in the input field.
Default is 1.

```
import {Field} from 'alinea'

Field.number('My decimal number field', {
  step: 0.01
})
```

```
import {Field} from 'alinea'

export default Field.number('My decimal number field', {
  help: 'You can increase or decrease the value by 0.01',
  initialValue: 0.01,
  step: 0.01
})
```

### Object (/docs/configuration/fields/object)
An object field groups multiple fields together. The fields are defined using the type function.

```
import {Config, Field} from 'alinea'

Field.object('Address', {
  fields: {
    street: Field.text('Street'),
    zip: Field.text('Zip code', {width: 0.5}),
    city: Field.text('City', {width: 0.5})
  })
})
```

```
import {Config, Field} from 'alinea'

export default Config.type('Object field', {
  fields: {
    object: Field.object('Address', {
      fields: {
        street: Field.text('Street'),
        zip: Field.text('Zip code', {width: 0.5}),
        city: Field.text('City', {width: 0.5})
      }
    })
  }
})
```

### Path (/docs/configuration/fields/path)
A path field is used to generate a slug based on another field - by default, the title field.

```
import {Field} from 'alinea'

Field.text('Title', {required: true, width: 0.5}),
Field.path('Path', {required: true, width: 0.5})
```

```
import {Config, Field} from 'alinea'

export default Config.type('Path field', {
  fields: {
    title: Field.text('Title', {
      initialValue: 'Contentpage'
      required: true, width: 0.5
    }),
    path: Field.path('Path', {
      required: true, width: 0.5
    })
  }
})
```

## Configuration

### `from`

Automatically generate (slugify) the path from another field.

```
import {Config, Field} from 'alinea'

export default Config.type('Path field (from)', {
  fields: {
    anchorId: Field.text('anchorId', {
      initialValue: 'Anchord ID',
      required: true, width: 0.5
    }),
    pathFrom: Field.path('Path from anchorId)', {
      from: 'anchorId',
      required: true, width: 0.5
    })
  }
})
```

```
import {Config, Field} from 'alinea'

export default Config.type('Path field (from)', {
  fields: {
    anchorId: Field.text('anchorId', {
      initialValue: 'Anchord ID'
      required: true, width: 0.5,
    }),
    pathFrom: Field.path('Path from anchorId)', {
      from: 'anchorId',
      required: true, width: 0.5
    })
  }
})
```

### `initialValue`

Prefills the path value. This is useful for cases like setting the homepage path.

```
import {Config, Field} from 'alinea'

export default Config.type('Path field (homepage)', {
  fields: {
    title: Field.text('Title', {
      initialValue: 'Homepage',
      required: true, width: 0.5
    }),
    path: Field.path('Path', {
      initialValue: 'index',
      hidden: false,
      readOnly: true,
      required: true, width: 0.5
    })
  }
})
```

```
import {Config, Field} from 'alinea'

export default Config.type('Path field (homepage)', {
  fields: {
    title: Field.text('Title', {
      initialValue: 'Homepage',
      required: true, width: 0.5
    }),
    path: Field.path('Path', {
      initialValue: 'index',
      hidden: false,
      readOnly: true,
      required: true, width: 0.5
    })
  }
})
```

### Rich Text (/docs/configuration/fields/rich-text)
Rich text can contain text marks like bold, italics or underline. Content can be structured using headings. It can even contain other types as blocks that can be moved around freely.

```
import {Field} from 'alinea'

Field.richText('Rich Text')
Field.richText('Extended with inline schema(s)', {
  schema: {
    ImageBlock
  }
})
const ImageBlock = Config.type('Image', {
  fields: {image: Field.image('Image', {inline: true})}
})
```

```
import {Config, Field} from 'alinea'

const ImageBlock = Config.type('Image', {
  fields: {image: Field.image('Image', {inline: true})}
})

export default Config.type('Rich Text field', {
  fields: {
    basic: Field.richText('Rich Text', {
      initialValue: [
        {_type: 'heading', level: 1, content: [
          {_type: 'text', text: "Hello world"}
        ]},
        {_type: 'paragraph', content: [
          {_type: 'text', text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit."}
        ]}   
      ]
    }),
    richText: Field.richText('Extended with inline schema(s)', {
      schema: {
        ImageBlock
      },
      initialValue: [
        {_type: 'paragraph', content: [
          {_type: 'text', text: "The “Insert block” option appears when you press Enter to create a new line."}
        ]}   
      ]
    })
  }
})
```

## Configuration

### `schema`

Allow Types of this Schema to be created between text fragments.

### `searchable`

Index the content of this field so it can be found in a search query.

### `enableTables`

Allow tables to be inserted in this field.

## Rendering rich text

Rich text values are encoded in an array.

Variant: JSON
```tsx
[
  {
    "_type": "heading",
    "level": 1,
    "content": [
      {
        "type": "text",
        "text": "Hello world"
      }
    ]
  },
  {
    "_type": "paragraph",
    "content": [
      {
        "type": "text",
        "text": "A paragraph follows"
      }
    ]
  }
]
```

Variant: Types
```tsx
type TextDoc = Array<TextNode>

type TextNode = 
  | {
    _type: 'text'
    text?: string
    marks?: Array<{
      type: string
      attrs?: Record<string, string>
    }>
  }
  | {
    _type: string
    content?: TextDoc
    [key: string]: any
  }
```

Alinea provides a React component to render this array in your app. By default it will use plain tags such as h1, h2, p, ul, li, etc. to represent the text. Any of these can be customized by either passing a React component or a vnode, of which we'll copy the type and props.

```
import {RichText} from 'alinea/ui'

<RichText
  doc={richTextValue}

  // Everything below is optional

  // Render plain text with a custom component
  text={TextComponent}

  // Attach a custom classname to paragraphs
  p={<p className="my-p" />}

  // Use a custom component for h1 headings
  h1={MyH1Heading}
  
  // Use a custom component for links
  a={LinkComponent}

  // Attach classes to list items
  ul={<ul className="my-list" />}
  ol={<ol className="my-ordered-list" />}
  li={<li className="my-list-item" />}

  // More options
  // b={<b />}
  // i={<i />}
  // u={<u />}
  // hr={<hr />}
  // br={<br />}
  // small={<small />}
  // blockquote={<blockquote />}
/>
```

The same principle is applied for custom blocks.

```
import {RichText} from 'alinea/ui'
import {MyBlock} from './MyBlock'
import {MyBlockSchema} from './MyBlock.schema'

const MyBlockSchema = alinea.type('Custom block', {
  property: alinea.text('Property')
})

function MyBlock({property}) {
  return <span>{property}</span>
}

// Add to config
alinea.richText('My rich text field', {
  schema: alinea.schema({
    MyBlock: MyBlockSchema
  })
})

// Render in page views
<RichText
  doc={richTextValue}

  // Render instances of my custom block with the MyBlock view
  MyBlock={MyBlock}
/>
```

If you need the HTML as string output and not as a component, you can convert the component as follows:

```typescript
import {RichText} from 'alinea/ui'
const {renderToString} = await import('react-dom/server')

const html = renderToString(<RichText doc={richTextValue} />)
```

Note the dynamic import of react-dom/server. This is necessary to avoid warnings from Next.js for using server imports.

### Select (/docs/configuration/fields/select)
A select field limits data to a specific set of options.

```
import {Field} from 'alinea'

Field.select('My select field', {
  options: {
    a: 'Option a',
    b: 'Option b'
  }
})
```

```
import {Config, Field} from 'alinea'

export default Config.type('Select field', {
  fields: {
    select: Field.select('My select field', {
      options: {
        a: 'Option a',
        b: 'Option b'
      }
    })
  }
})
```

### Tabs (/docs/configuration/fields/tabs)
Tabs help you structure the input fields in the admin panel. Tabs do not hold data, but can declare underlying fields which do (or hold more tabs).

```
import {Field} from 'alinea'

Field.tabs(
  Field.tab('Tab A', {
    fields: {
      // ... fields
    }
  }),
  Field.tab('Tab B', {
    fields: {
      // ... fields
    }
  })
)
```

```
import {Config, Field} from "alinea"

export default Config.type("Tabs field", {
  fields: {
    ...Field.tabs(
      Field.tab("Tab A", {
        fields: {
          fieldA: Field.text("Field in Tab A")
        }
      }),
      Field.tab("Tab B", {
        fields: {
          fieldB: Field.text("Field in Tab B")
        }
      })
    )
  }
})
```

### Text (/docs/configuration/fields/text)
A text field is used to input textual data.

```
import {Field} from 'alinea'

Field.text('My text field', {
  help: 'This is an example field',
  multiline: true
})
```

```
import {Config, Field} from 'alinea'

export default Config.type('Text field', {
  fields: {
    text: Field.text('My text field', {
      help: 'This is an example field',
      multiline: true,
      initialValue: 'A text value'
    })
  }
})
```

## Configuration

### `searchable`

Index the content of this field so it can be found in a search query.

### `autoFocus`

Focus this input automatically

### Url (/docs/configuration/fields/url)
The Url field can be used to link to an external resource.

```
import {Field} from 'alinea'

Field.url('Single URL link')

Field.url.multiple('Multiple URL links')
```

```
import {Config, Field} from 'alinea'

export default Config.type('Url field', {
  fields: {
    url: Field.url('Single URL link'),
    urlMultiple: Field.url.multiple('Multiple URL links')
  }
})
```

## Configuration

### `max`

Limit the amount of rows in case of multiple links.

### Roles & Permissions (/docs/configuration/roles-permissions)
Roles define what users can see and do within the CMS dashboard. Alinea automatically creates a default Admin role for every project with full access to every workspace, root, and entry. You do not need to define this manually, ensuring there is always an unrestricted management account available.

## Setting up roles

To create a custom role, use the Config.role helper and register it in your CMS configuration.

```
const myRole = Config.role('My role', {
  async permissions(policy, graph) {
    // Define permissions here
  }
})

export const cms = createCMS({
  roles: { myRole },
  // ...other config
})
```

## Defining Policies

The permissions function is where you define your access policies. You use allow to grant access to specific actions and deny to strictly forbid them.

Note (info): Note: A deny rule is absolute and propagates downstream without exception. Once an action is denied at a higher level, it becomes "locked" for all child entries and cannot be overridden by an allow rule or contained by grant: 'explicit' further down the stream. Always apply deny restrictions at the most specific level possible to avoid unintentionally locking out sub-sections.

### Available Actions

When configuring a policy, you can target these specific actions to create granular permissions:

read: View the entry in the dashboard.

create: Create new entries of a specific type.

update: Edit existing content.

delete: Remove entries from the CMS.

publish: Push changes to the live environment.

archive: Move entries to an archived state.

reorder: Change the sorting order within a parent.

move: Relocate entries to a different parent or root.

upload: Add new media files to the library.

explore: Browse through the workspace structure.

all: A shorthand to grant every possible permission at once.

## Understanding Grants

The grant property specifically modifies how allow rules are applied across the content tree. By default, permissions use grant: 'implicit', meaning access trickles down to all children.

```
// (Default) Permissions automatically trickle down to all child entries and sub-pages.
grant: 'implicit' 
// Permissions only apply to the specific entry or level defined. 
// Access to children must be granted separately.
grant: 'explicit'
```

## Example use cases

### Static Access Control

In these examples, we define roles based on fixed paths or roots within your CMS. This is the most direct way to manage access for different user groups.

Viewer: This role is granted read-only access to the entire workspace by default but is specifically excluded from sensitive administrative roots such as configurations and databases.

```
// Block specified pages, only give read-rights
const viewer = Config.role('Viewer', {
  async permissions(policy) {
    policy.set(
      {
        allow: {read: true}
      },
      {
        root: cms.workspaces.main.general,
        deny: {read: true}
      },
      {
        root: cms.workspaces.main.redirects,
        deny: {read: true}
      },
      {
        root: cms.workspaces.main.configurator,
        deny: {read: true}
      },
      {
        root: cms.workspaces.main.database,
        deny: {read: true}
      }
    )
  }
})
```

Editor: Similar to the Viewer, but with the addition of editing rights (update) for all accessible sections.

```
// Block acces to certain roots, give read and update rights to everything else
const editor = Config.role('Editor', {
  async permissions(policy) {
    policy.set(
      {
        allow: {read: true, update: true}
      },
      {
        root: cms.workspaces.main.general,
        deny: {read: true}
      },
      {
        root: cms.workspaces.main.redirects,
        deny: {read: true}
      },
      {
        root: cms.workspaces.main.configurator,
        deny: {read: true}
      },
      {
        root: cms.workspaces.main.database,
        deny: {read: true}
      }
    )
  }
})
```

### Explicit Granular Access

Sometimes you want to grant a user access to a specific item without immediately making the entire underlying tree structure visible. By using grant: 'explicit', you ensure that rights must be manually assigned at each level. This is ideal for moderators who only need to manage one specific page (like a particular bike) while keeping the rest of the navigation hidden.

```
const bikeId = '4d454f57636d735f323032365f7a'

// Grant explicit to make sure not all children are visible
const bikeModerator = Config.role('Bike Moderator', {
  async permissions(policy) {
    policy.set(
      {
        workspace: cms.workspaces.main,
        allow: {read: true},
        grant: 'explicit'
      },
      {
        root: cms.workspaces.main.pages,
        allow: {read: true},
        grant: 'explicit'
      },
      {
        id: bikeId,
        allow: {all: true}
      }
    )
  }
})
```

### Dynamic Query-based Permissions

Alinea offers the unique capability to base permissions on your data structure via the graph. Instead of hardcoding IDs, you can perform a query to retrieve, for example, all products belonging to a specific brand. The results of this query are then used to dynamically assign permissions. This means you don't have to update the role when new products are added to the brand; the permissions automatically grow along with your content.

```
bikeBrandId = '2o7I2ig8ry0ci1e0hZ5k1kKK3Qx'

// Use querying to show only specific products
const bikeBrandModerator = Config.role('Bike Brand Moderator', {
  async permissions(policy, graph) {
    const bikes = await graph.find({
      type: ProductSchema,
      filter: {
        brand: {
          has: {_entry: bikeBrandId}
        }
      }
    })
    policy.set({
      workspace: cms.workspaces.main,
      allow: {read: true},
      grant: 'explicit'
    },
    {
    root: cms.workspaces.main.products,
      allow: {read: true},
      grant: 'explicit'
    })
    for (const bike of bikes) {
      policy.set({
        id: bike._id,
        allow: {read: true, update: true}
      })
    }
  }
})
```

### Schema (/docs/configuration/schema)
A schema is a collection of [Types](/docs/configuration/schema/type).

```
import {Config} from 'alinea'

Config.schema({
  types: {
    TypeA, TypeB, TypeC
  }
})
```

## Configuration

Your Schema should be defined in the [CMS config](/docs/configuration). There are currently no configuration options for it.

## Example schema

The Schema below is a minimal example of a blog setup. It holds two types: `BlogOverview` and `BlogPost`. The overview type corresponds to a page that lists the posts. To achieve that it is configured as a container which can hold blog posts as children.

```
import {Config, Field} from 'alinea'

Config.schema({
  types: {
    BlogOverview: Config.document('Blog overview', {
      contains: ['BlogPost']
    }),
    BlogPost: Config.document('Blog post', {
      fields: {
        publishDate: Field.date('Publish date'),
        body: Field.richText('Body')
      }
    })
  }
})
```

### Document (/docs/configuration/schema/document)
Use the `document` utility function to create a [Type](/docs/configuration/schema/type) with the following preconfigured fields:

- `title`
- `path`
- `metadata`

```
import {Config} from 'alinea'

Config.document('Web page', {
  fields: {
    // Extra fields
  }
})
```

## Configuration

Configuration options are the same as for a [Type](/docs/configuration/schema/type).

### Type (/docs/configuration/schema/type)
A type defines fields and presentational settings. Fields hold data that can be edited in the dashboard. Top level types which are used in a [Schema](/docs/configuration/schema)

```
import {Config, Field} from 'alinea'

Config.type('Blog', {
  contains: ['BlogPost'],
  fields: {
    title: Field.text('Title', {required: true, width: 0.5}),
    path: Field.path('Path', {required: true, width: 0.5})
  }
})
```

## Configuration

Types should be defined within a [Schema](/docs/configuration/schema)

### `contains`

Accept entries of these Types as children. For example a `Blog` can accept `BlogEntry` children.

```
contains: ['BlogEntry']
```

### `orderChildrenBy`

Defines the sort order of child entries, either ascending or descending, based on a chosen field.

```
orderChildrenBy: {asc: PageSchema.title}
orderChildrenBy: {desc: NewsDetailSchema.date}
```

### `entryUrl`

The `url` property of entries can be controlled using the `entryUrl` function in the type options. Urls are computed during generation and this can help to keep them constant if you're using a web framework that does file system routing. The available parameters are `path`, `parentPaths` and `locale`. For example: making sure a doc page always has an url in the form of `/doc/$path` you can specify `entryUrl` as the following:

```
entryUrl({path}) {
  return `/doc/${path}`
}
```

### `icon`

An icon can be used to label a type in the sidebar entry tree. Icons are implemented as a React component. You can find icons on [Icones](https://icones.js.org)

## Good to know

### Fields must be unique

Alinea will throw an error if a field shows up multiple times in the same `Type`. Having unique fields is important so we can use the field references directly when [querying data](/docs/content/query)

```
import {Config, Field} from 'alinea'

const textField = Field.text('Text field')
const MyType = Config.type('My type', {
  fields: {
    fieldA: textField,
    fieldB: textField // This is not allowed
  }
})

// You can rewrite the above to get unique 
// references by making textField a function
const textField = () => Field.text('Text field')
const MyType = Config.type('My type', {
  fields: {
    fieldA: textField(),
    fieldB: textField() // This is fine
  }
})
```

### Workspaces (/docs/configuration/workspaces)
Workspaces allow you to structure content into logical categories. For example it is possible to power multiple websites with a single CMS dashboard by using a workspace per website. Editors can easily switch workspaces in the dashboard.

```
import {Config} from 'alinea'

Config.workspace('Main workspace', {
  color: '#3F61E8',
  source: 'content',
  mediaDir: 'public',
  roots: {
    // Website pages can be managed within this Root
    pages: Config.root('Pages'),
    // Image and file uploads will end up here
    media: Config.media()
  }
})
```

## Configuration

Workspaces should be defined in your [CMS config](/docs/configuration).

### `color`

Pick a theme color this workspace.

### `source`

A directory in which published content is stored.

### `roots`

An object containing [Roots](/docs/configuration/workspaces/root).

```
// content is stored in the `content` directory
source: './content'
```

### `mediaDir`

A directory in which uploaded files are placed. In case you're using Alinea to manage web content this will often point to a directory that is made publicly available so an url can be created to download or display the file.

```
// uploaded files are placed in the `public` folder
mediaDir: './public'
```

### Media (/docs/configuration/workspaces/media)
To manage uploads a Media Root is added to a Workspace. Multiple Media Roots are supported but uploads will default to the first Media Root found.

```
import {Config} from 'alinea'

Config.workspace('Main workspace', {
  roots: {
    // ...
    media: Config.media()
  }
})
```

## Configuration

Media Roots should be defined within a [Workspace](/docs/configuration/workspaces).

### Roots (/docs/configuration/workspaces/root)
Roots help you organize content hierarchically in a tree. Pages can have subpages, subpages can have sub-subpages and so on. This hierarchy starts at a `Root`.

```
import {Config} from 'alinea'

Config.root('Pages', {
  i18n: {
    locales: ['en', 'fr', 'nl']
  },
  preview: true,
  contains: ['HomePage', 'GenericPage'],
  children: {
    index: Config.page({
      type: HomePage,
      fields: {title: 'Home'}
    }),
    'about-us': Config.page({
      type: GenericPage,
      fields: {title: 'About us'}
    })
  }
})
```

## Configuration

Roots should be defined within a [Workspace](/docs/configuration/workspaces).

### `i18n`

An object with internationalization configuration. Locales is an array of strings, containing locales described by a key that will, by default, show up in the url structure as well.

```
i18n: {
  locales: ['en', 'fr', 'nl']
}
```

### `icon`

An icon can be used to label a root in the sidebar. Icons are implemented as a React component. You can find icons on [Icones](https://icones.js.org) or install a package such as [react-icons](https://react-icons.github.io/react-icons).

### `preview`

Display an iframe with live previews on the side of the editor.

### `contains`

An array of strings containing the names of types that can be created at the root.

```
// only entries of type Page can be created at the top of the tree
contains: ['GenericPage']
```

### `children`

Defines child entries that can be automatically seeded with predefined types and fields.
The first object sets the path field. To create an empty path (for a homepage), set the path to index.

```
children: {
  index: Config.page({
    type: HomePage,
    fields: {title: 'Home'}
  })
}
```

### Content (/docs/content)
Alinea generates files on your file system in a few places.

- Content is published into a directory as JSON files. Note that [we're considering](https://github.com/alineacms/alinea/issues/10) making these files more human editable in the future too.
- Media files are published in two places: a JSON file with metadata is placed with the content and the file itself is placed in a separate directory, typically `./public`.
- A supporting Javascript library is generated inside the `@alinea/generated` package.

```
// Published content is stored in json files 
// inside the content directory
content
├ pages // pages root
│ ├ index.json
│ ├ blog.json
│ ╰ blog
│   ├ blog-post.json
│   ╰ another-blog-post.json
╰ media // media root
  ├ image.json
  ╰ file.json

// A folder which is publicly available
public
├ image.XYZ.png
╰ file.XYZ.pdf

// The alinea directory exports a Javascript library
// which can be imported from @alinea/generated
node_modules/@alinea/generated
├ ...
├ config.js
╰ source.js
```

### Editing content (/docs/content/editing-content)
Your CMS instance has methods to create and update content. During development these will update the file system directly, while in production the changes will result in a new git commit.

## Creating Entries

New Entries can be created using the `create` function.

```
import {Edit} from 'alinea'

// Start a transaction to create a new entry of type BlogPost
const post = Edit.create({
  type: BlogPost,
  set: {
    title: 'A new blog post',
    body: 'Hello world'
  }
})

// The new entry ID can be read before comitting
console.log(`Creating post with id: ${post.id}`)

// Save the changes
await cms.commit(post)
```

### Creating children

Set the `_parentId` property to nest children under a parent.

```
import {Edit} from 'alinea'

const blog = Edit.create({
  type: Blog,
  set: {title: 'Blog'}
})
await cms.commit(blog)

const posts = postData.map(data =>
  Edit.create({
    type: BlogPost,
    parentId: blog.id, 
    set: {
      title: data.title
    }
  })
)
await cms.commit(...posts)
```

## Update Fields

Entry fields can be edited using the `update` function. Optionally pass the entry Type if you want to update its fields.

```
import {Edit} from 'alinea'

// Select the first blog post
const post = await cms.get({
  type: BlogPost
})

// Edit a field and save
const update = Edit.update({
  id: post._id,
  type: BlogPost,
  set: {
    body: 'New body text'
  }
})

await cms.commit(update)
```

### Constructing field values

Some fields contain values that are more complex than a string. The Edit namespace contains helper functions to construct these. In this example we construct the value of a List Field.

```
const richTextField = richText('Item body text');
const listField = list('My list field', {
  schema: {
    Text: type('Text', {
      title: text('Item title'),
      text: richText,
    })
  }
})
const rowText = Edit.richText(richTextField)
  .addHtml(`
    <h1>Main heading</h1>
    <p>A rich text value parsed from HTML.</p>
  `)
  .value()
const listValue = Edit.list(listField)
  .add('Text', {
    title: 'The row title',
    text: rowText,
  })
  .value()
const update = Edit.update({
  id: entryId,
  type: TypeWithList,
  set: {list: listValue}
})
```

## File uploads

Files can be uploaded using the upload function.

```
import {Edit} from 'alinea'

const file = new File(['content'], 'test.txt')
const upload = Edit.upload({file})

// The new entry ID can be read before comitting
console.log(`Creating post with id: ${upload.id}`)

// Upload file and save file metadata
await cms.commit(upload)
```

### Creating image previews

Alinea can create all the metadata for images (such as previews) by passing a `createPreview` function. On the server this will use the `sharp` package to read image data. The package will need to be installed separately.

```
import {Edit} from 'alinea'
import {createPreview} from 'alinea/core/media/CreatePreview'
import fs from 'node:fs'

const file = new File([
  fs.readFileSync('./test.png')
], 'test.png')
const upload = Edit.upload({
  file, createPreview
})
await cms.commit(upload)
```

### Live previews (/docs/content/live-previews)
You can set up a live preview of your website inside the dashboard.

Framework: next

Update your [CMS config](/docs/configuration) to enable live previews:

File: cms.ts
```
import {createCMS} from 'alinea/next'

export const cms = createCMS({
  // schema and workspaces ...
  baseUrl: {
    // Point this url to your Next.js website
    development: 'http://localhost:3000'
  },
  preview: true
})
```

Include the preview widget (`<cms.previews />`) in your root layout:

File: app/layout.tsx
```
import {cms} from '@/cms'

export default async function Layout({children}: PropsWithChildren) {
  return (
    <>
      <header />
      <main>{children}</main>
      <footer />
      <cms.previews widget />
    </>
  )
}
```

This will enable a little widget at the bottom of the page that confirms to the editor they're looking at a preview of the page. If you don't want a preview widget but do want live previews, remove the `widget` prop.

### Querying content (/docs/content/query)
## Setup

Once content has been saved in the CMS you'll want a way to retrieve it. Your CMS instance has methods to fetch specific entries or search through all content.

Framework: next

```
import {cms} from '@/cms'

export default async function HomePage() {
  const homePage = await cms.get({type: HomePage})
  return <h1>{homePage.title}</h1>
}
```

## Methods

There are multiple ways to retrieve your data, which one you use depends on how you want to receive it.

```
// Always returns an array
await cms.find() 

// Returns the first result or null
await cms.first() 
// Returns only one result or throws an exception
await cms.get() 

// Returns the number of results
await cms.count()
```

## The Query object

When querying items you will have to use the Query object for its two use cases:

### Referencing standard fields:

```
Query.id                // The unique identifier of the entry.
Query.title             // The title of the entry.
Query.type              // The name of the entry's type (e.g., 'BlogPost').
Query.url               // The calculated public URL of the entry.
Query.path              // The path segment of the entry (e.g., 'my-post').
Query.parentId          // The ID of the parent entry.
Query.workspace         // The workspace the entry belongs to.
Query.root              // The root the entry belongs to.
Query.locale            // The locale code of the entry (e.g., 'en', 'fr').
Query.status            // The status of the entry (e.g., 'published').
Query.index             // The sort index of the entry within its parent.
```

### Defining relationships:

Related (/docs/content/query/related)

### Advanced (/docs/content/query/advanced)
## Complex Relations

When fetching data, you will eventually need information from a linked entry. Instead of performing multiple fetches, Alinea allows you to nest these requests within a single call.

```
await cms.first({
  locale,
  url,
  type: ParentSchema,
  select: {
    ...ParentSchema,
    // Use filters from another schema to retrieve related data
    filters: ChildSchema.filters.find({
      select: {
        id: Query.id,
        title: Query.title,
        // Fetch multiple levels of nested relations in a single, optimized query
        filterItems: Query.children({
          select: {
          id: Query.id,
          title: Query.title
          }
        })
      }
    })
  }
})
```

## Query Logic

### OR Logic

By default, providing an array of values to a filter field or using the {in: [...]} operator will apply OR logic. This means the query will return any entry that matches at least one of the specified criteria. It is the most efficient way to broaden your search across multiple possible values, such as categories or tags.

```
await cms.find({
  type: ChildSchema,
  filter: {
    category: {in: ['abc', 'def']}
  }
})
```

For more complex scenarios, such as combining filters across different fields use the explicit or operator. This allows you to group multiple distinct conditions where only one needs to be true for an entry to be included.

```
await cms.find({
  type: BlogPostSchema,
  filter: {
    or: [
      {author: 'alinea'},
      {category: 'news'}
    ]
  }
})
```

### AND Logic

Use AND logic when an entry must strictly satisfy multiple conditions simultaneously. In Alinea, this is achieved by using the and operator, which requires every filter within its scope to be true for an entry to be included in the results. This is particularly useful for complex cross-referencing where a partial match is not enough.

```
await cms.find({
  type: ChildSchema,
  filter: {
    // Use .map to create a filter condition for each ID
    and: ['abc', 'def'].map(id => ({
      filters: {includes: { _entry: id }}
    }))
  }
})
```

## Global Search

Alinea also provides a global search property. This allows you to perform broad queries across your content without targeting specific structural properties or nested filters.

```
await cms.find({
  type: BlogPostSchema,
  search: 'querying',
})

//You can also pass an array into the search, it then works like a logical OR
await cms.first({
  type: BlogPostSchema,
  search: ['cats', 'dogs'], // This will return all blogposts containing cats or dogs
})
```

### Examples (/docs/content/query/examples)
## Example Schemas

```
export const ChildSchema = Config.document('Child', {
  fields: {
    title: Field.text('Title'),
    // A multiple entry field to link related filters or categories
    filters: Field.entry.multiple('Filter(s)', {
      condition: { _type: 'FilterItem' }
    }),
    // Main content area
    blocks: Field.text('Content') 
  }
})
```

```
export const BlogPostSchema = Config.document('Blog Post', {
  fields: {
    path: Field.path('Path'),
    title: Field.text('Title'),
    description: Field.text('Description'),
    publishedDate: Field.date('Published Date'),
    // Used for the "Related Content" example
    tags: Field.list('Tags', {
      schema: Field.text('Tag')
    })
  }
})
```

## Recent Content & Pagination

This example shows how to retrieve the most recent entries while implementing a reusable pagination logic. By using constants for pageLength, you keep your query logic clean and easy to maintain.

```
const pageLength = 10
const pageIndex = 0 // Start at the first page

await cms.find({
  type: BlogPost,
  // Sort by date to get the newest items first
  orderBy: { 
    desc: BlogPost.publishedDate 
  },
  // Calculate offset based on the current page
  skip: pageIndex * pageLength,
  take: pageLength
})
```

## Filtering by Linked References

### OR Logic

Use this approach when you want to retrieve entries that are linked to one or more specific items. This is particularly useful for features like "Filtering by Category" or "View all posts by these Authors."

```
const filterIds = [
  'abc123',
  'def456'
]

await cms.find({
  type: ChildSchema,
  filter: {
    filters: {
      includes: {_entry: {in: filterIds}}
    }
  }
})
```

### AND Logic

If you need to find entries that contain all specified references simultaneously, you can map over your IDs using an and operator. This ensures every single link in your list must be present for a match.

```
const filterIds = [
  'abc123',
  'def456'
]

await cms.find({
  type: ChildSchema,
  filter: {
    and: filterIds.map(id => ({
      filters: {includes: {_entry: id}}
    }))
  }
})
```

This pattern demonstrates how to find related content by comparing shared references. First, we retrieve the current entry to extract its linked metadata. Then, we use those IDs to query other entries of the same type, ensuring the current item is excluded from the results to provide a clean list of recommendations. This leverages Next.js routing params.

Framework: next

```
type ChildProps = {locale: Locale; url: string}

export default async function Child({locale, url}: ChildProps) {
  const page = await cms.first({locale, url, type: ChildSchema})
  if (!page) return
  const filterIds = page.filters.map(filter => filter?._entry).filter(Boolean)

  const other = await cms.find({
    type: ChildSchema,
    id: {notIn: [page._id]},
    filter: {
      filters: {
        includes: {_entry: {in: filterIds}}
      }
    },
    select: {title: Query.title}
  })
}
```

### Filtering (/docs/content/query/filtering)
Use the filter property when you need to narrow down your results based on dynamic criteria or specific field values. While Structural Querying defines where or what you are looking for, Filtering allows you to search based on content, such as authors, dates, or full-text search terms.

```
await cms.first({
  type: BlogPostSchema,
  // Filter using search terms
  search: 'querying',
  // Filter by fields
  // This filters the blogposts with author 'alinea'
  filter: {
    author: 'alinea'
  }
})
```

### Comparison Operators

When filtering, you can use these operators to create precise conditions. They are especially useful for dates, numbers, and strings.

is / isNot (Equality)

```
filter: {status:{is: 'published'}}
filter: {type: {isNot: 'feature'}}}
```

in / notIn (List matching)

```
filter: {category: {in: ['news', 'tech']}}
filter: {tags: {notIn: ['draft', 'internal']}}
```

gt / gte (Greater than (or equal))

```
filter: {price: {gt: 100}}
filter: {publishedDate: {gte: '2024-01-01'}}
```

lt / lte (Less than (or equal))

```
filter: {stock: {lt: 10}}
filter: {date: {lte: today}}
```

startsWith (String matching)

```
filter: {title: {startsWith: 'Alinea'}}
```

or (Logical OR)

```
filter: {or: [{category: 'blog'}, {featured: true}]}
```

### Example

Let's take using a date as a filter for example as this lets us show quite a few of your options.

```
const today = new Date().toISOString()

// Getting the posts published in 2023
await cms.find({
  type: BlogPostSchema,
  
  filter: {
    author: 'alinea',
    publishedDate: {
      lt: '2024-01-01',
      gte: '2023-01-01'
    }
  }
})

// Getting future events
await cms.find({
  type: Event,

  filter: {
    date: {gte: today}
  }
})
```

### Related (/docs/content/query/related)
## Relational Querying

As mentioned previously, you can use include to fetch related 'edges' of a queried item. Instead of manually storing IDs and performing multiple round-trips, Alinea provides built-in relational querying for a more seamless developer experience.

```
await cms.first({
  type: DocsSchema,
  include: {
    parents: Query.parents({
      select: {
        url: Query.url, 
        title: Query.title
      }
    }),  
    children: Query.children({
      type: Doc, 
      select: {
        url: Query.url, 
        title: Query.title
      }
    }),
    siblings: Query.siblings({
      type: Docs, 
      select: {
        url: Query.url, 
        title: Query.title
      }
    })
  }
})
```

## Sequential Navigation

You can also leverage previous and next helpers for sequential navigation, ideal for building components like the pagination links at the bottom of this page.

```
await cms.first({
  type: DocSchema,
  
  include: {   
    previous: Query.previous({
      select: {
        url: Query.url, 
        title: Query.title
      }
    }),
    next: Query.next({
      select: {
        url: Query.url, 
        title: Query.title
      }
    })
  }
})
```

## Translations

Fetches translations of the current entry by locale.

```
await cms.first({
  type: DocSchema,
  
  include: {   
    translations: Query.translations({
      select: {locale: Query.locale, url: Query.url}
    })
  }
})
```

### Structural (/docs/content/query/structural)
## Structural Querying

Use top-level properties to define the scope of your query. This allows you to retrieve entries based on their structural identity such as content type, unique ID or their specific location in the project tree.

### 1. Using default fields

```
const pageIds = ['abc123', 'def456', 'ghi789']
await cms.first({
  // Filter by the schema.
  type: BlogPostSchema,

  // When you know the unique id
  id: pageId,
  // You can also use operations in these fields
  id: {in: pageIds},
  id: {notIn: pageIds},

  // Using the path (e.g. querying).
  path: pagePath,

  // Using the url (e.g. docs/content/query)
  url: pageUrl,
  // But sometimes you only need the URL to start with a certain prefix
  // instead of using javascript string manipulation you can use the startsWith field
  url: {startsWith: 'docs/content'}

  // Using the parents id
  parentId: pageParentId  
})
```

Filtering (/docs/content/query/filtering)

### 2. Using the location in the tree structure

```
await cms.first({
  // e.g. cms.workspaces.main
  workspace,
  // e.g. cms.workspaces.main.pages
  root,
  // Level: 0 = parent level, 1 = child level, 2 = grandchild level, ...
  level,

  // For Location you can use a Root, Workspace or a Page as input.
  // e.g. cms.workspaces.main | cms.workspaces.main.pages
  location
})
```

### 3. Miscellaneous

```
await cms.first({
  // This can be: 'published', 'draft', 'archived', 'preferDraft', 'preferPublished', 'all' 
  status: 'published',
  // Look for result with the exact locale.
  locale: pageLocale 
  // Look for a result with the locale, but ignore when there are no exact matches
  preferredLocale: pageLocale 
})
```

## Output control

Once you've filtered the results, you can manipulate the output. This can be done in two ways:

### 1. Data

```
await cms.first({
  // Return everything from the related type
  type: BlogPostSchema,
  id: BlogPostId
})

await cms.first({
  id: BlogPostId,
  // Only return the selected fields (similar to SQL's SELECT)  
  select: {
    title: Query.title,
    description: Query.description
  }
})

await cms.first({
  id: BlogPostId,
  select: {
    title: Query.title
  },
  // Use include to return extra fields that are not in the queried entry's schema
  include: {
    parent: Query.parent({
      select: {
        url: Query.url,
        title: Query.title
      }
    })
  }
}
```

### 2. Sorting

```
await cms.find({
  type: BlogPostSchema,
  select: {
    id: Query.id,
    title: Query.title
  },
  orderBy: {
    // Use desc or asc to sort by a specific field.
    desc: BlogPost.publishedDate,
    // This will sort the posts in alphabetical order
    asc: BlogPost.title
  },
  // Group results by 1 or more categories
  groupBy: BlogPost.category,

  //skip the first 10 results and return a max of 20
  skip: 10,
  take: 20
})
```

### TypeScript (/docs/content/typescript)
The type of a content [Type](/docs/configuration/schema/type) can be inferred.

```
import {Infer, Config, Field} from 'alinea'

const schema = {
  BlogOverview: Config.document('Blog overview', {
    contains: ['BlogPost']
  }),
  BlogPost: Config.document('Blog post', {
    fields: {
      publishDate: Field.date('Publish date'),
      body: Field.richText('Body')
    }
  })
}

type BlogOverview = Infer<typeof schema.BlogOverview>
type BlogPost = Infer<typeof schema.BlogPost>
```

### Deploy (/docs/deploy)
Once you're ready to deploy to production it's time to hook up Alinea to a backend. The Alinea handler needs access to a backend service that can authenticate users, store drafts and publish changes back to the git repository.

Framework: next

Update your [CMS config](/docs/configuration) to inform Alinea where to generate the admin dashboard.

```
export const cms = createCMS({
  // ...
  handlerUrl: '/api/cms',
  dashboardFile: 'admin.html'
})
```

While building your project, an admin folder will be created together with the `dashboardFile` as defined within your [CMS config](/docs/configuration). Exclude the admin path and the staticFile from git by adding them to your `.gitignore` file:

File: .gitignore
```
/public/admin*
```

Deploy your code and follow the instructions on `/admin.html` to configure your backend.

### Alinea Cloud (/docs/deploy/alinea-cloud)
[Alinea.cloud](https://www.alinea.cloud) provides a cloud service to set up a backend in a few clicks. It takes care of authentication, inviting other users to collaborate, and pushing changes to your git repository. After [exporting the handler and static files](/docs/deploy) you can follow the instructions by navigating to your admin dashboard (/admin.html).

### Self-Hosted (/docs/deploy/self-host)
The Alinea backend can be hosted on most Javascript runtimes if provided with a database (PostgreSQL, SQLite or Mysql) and a Github authentication token. Commits are persisted to the repository via the Github API. See a list of supported database drivers [here](https://github.com/benmerckx/rado?tab=readme-ov-file#supported-databases).

## Hosting on Vercel

An example of hosting the backend on Vercel using the included Postgres database. Authentication is provided using basic HTTP authentication. Create the credentials and a Github token and store them in your environment variables.

File: app/(alinea)/api/cms/route.ts
```
import {cms} from '@/cms'
import {db} from '@vercel/postgres'
import {createHandler} from 'alinea/next'

const handler = createHandler({
  cms,
  backend: {
    database: {
      driver: '@vercel/postgres',
      client: db
    },
    auth(username, password) {
      return (
        username === process.env.ALINEA_USERNAME &&
        password === process.env.ALINEA_PASSWORD
      )
    },
    github: {
      rootDir: 'apps/web',
      contentDir: 'content',
      authToken: process.env.ALINEA_GITHUB_TOKEN!,
      owner: process.env.ALINEA_GITHUB_OWNER!,
      repo: process.env.ALINEA_GITHUB_REPO!,
      branch: process.env.ALINEA_GITHUB_BRANCH!
    }
  }
})

export const GET = handler
export const POST = handler
```

### Getting started (/docs/getting-started)
Framework: next

## 1. Setup Next.js

Get started by creating a new Next.js project. The following will setup a project in your chosen directory. If you'd like to add Alinea to an existing project, skip this step and continue with step 2.

```shellscript
npx create-next-app@latest
```

Note (info): Read the full instructions in the [Next.js docs](https://nextjs.org/docs/getting-started/installation)

## 2. Install Alinea

Navigate to the newly created project directory and install the package with your preferred package manager.

Variant: npm
```shellscript
npm install alinea
```

Variant: yarn
```shellscript
yarn add alinea
```

Variant: pnpm
```shellscript
pnpm install alinea
```

Variant: bun
```shellscript
bun add alinea
```

## 3. Initialize the project

Alinea requires a [config file](/docs/configuration) which can be auto-generated by running `alinea init`. If you prefer plain Javascript over Typescript, rename the created file from `cms.ts` to `cms.js`.

Variant: npm
```shellscript
npx alinea init
```

Variant: yarn
```shellscript
yarn alinea init
```

Variant: pnpm
```shellscript
pnpm alinea init
```

Variant: bun
```shellscript
bun alinea init
```

## 4. Adjust Next.js config

To work around a few Next.js quirks some config changes are needed. Alinea exports a function to do this for you:

Variant: next.config.ts
```tsx
import {withAlinea} from 'alinea/next'

const nextConfig = {...}

export default withAlinea(nextConfig)
```

Variant: next.config.js
```tsx
const {withAlinea} = require('alinea/next')

const nextConfig = {...}

module.exports = withAlinea(nextConfig)
```

## 5. Access the dashboard

Congratulations, Alinea is now ready to boot!
Have a look around in the dashboard by running:

Variant: npm
```shellscript
npx alinea dev
```

Variant: yarn
```shellscript
yarn alinea dev
```

Variant: pnpm
```shellscript
pnpm alinea dev
```

Variant: bun
```shellscript
bun alinea dev
```

### Reference (/docs/reference)
Working with Alinea should feel intuitive but if you're looking for more in-depth information or feel like something is missing have a look here.

### Agents playbook (/docs/reference/agents-playbook)
This page is the operational playbook for coding agents that need to create or update Alinea content JSON directly. It focuses on deterministic rules that match Alinea core behavior and this repository's content shapes.

## Scope and source priority

When documenting or generating Alinea content, use source of truth in this order:

- The Alinea full-llms.txt file holds all documentation (including this playbook section). Use this as primary source of truth.
- In case of ambiguity or missing documentation, consult the alinea core source code. - Projects using alinea will typically have alinea as a bundled node_modules dependency, which means the (compiled) source code can be accessed directly. - In case the source code can not be retrieved, or the compiled code is unclear, consult the source code on https://github.com/alineacms/alinea
- Consult schema implementations and concrete JSON fixtures/content in the target project (for example `content/main/**`, `content/demo/**`) to find helpful examples and try to following the same structure when suggesting changes.
- The source code of the alinea documentation website is also publicly available on https://github.com/alineacms/alineacms.com. The documentation website is itself created with Alinea + Next.js and can serve as a useful example.

## Project structure conventions

In new Next.js projects, agents should follow the tutorial file structure as closely as possible to keep the codebase readable and maintainable.

In existing codebases, first scan the current structure and then align new files and changes to the established coding guidelines and architectural principles.

## Entry metadata rules

Top-level entries are JSON records with required meta fields. In Alinea core this is defined by `EntryMeta` in `src/core/EntryRecord.ts`.

```json
{
  "_id": "<createId()>",
  "_type": "<schema type name>",
  "_index": "<fractional index>",
  "_root": "pages",
  "_seeded": "/index.json",
  "title": "..."
}
```

- `_id`: unique entry id from `createId()` (`alinea/core/Id`).
- `_type`: exact schema type key.
- `_index`: fractional ordering key. Generate with `generateKeyBetween` from `alinea/core/util/FractionalIndexing`.
- `_root`: required for root-level entries (entries without a parent). Value is the workspace root key, for example `pages` or `media`.
- `_seeded`: only for entries that correspond to seeded config pages (`Config.page(...)`). Keep path stable.

## ID and index generation

```shellscript
# New id
node --input-type=module -e "import {createId} from 'alinea/core/Id'; console.log(createId())"

# Index between two siblings
node --input-type=module -e "import {generateKeyBetween} from 'alinea/core/util/FractionalIndexing'; console.log(generateKeyBetween('a0', 'a1'))"

# Append after last sibling
node --input-type=module -e "import {generateKeyBetween} from 'alinea/core/util/FractionalIndexing'; console.log(generateKeyBetween('a0', null))"
```

Alinea sorts sibling entries by `_index` ascending. Never hand-pick `_index` by eye when inserting between entries.

## Internal and external links

Links appear in two places: rich text marks and link fields (`Field.link` / `Field.link.multiple`).

### Rich text link marks

```json
[
  {
    "_type": "paragraph",
    "content": [
      {
        "_type": "text",
        "text": "Internal doc",
        "marks": [
          {
            "_type": "link",
            "_id": "<createId()>",
            "_link": "entry",
            "_entry": "<target entry id>"
          }
        ]
      },
      {
        "_type": "text",
        "text": " and external site",
        "marks": [
          {
            "_type": "link",
            "_id": "<createId()>",
            "_link": "url",
            "href": "https://example.com",
            "target": "_blank",
            "title": ""
          }
        ]
      }
    ]
  }
]
```

Rich text link mark shape is defined by `LinkMark` in `src/core/TextDoc.ts`: `_type: link`, `_id`, `_link` (`entry` | `file` | `url`), and optional `_entry`.

### Link field objects

```javascript
// Field.link('Link') -> single entry link
{
  "_id": "<createId()>",
  "_type": "entry",
  "_entry": "<target entry id>",
  "label": "Optional extra field"
}

// Field.link('Link') -> single external url
{
  "_id": "<createId()>",
  "_type": "url",
  "_url": "https://example.com",
  "_title": "Example",
  "_target": "_blank",
  "label": "Optional extra field"
}

// Field.link.multiple('Links') row
{
  "_id": "<createId()>",
  "_index": "<fractional index>",
  "_type": "entry",
  "_entry": "<target entry id>",
  "label": "Optional extra field"
}
```

For `Field.link.multiple`, each row is also a list row, so `_index` is required.

## Lists and union/list row metadata

List rows are defined by `ListRow` in `src/core/shape/ListShape.ts`. Every row must include `_id`, `_type`, `_index`.

```json
{
  "items": [
    {
      "_id": "<createId()>",
      "_index": "a0",
      "_type": "Item",
      "title": "First"
    },
    {
      "_id": "<createId()>",
      "_index": "a1",
      "_type": "Item",
      "title": "Second"
    }
  ]
}
```

Union values (from `UnionShape`) require `_id` and `_type`. If a union is inside a list, it still needs list row `_index` as well.

## Rich text JSON format

Alinea rich text is a `TextDoc` array (`src/core/TextDoc.ts`). Common internal nodes used by this project include `heading`, `paragraph`, `text`, `bulletList`, `orderedList`, `listItem`, `hardBreak`, and block nodes like `CodeBlock`/`ImageBlock` with `_id`.

```json
[
  {
    "_type": "heading",
    "level": 2,
    "content": [{"_type": "text", "text": "Heading"}]
  },
  {
    "_type": "paragraph",
    "textAlign": "left",
    "content": [
      {"_type": "text", "text": "Normal text "},
      {
        "_type": "text",
        "text": "bold",
        "marks": [{"_type": "bold"}]
      },
      {"_type": "text", "text": " "},
      {
        "_type": "text",
        "text": "italic",
        "marks": [{"_type": "italic"}]
      },
      {"_type": "hardBreak"},
      {
        "_type": "text",
        "text": "anchor",
        "marks": [
          {
            "_type": "link",
            "_id": "<createId()>",
            "_link": "url",
            "href": "https://example.com",
            "target": "_blank",
            "title": ""
          }
        ]
      }
    ]
  },
  {
    "_type": "bulletList",
    "content": [
      {
        "_type": "listItem",
        "content": [
          {
            "_type": "paragraph",
            "content": [{"_type": "text", "text": "Bullet item"}]
          }
        ]
      }
    ]
  },
  {
    "_type": "orderedList",
    "start": 1,
    "content": [
      {
        "_type": "listItem",
        "content": [
          {
            "_type": "paragraph",
            "content": [{"_type": "text", "text": "Ordered item"}]
          }
        ]
      }
    ]
  },
  {
    "_type": "CodeBlock",
    "_id": "<createId()>",
    "code": "console.log('block nodes need _id')",
    "language": "javascript",
    "fileName": "",
    "compact": false
  }
]
```

If generating from HTML, Alinea's parser maps common tags to these node/mark types (`src/core/field/RichTextField.ts`), for example `<p>` -> `paragraph`, `<a>` -> `link`, `<ul>/<ol>/<li>` -> list nodes, `<strong>` -> `bold`.

## Roots and workspaces

`Config.workspace` and `Config.root` define where content is stored and which root key each entry belongs to (`src/core/Workspace.ts`, `src/core/Root.ts`).

```text
// Example workspace layout in this repository
content/
  main/
    pages/
      docs.json
      docs/
        reference/
          cli.json
    media/
      screenshot.json
  demo/
    pages/
      index.json
      recipes/
        chocolate-chip.json
    media/
      ...

// Workspace key -> source
main -> content/main
demo -> content/demo

// Root key -> folder under each workspace source
pages -> <workspace>/pages
media -> <workspace>/media
```

Manual generation rules:

- When creating a root-level entry file, include `_root` with the matching root key.
- Place files under the workspace source directory and root directory that match config.
- For seeded pages, keep `_seeded` stable and matching the configured seed path.
- Do not remove nested identity fields (`_id`, `_index`, `_type`) from list rows, union values, or rich text block nodes.

## Validation workflow

```shellscript
# Normalize metadata via Alinea fix path
alinea build --fix

# In this repository
bun run build -- --fix

# Final validation
bun run build
```

Before commit: ensure JSON parses, `_type` matches schema, `_index` order is correct among siblings, and no duplicate `_id` values were introduced in edited scope.

### CLI (/docs/reference/cli)
```
Usage
  $ alinea <command> [options]

Available Commands
  init     Copy a sample config file to the current directory
  dev      Start development dashboard
  build    Generate content cache

For more info, run any command with the `--help` flag
  $ alinea build --help
  $ alinea init --help

Options
  -v, --version    Displays current version
  -h, --help       Displays this message
```

## Commands

### alinea init

Creates an example config file. Run once during setup.

### alinea dev

Starts a development server to display the alinea dashboard. It can be prepended to another development command to run it in parallel.

```
// Example: start the dashboard and next development server
alinea dev -- next dev
```

```
Options
  -p, --port      Port to listen on
  --production    Use production backend
```

### alinea build

The build command indexes content and generates the `@alinea/generated` package. It's recommended to run it as part of the build step in your project.

```
Options
  -w, --watch     Watch for changes to source files
  -c, --config    Location of the config file, defaults to "cms.ts"
  --fix           Any missing or incorrect properties will be overwritten by their default
```

### Internationalization (/docs/reference/internationalization)
Alinea is built with internalization in mind. It's possible to make content available in more than one language or region. To get started configure the `i18n` on a [Root](/docs/configuration/workspaces/root) in your schema. It requires a list of supported locales. Any entry created within this root can then be translated to the other languages. The url of these entries will have the locale prepended (this behaviour can be configured, see the `entryUrl` property when creating a [Type](/docs/configuration/schema/type)).

### Tutorial (/docs/tutorial)
This tutorial is an opinionated, Next.js-first setup for building a real website with Alinea. It starts with one fixed homepage entry and grows into blocks, shared layout content, a blog, and finally a catch-all route.

## Conventions used in every step

Each page type and block type lives in its own folder with a server component and schema file. We found that this is the most convenient structure for readability and maintenance. Every page/block is responsible for collecting additional data/content when necessary. You can turn each server component into [cache components](https://nextjs.org/docs/app/getting-started/cache-components) when you see fit, giving you a lot of flexibility with maximum performance. Step 4 demonstrates this with a server-rendered blog post page that fetches sibling posts for Next/Previous navigation.

Continue with the five steps in this section.

### Step 1: Landing page (/docs/tutorial/step-1-landing-page)
We're going to work towards setting up a welcome page, served on the root domain of the website. This page will simply print the title of the page, which can be adjusted in the CMS.

Image: step1 (/step1)

We recommend creating a single component file for every page. In this case we call it LandingPage.tsx. This React component will be accompagnied by a schema file, which defines the fields of this page.

File: project structure
```
app/
╰ page.tsx
╰ layout.tsx

entries/
╰ landing/
  ├ LandingPage.tsx
  ╰ LandingPage.schema.tsx

cms.tsx
```

We start by defining the landing entry schema in its own file. Its path is fixed to an empty string and made read only so it always resolves to /.

File: entries/landing/LandingPage.schema.tsx
```tsx
import {Config, Field} from 'alinea'

export const LandingPage = Config.document('Landing page', {
  fields: {
    title: Field.text('Title', {required: true, width: 0.5}),
    path: Field.path('Path', {readOnly: true, width: 0.5, initialValue: ''})
  }
})
```

Then we set up the Page-component itself. We define this as a server component and make it responsible for fetching the required data from the CMS. Notice we defined the LandingPage schema as a document, which means it automatically receives various metadata fields. We fetch this from the CMS to properly implement a generateMetadata function.

File: entries/landing/LandingPage.tsx
```tsx
import type {Metadata} from 'next'
import {notFound} from 'next/navigation'
import {cms} from '@/cms'
import {LandingPage} from './LandingPage.schema'

export async function LandingPageView() {
  const page = await cms.get({url: '/', type: LandingPage})
  if (!page) notFound()

  return (
    <main>
      <h1>{page.title}</h1>
    </main>
  )
}

export async function generateMetadata(): Promise<Metadata> {
  const page = await cms.get({url: '/', type: LandingPage})
  if (!page) return {}

  return {
    title: page.metadata.title || page.title,
    description: page.metadata?.description,
    openGraph: {
      title: page.metadata.openGraph.title || page.metadata.title || page.title,
      description: page.metadata.openGraph.description || page.metadata?.description,
      images: page.metadata?.openGraph.image
        ? [page.metadata?.openGraph.image.src]
        : undefined
    }
  }
}
```

Register the landing schema in the cms.tsx and (optionally) seed the initial homepage entry.

File: cms.tsx
```tsx
import {Config} from 'alinea'
import {createCMS} from 'alinea/next'
import {LandingPage} from '@/entries/landing/LandingPage.schema'

export const cms = createCMS({
  schema: {LandingPage},
  workspaces: {
    main: Config.workspace('Main', {
      source: 'content',
      mediaDir: 'public',
      roots: {
        pages: Config.root('Pages', {
          contains: ['LandingPage'],
          children: {
            // Optionally seed this page, alternatively you can simply create the page from the CMS directly
            index: Config.page({
              type: LandingPage,
              fields: {
                title: 'Welcome',
                path: ''
              }
            })
          }
        }),
        media: Config.media()
      }
    })
  },
  baseUrl: {
    development: 'http://localhost:3000'
  },
  handlerUrl: '/api/cms',
  dashboardFile: 'admin.html',
  preview: true
})
```

Finally, we implement the page.tsx and layout.tsx files in our app folder to fully wire this site up like a standard Next.js project. Notice we added the previews widget to the root layout, this will activate live previews for content editors.

File: app/page.tsx
```tsx
import {LandingPageView} from '@/entries/landing/LandingPage'

export {generateMetadata} from '@/entries/landing/LandingPage'

export default function Page() {
  return <LandingPageView />
}
```

File: app/layout.tsx
```tsx
import type {Metadata} from 'next'
import {cms} from '@/cms'

export default function RootLayout({children}: {children: React.ReactNode}) {
  return (
    <html lang="en">
      <body>
        {children}
        <cms.previews widget />
      </body>
    </html>
  )
}
```

Note (info): Upon completion of these steps, you should have a minimal working website with these verifiable features:

- Upon starting your CMS, a single page entry representing your landing page is present.
- Live previews are enabled, when editing the title of your landing page the previewed page should reflect these any changes.
- Wired up metadata: try navigating to the metadata tab and adjusting any of the content.

### Step 2: Content blocks (/docs/tutorial/step-2-block-list)
Websites are often build up with a variety of full-width "page blocks". Content editors can freely manage these building blocks and mix-and-match them to create compelling web content in a curated environment.

We will expand our simple landing page and allow content editors to build up content with text blocks, image blocks and a configurable weather block. Alinea has a [list-field](/docs/configuration/fields/list) which can be used to model this.

Image: step2 (/step2)

Just like for pages, we recommend using a single component and schema file for every block type. Blocks

File: project structure
```
app/
╰ page.tsx
╰ layout.tsx

entries/
╰ landing/
  ├ LandingPage.tsx
  ╰ LandingPage.schema.tsx

blocks/
├ text/
│ ├ TextBlock.tsx
│ ╰ TextBlock.schema.tsx
├ image/
│ ├ ImageBlock.tsx
│ ╰ ImageBlock.schema.tsx
╰ weather/
  ├ WeatherBlock.tsx
  ╰ WeatherBlock.schema.tsx

cms.tsx
```

Create each block type as a schema and a matching view component. The text block is composed of a single [rich text field](/docs/configuration/fields/rich-text), which we mark as inline to reduce noise for content editors.

File: blocks/text/TextBlock.schema.tsx
```tsx
import {Config, Field} from 'alinea'

export const TextBlock = Config.type('Text block', {
  fields: {
    body: Field.richText('Text', {inline: true})
  }
})
```

Alinea exposes a RichText component, which can be used to wrap rich text data and turn it into safely rendered HTML-output. The component allows for easy extending and styling of html components and even full-customizable, nested blocks.

File: blocks/text/TextBlock.tsx
```tsx
import type {Infer} from 'alinea'
import {RichText} from 'alinea/ui'
import NextLink from 'next/link'
import type {TextBlock} from './TextBlock.schema'

type TextBlockData = Infer.ListItem<typeof TextBlock>

function Link({href, ...props}: {href?: string; [key: string]: any}) {
  if (!href) return <a {...props} />
  return <NextLink href={href!} {...props} />
}

export function TextBlockView({block}: {block: TextBlockData}) {
  return <RichText doc={block.body} a={Link} />
}
```

The ImageBlock fields are quite straightforward.

File: blocks/image/ImageBlock.schema.tsx
```tsx
import {Config, Field} from 'alinea'

export const ImageBlock = Config.type('Image block', {
  fields: {
    image: Field.image('Image', {required: true, width: 0.5}),
    alt: Field.text('Alt text', {width: 0.5})
  }
})
```

We use the [nextjs Image component](https://nextjs.org/docs/app/api-reference/components/image) to render the image, which will make sure the image is cropped and optimized.

File: blocks/image/ImageBlock.tsx
```tsx
import type {Infer} from 'alinea'
import Image from 'next/image'
import type {ImageBlock} from './ImageBlock.schema'

type ImageBlockData = Infer.ListItem<typeof ImageBlock>

export function ImageBlockView({block}: {block: ImageBlockData}) {
  if (!block.image) return null

  const {src, width, height} = block.image
  return (
    <Image
      src={src}
      width={width}
      height={height}
      alt={block.alt || ''}
      style={{width: '300px', height: 'auto'}}
    />
  )
}
```

Finally we define a WeatherBlock. Content editors can define a region, from which the geo location will be determined and the weather forecast predicted.

File: blocks/weather/WeatherBlock.schema.tsx
```tsx
import {Config, Field} from 'alinea'

export const WeatherBlock = Config.type('Weather block', {
  fields: {
    title: Field.text('Title', {required: true, width: 0.5}),
    region: Field.text('Region', {
      required: true,
      width: 0.5,
      help: 'City or region name, for example: Brussels or New York'
    })
  }
})
```

The WeatherBlock is implemented as a cached, server component. The component is fully responsible for fetching the data it requires. Data is cached for a maximum of 15 minutes.

File: blocks/weather/WeatherBlock.tsx
```tsx
import {Infer} from 'alinea'
import {unstable_cacheLife as cacheLife} from 'next/cache'
import {WeatherBlock} from './WeatherBlock.schema'

type WeatherBlockData = Infer.ListItem<typeof WeatherBlock>

const weatherCodeLabels = {
  0: 'Clear sky',
  1: 'Mainly clear',
  ...,
  95: 'Thunderstorm'
}

async function getCurrentWeather(region: string) {
  'use cache'
  cacheLife({stale: 900, revalidate: 900, expire: 900})

  const geocoding = await fetch(
    'https://geocoding-api.open-meteo.com/v1/search?name=' + encodeURIComponent(region) + '&count=1'
  )
  if (!geocoding.ok) return null

  const result = (await geocoding.json()).results?.[0]
  if (!result) return null

  const forecast = await fetch(
    'https://api.open-meteo.com/v1/forecast?latitude=' +
      result.latitude +
      '&longitude=' +
      result.longitude +
      '&current=temperature_2m,weather_code&timezone=auto'
  )
  if (!forecast.ok) return null

  const current = (await forecast.json()).current
  if (!current) return null

  return {
    location: result.country ? result.name + ', ' + result.country : result.name,
    temperature: current.temperature_2m,
    summary: weatherCodeLabels[current.weather_code] ?? 'Current weather'
  }
}

export async function WeatherBlockView({block}: {block: WeatherBlockData}) {
  const weather = await getCurrentWeather(block.region)
  if(!weather) return null
  return <p>{block.title}: {weather.location}, {weather.temperature}°C ({weather.summary})</p>
}
```

Extend the landing schema by adding a blocks list that references those block schemas.

File: entries/landing/LandingPage.schema.tsx
```tsx
import {Config, Field} from 'alinea'
import {ImageBlock} from '@/blocks/image/ImageBlock.schema'
import {TextBlock} from '@/blocks/text/TextBlock.schema'
import {WeatherBlock} from '@/blocks/weather/WeatherBlock.schema'

export const LandingPage = Config.document('Landing page', {
  fields: {
    title: Field.text('Title', {required: true, width: 0.5}),
    path: Field.path('Path', {readOnly: true, width: 0.5, initialValue: ''}),
    blocks: Field.list('Blocks', {
      schema: {
        TextBlock,
        ImageBlock,
        WeatherBlock
      }
    })
  }
})
```

Update the step landing page view from the previous step. Keep it as a server component that fetches the page and generates metadata, then render each block variant based on _type. We also illustrate how to generate a fall-back metadata description.

File: entries/landing/LandingPage.tsx
```tsx
import type {TextDoc} from 'alinea'
import {Node} from 'alinea/core/TextDoc'
import type {Metadata} from 'next'
import {notFound} from 'next/navigation'
import {ImageBlockView} from '@/blocks/image/ImageBlock'
import {TextBlockView} from '@/blocks/text/TextBlock'
import {WeatherBlockView} from '@/blocks/weather/WeatherBlock'
import {cms} from '@/cms'
import {LandingPage} from './LandingPage.schema'

export async function LandingPageView() {
  const page = await cms.get({url: '/', type: LandingPage})
  if (!page) notFound()

  return (
    <main>
      <h1>{page.title}</h1>
      {page.blocks.map(block => {
        if (block._type === 'TextBlock') return <TextBlockView key={block._id} block={block} />
        if (block._type === 'ImageBlock') return <ImageBlockView key={block._id} block={block} />
        if (block._type === 'WeatherBlock') return <WeatherBlockView key={block._id} block={block} />
        return null
      })}
    </main>
  )
}

export async function generateMetadata(): Promise<Metadata> {
  const page = await cms.get({url: '/', type: LandingPage})
  if (!page) return {}

  let fallbackDescription = ''
  for (const block of page.blocks) {
    if (block._type === 'TextBlock') {
      fallbackDescription = plainText(block.body)
      break
    }
  }

  return {
    title: page.metadata.title || page.title,
    description: page.metadata?.description || fallbackDescription,
    openGraph: {
      title: page.metadata.openGraph.title || page.metadata.title || page.title,
      description: page.metadata.openGraph.description || page.metadata?.description,
      images: page.metadata?.openGraph.image
        ? [page.metadata?.openGraph.image.src]
        : undefined
    }
  }
}

export function plainText(value: TextDoc<any> | string | undefined): string {
  if (!value) return ''
  if (typeof value === 'string') return value

  if (!Array.isArray(value)) return ''
  const result = value
    .reduce((acc, node) => {
      return acc + textOf(node)
    }, '')
    .trim()
  return result.replace(/ +(?= )/g, '')
}

function textOf(node: Node): string {
  if (node._type === 'hardBreak') return '\n'
  if (Node.isText(node)) {
    return node.text ? ' ' + node.text : ''
  } else if (Node.isElement(node) && node.content) {
    return node.content.reduce((acc, node) => {
      return acc + textOf(node)
    }, '')
  }
  return ''
}
```

Note (info): Upon completion of these steps, your landing page should be extended with these features:

- A system of content blocks which can be rearranged at will.
- An example of a rendered, rich text field.
- An example of a more complex block, which fetches data from an external API.
- Metadata fallback: content of the first text block is used as fallback description.

### Step 3: Shared root (/docs/tutorial/step-3-layout-root)
In this step we keep the landing page from the previous steps and add a dedicated settings root for layout content shared by all pages. We will define a global settings entry which holds information about the top menu or global footer

Other examples you might want to include in this root:

- Taxonomy lists (eg. for tagging)
- Shared content such as a list of authors
- A dictionary of words used across the website that are not tied to a specific page

Image: step3 (/step3)

Under entries we define settings, just like the pages and blocks we split this logic in 2 files. One to define the schema and one to define the corresponding React views.

File: project structure
```
app/
├ page.tsx
╰ layout.tsx

entries/
├ landing/
│ ╰ ...
╰ settings/
  ├ SiteLayout.tsx
  ╰ SiteLayout.schema.tsx

blocks/
╰ ...

cms.tsx
```

Define a dedicated entry type for shared layout content used site-wide, and disable preview for this global settings entry. Notice that we use Config.type here, instead of Config.document. We don't need metadata fields on this type of entry.

File: entries/settings/SiteLayout.schema.tsx
```tsx
import {Config, Field} from 'alinea'

export const SiteLayout = Config.type('Site layout', {
  preview: false,
  fields: {
    title: Field.text('Entry title', {initialValue: 'Global settings', width: 0.5}),
    path: Field.path('Path', {readOnly: true, initialValue: 'settings', width: 0.5}),
    headerText: Field.text('Header text', {required: true}),
    footerText: Field.text('Footer text', {required: true})
  }
})
```

Create small header and footer components and infer their props from the SiteLayout schema.

File: entries/settings/SiteLayout.tsx
```tsx
import type {Infer} from 'alinea'
import {SiteLayout as SiteLayoutEntry} from './SiteLayout.schema'

type SiteLayoutProps = Infer.Entry<typeof SiteLayoutEntry>

export function SiteHeader({settings}: {settings: SiteLayoutProps}) {
  return <header>{settings.headerText}</header>
}

export function SiteFooter({settings}: {settings: SiteLayoutProps}) {
  return <footer>{settings.footerText}</footer>
}
```

Register SiteLayout in a dedicated settings root and (optionally) seed one entry.

File: cms.tsx
```tsx
import {Config} from 'alinea'
import {createCMS} from 'alinea/next'
import type {SVGProps} from 'react'
import {LandingPage} from '@/entries/landing/LandingPage.schema'
import {SiteLayout} from '@/entries/settings/SiteLayout.schema'

export const cms = createCMS({
  schema: {
    LandingPage,
    SiteLayout
  },
  workspaces: {
    main: Config.workspace('Main', {
      source: 'content',
      mediaDir: 'public',
      roots: {
        pages: Config.root('Pages', {
          contains: ['LandingPage'],
          children: {
            index: Config.page({
              type: LandingPage,
              fields: {
                title: 'Welcome',
                path: ''
              }
            })
          }
        }),
        settings: Config.root('Settings', {
          icon: MaterialSymbolsSettingsOutline,
          contains: ['SiteLayout'],
          children: {
            settings: Config.page({
              type: SiteLayout,
              fields: {
                title: 'Global settings',
                path: 'settings',
                headerText: 'My website',
                footerText: 'Copyright 2026'
              }
            })
          }
        }),
        media: Config.media()
      }
    })
  },
  baseUrl: {
    development: 'http://localhost:3103'
  },
  handlerUrl: '/api/cms',
  dashboardFile: 'admin.html',
  preview: true
})

// Probably best to place this in a separate file, but for the sake of simplicity we'll keep it here
export function MaterialSymbolsSettingsOutline(props: SVGProps<SVGSVGElement>) {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      width="1em"
      height="1em"
      viewBox="0 0 24 24"
      {...props}
    >
      {/* Icon from Material Symbols by Google - https://github.com/google/material-design-icons/blob/master/LICENSE */}
      <path
        fill="currentColor"
        d="m9.25 22l-.4-3.2q-.325-.125-.612-.3t-.563-.375L4.7 19.375l-2.75-4.75l2.575-1.95Q4.5 12.5 4.5 12.338v-.675q0-.163.025-.338L1.95 9.375l2.75-4.75l2.975 1.25q.275-.2.575-.375t.6-.3l.4-3.2h5.5l.4 3.2q.325.125.613.3t.562.375l2.975-1.25l2.75 4.75l-2.575 1.95q.025.175.025.338v.674q0 .163-.05.338l2.575 1.95l-2.75 4.75l-2.95-1.25q-.275.2-.575.375t-.6.3l-.4 3.2zM11 20h1.975l.35-2.65q.775-.2 1.438-.587t1.212-.938l2.475 1.025l.975-1.7l-2.15-1.625q.125-.35.175-.737T17.5 12t-.05-.787t-.175-.738l2.15-1.625l-.975-1.7l-2.475 1.05q-.55-.575-1.212-.962t-1.438-.588L13 4h-1.975l-.35 2.65q-.775.2-1.437.588t-1.213.937L5.55 7.15l-.975 1.7l2.15 1.6q-.125.375-.175.75t-.05.8q0 .4.05.775t.175.75l-2.15 1.625l.975 1.7l2.475-1.05q.55.575 1.213.963t1.437.587zm1.05-4.5q1.45 0 2.475-1.025T15.55 12t-1.025-2.475T12.05 8.5q-1.475 0-2.488 1.025T8.55 12t1.013 2.475T12.05 15.5M12 12"
      />
    </svg>
  )
}
```

Use the SiteLayout type in app/layout.tsx and render shared header and footer content around all pages.

File: app/layout.tsx
```tsx
import {cms} from '@/cms'
import {SiteFooter, SiteHeader} from '@/entries/settings/SiteLayout'
import {SiteLayout} from '@/entries/settings/SiteLayout.schema'

export default async function RootLayout({children}: {children: React.ReactNode}) {
  const settings = await cms.get({
    root: cms.workspaces.main.settings,
    type: SiteLayout
  })

  return (
    <html lang='en'>
      <body>
        <SiteHeader settings={settings} />
        {children}
        <SiteFooter settings={settings} />
        <cms.previews widget />
      </body>
    </html>
  )
}
```

Note (info): Upon completion of these steps, the website is extended with these features:

- We defined an additional root in our CMS dashboard, this root holds a global settings entry.
- We demonstrated how to use custom icons in the CMS dashboard.
- The global settings are fetched from the root layout and used to display a header/footer on any website page.

### Step 4: Blog (/docs/tutorial/step-4-blog)
In this step we keep the landing page, blocks, and shared settings root from step 3, then add a dedicated blog section. The blog overview is fixed to /blog and contains nested post pages with editable slugs.

Image: step4_a (/step4-a)

Image: step4_b (/step4-b)

File: project structure
```
app/
├ page.tsx
├ layout.tsx
├ blog/page.tsx
╰ blog/[slug]/page.tsx

entries/
├ landing/
│ ├ LandingPage.tsx
│ ╰ LandingPage.schema.tsx
├ blog/
│ ├ Blog.tsx
│ ╰ Blog.schema.tsx
├ post/
│ ├ Post.tsx
│ ╰ Post.schema.tsx
╰ settings/
  ├ SiteLayout.tsx
  ╰ SiteLayout.schema.tsx

blocks/
╰ ...

cms.tsx
```

Start by defining the blog overview entry schema.

File: entries/blog/Blog.schema.tsx
```tsx
import {Config, Field} from 'alinea'

export const Blog = Config.document('Blog page', {
  contains: ['Post'],
  fields: {
    title: Field.text('Title', {required: true, width: 0.5}),
    path: Field.path('Path', {
      readOnly: true,
      initialValue: 'blog',
      width: 0.5
    }),
    intro: Field.text('Intro', {multiline: true})
  }
})
```

Then implement the blog overview server component. It fetches the blog entry and its direct post children in one query, and exposes generateMetadata.

File: entries/blog/Blog.tsx
```tsx
import {Query} from 'alinea'
import {Entry} from 'alinea/core/Entry'
import type {Metadata} from 'next'
import {notFound} from 'next/navigation'
import Link from 'next/link'
import {cms} from '@/cms'
import {Post} from '@/entries/post/Post.schema'
import {Blog} from './Blog.schema'

type PostLink = {id: string; title: string; url: string}

export async function BlogView() {
  const page = await cms.get({
    url: '/blog',
    type: Blog,
    select: {
      title: Blog.title,
      intro: Blog.intro,
      posts: Query.children({
        type: Post,
        select: {
          id: Entry.id,
          title: Entry.title,
          url: Entry.url
        }
      })
    }
  })
  if (!page) notFound()

  return (
    <main>
      <h1>{page.title}</h1>
      {page.intro && <p>{page.intro}</p>}
      <ul>
        {page.posts.map((post: PostLink) => (
          <li key={post.id}>
            <Link href={post.url}>{post.title}</Link>
          </li>
        ))}
      </ul>
    </main>
  )
}

export async function generateMetadata(): Promise<Metadata> {
  const page = await cms.get({url: '/blog', type: Blog})
  if (!page) return {}

  return {
    title: page.metadata.title || page.title,
    description: page.metadata?.description || page.intro,
    openGraph: {
      title: page.metadata.openGraph.title || page.metadata.title || page.title,
      description:
        page.metadata.openGraph.description ||
        page.metadata?.description ||
        page.intro,
      images: page.metadata?.openGraph.image
        ? [page.metadata?.openGraph.image.src]
        : undefined
    }
  }
}
```

Define the post entry schema in its own folder.

File: entries/post/Post.schema.tsx
```tsx
import {Config, Field} from 'alinea'

export const Post = Config.document('Post page', {
  fields: {
    title: Field.text('Title', {required: true, width: 0.5}),
    path: Field.path('Path', {required: true, width: 0.5}),
    excerpt: Field.text('Excerpt', {multiline: true}),
    body: Field.richText('Body')
  }
})
```

Implement the post detail server component with previous/next sibling navigation and a post metadata helper.

File: entries/post/Post.tsx
```tsx
import {Entry} from 'alinea/core/Entry'
import type {TextDoc} from 'alinea'
import {Node} from 'alinea/core/TextDoc'
import {RichText} from 'alinea/ui'
import type {Metadata} from 'next'
import {notFound} from 'next/navigation'
import Link from 'next/link'
import {cms} from '@/cms'
import {Post} from './Post.schema'

type PostLink = {id: string; title: string; url: string; path: string}

export async function PostView({slug}: {slug: string}) {
  const post = await cms.get({url: `/blog/${slug}`, type: Post})
  if (!post) notFound()

  const blogPage = await cms.get({url: '/blog'})
  if (!blogPage) notFound()

  const siblings = await cms.find({
    parentId: blogPage._id,
    select: {
      id: Entry.id,
      title: Entry.title,
      url: Entry.url,
      path: Entry.path
    }
  })

  const index = siblings.findIndex(candidate => candidate.path === slug)
  const previousPost: PostLink | null = index > 0 ? siblings[index - 1] : null
  const nextPost: PostLink | null =
    index >= 0 && index < siblings.length - 1 ? siblings[index + 1] : null

  return (
    <article>
      <h1>{post.title}</h1>
      {typeof post.body === 'string' ? <p>{post.body}</p> : <RichText doc={post.body} />}
      <p>
        <Link href="/blog">← Back to the full blog archive</Link>
      </p>
      {(previousPost || nextPost) && (
        <nav aria-label="Post navigation">
          <h2>Next/Previous blogpost</h2>
          <ul>
            {previousPost && (
              <li>
                <Link href={previousPost.url}>
                  Previous: {previousPost.title}
                </Link>
              </li>
            )}
            {nextPost && (
              <li>
                <Link href={nextPost.url}>Next: {nextPost.title}</Link>
              </li>
            )}
          </ul>
        </nav>
      )}
    </article>
  )
}

export async function generatePostMetadata(slug: string): Promise<Metadata> {
  const post = await cms.get({url: `/blog/${slug}`, type: Post})
  if (!post) return {}

  const bodyText = plainText(post.body)

  return {
    title: post.metadata.title || post.title,
    description: post.metadata?.description || post.excerpt || bodyText,
    openGraph: {
      title: post.metadata.openGraph.title || post.metadata.title || post.title,
      description:
        post.metadata.openGraph.description ||
        post.metadata?.description ||
        post.excerpt ||
        bodyText,
      images: post.metadata?.openGraph.image
        ? [post.metadata?.openGraph.image.src]
        : undefined
    }
  }
}

function plainText(value: TextDoc<any> | string | undefined): string {
  if (!value) return ''
  if (typeof value === 'string') return value

  if (!Array.isArray(value)) return ''
  const result = value
    .reduce((acc, node) => {
      return acc + textOf(node)
    }, '')
    .trim()
  return result.replace(/ +(?= )/g, '')
}

function textOf(node: Node): string {
  if (node._type === 'hardBreak') return '\n'
  if (Node.isText(node)) {
    return node.text ? ' ' + node.text : ''
  } else if (Node.isElement(node) && node.content) {
    return node.content.reduce((acc, node) => {
      return acc + textOf(node)
    }, '')
  }
  return ''
}
```

Register Blog and Post in cms.tsx and seed the fixed /blog overview page under the Pages root.

File: cms.tsx
```tsx
import {Blog} from '@/entries/blog/Blog.schema'
import {LandingPage} from '@/entries/landing/LandingPage.schema'
import {Post} from '@/entries/post/Post.schema'
import {SiteLayout} from '@/entries/settings/SiteLayout.schema'

export const cms = createCMS({
  schema: {
    LandingPage,
    SiteLayout,
    Blog,
    Post
  },
  workspaces: {
    main: Config.workspace('Main', {
      roots: {
        pages: Config.root('Pages', {
          contains: ['LandingPage', 'Blog'],
          children: {
            index: Config.page({
              type: LandingPage,
              fields: {
                title: 'Welcome',
                path: ''
              }
            }),
            blog: Config.page({
              type: Blog,
              fields: {
                title: 'Blog',
                path: 'blog',
                intro: 'Latest posts'
              }
            })
          }
        })
      }
    })
  }
})
```

Add dedicated Next.js routes for /blog and /blog/[slug]. Keep these routes thin and delegate rendering + metadata to entry components.

File: app/blog/page.tsx
```tsx
import {BlogView} from '@/entries/blog/Blog'

export {generateMetadata} from '@/entries/blog/Blog'

export default function BlogRoute() {
  return <BlogView />
}
```

File: app/blog/[slug]/page.tsx
```tsx
import {Entry} from 'alinea/core/Entry'
import type {Metadata} from 'next'
import {cms} from '@/cms'
import {generatePostMetadata, PostView} from '@/entries/post/Post'
import {Post} from '@/entries/post/Post.schema'

interface PostRouteProps {
  params: Promise<{slug: string}>
}

export async function generateStaticParams() {
  const paths = await cms.find({
    type: Post,
    select: Entry.path
  })

  return paths.map(slug => ({slug}))
}

export async function generateMetadata({
  params
}: PostRouteProps): Promise<Metadata> {
  const {slug} = await params
  return generatePostMetadata(slug)
}

export default async function BlogPostRoute({params}: PostRouteProps) {
  const {slug} = await params
  return <PostView slug={slug} />
}
```

Note (info): Upon completion of these steps, your website should be extended with these features:

- The landing page from step 3 keeps working with text, image, and weather blocks.
- A fixed /blog overview page is seeded and available right away.
- Individual blog post pages support previous/next navigation between sibling posts.
- Metadata generation is implemented for the blog listing and post detail routes.

### Step 5: Catch-all route (/docs/tutorial/step-5-catch-all)
In this step we replace dedicated routes with one catch-all route, which gives full freedom to content editors to create and maintain their own content structures. All pages are served via one route.

Image: step5 (/step5)

We keep the Blog/Post setup from the previous step and add recursive Page entries for regular pages. We get rid of the current page.tsx routes for the landing page, the blog and invidivual posts and replace them all with one new route.

File: project structure
```
app/
├ layout.tsx
╰ [[...slug]]/page.tsx

entries/
├ page/
│ ├ Page.tsx
│ ╰ Page.schema.tsx
├ blog/
│ ├ Blog.tsx
│ ╰ Blog.schema.tsx
├ post/
│ ├ Post.tsx
│ ╰ Post.schema.tsx
╰ settings/
  ├ SiteLayout.tsx
  ╰ SiteLayout.schema.tsx

blocks/
╰ ...

cms.tsx
```

Define a recursive Page entry type so editors can freely nest regular pages under one another, such as an About page with Team and History underneath it. You can define which page types can be nested by using the contains property.

File: entries/page/Page.schema.tsx
```tsx
import {Config, Field} from 'alinea'
import {GalleryBlock} from '@/blocks/gallery/GalleryBlock.schema'
import {ImageBlock} from '@/blocks/image/ImageBlock.schema'
import {TextBlock} from '@/blocks/text/TextBlock.schema'

export const Page = Config.document('Page', {
  contains: ['Page'],
  fields: {
    title: Field.text('Title', {required: true, width: 0.5}),
    path: Field.path('Path', {required: true, width: 0.5}),
    blocks: Field.list('Blocks', {
      schema: {
        TextBlock,
        ImageBlock,
        GalleryBlock
      }
    })
  }
})
```

Make sure to register this new type in cms.tsx. Regular pages can then be created freely in the Pages tree. We did opt to also go for one shared generateMetadata function, since all pages share the same metadata fields. In a real applications you might want to define different logic per page type, do define custom fallback metadata depending on the type of page.

File: cms.tsx
```tsx
import {Blog} from '@/entries/blog/Blog.schema'
import {Page} from '@/entries/page/Page.schema'
import {Post} from '@/entries/post/Post.schema'
import {SiteLayout} from '@/entries/settings/SiteLayout.schema'

export const cms = createCMS({
  schema: {
    Page,
    SiteLayout,
    Blog,
    Post
  },
  workspaces: {
    main: Config.workspace('Main', {
      roots: {
        pages: Config.root('Pages', {
          contains: ['Page', 'Blog'],
          children: {
            blog: Config.page({
              type: Blog,
              fields: {title: 'Blog', path: 'blog', intro: 'Latest posts'}
            })
          }
        }),
        settings: Config.root('Settings', {
          contains: ['SiteLayout']
        })
      }
    })
  }
})
```

Use a catch-all route that resolves the URL from slug segments and branches on page._type. Page rendering stay inside their own server components, which nicely isolates functionality per page type. This one route replaces app/page.tsx, app/blog/page.tsx, and app/blog/[slug]/page.tsx.

File: app/[[...slug]]/page.tsx
```tsx
import type {Infer} from 'alinea'
import {Entry} from 'alinea/core/Entry'
import type {Metadata} from 'next'
import {notFound} from 'next/navigation'
import {cms} from '@/cms'
import {BlogView} from '@/entries/blog/Blog'
import {PageView} from '@/entries/page/Page'
import {Page} from '@/entries/page/Page.schema'
import {PostView} from '@/entries/post/Post'

interface RouteProps {
  params: Promise<{slug?: Array<string>}>
}

export async function generateStaticParams() {
  const urls = await cms.find({
    root: cms.workspaces.main.pages,
    select: Entry.url
  })

  return urls.map(url => ({slug: url === '/' ? [] : url.slice(1).split('/')}))
}

export async function generateMetadata({
  params
}: RouteProps): Promise<Metadata> {
  const {slug = []} = await params
  const url = slug.length > 0 ? `/${slug.join('/')}` : '/'
  const page = await cms.get({
    url,
    include: {
      title: Entry.title,
      metadata: Page.metadata // We will introduce Entry.metadata shortly, for now use Page or any other document type
    }
  })
  if (!page) return {}

  return {
    title: page.metadata?.title || page.title,
    description: page.metadata?.description,
    openGraph: {
      title:
        page.metadata?.openGraph?.title || page.metadata?.title || page.title,
      description:
        page.metadata?.openGraph?.description || page.metadata?.description,
      images: page.metadata?.openGraph?.image
        ? [page.metadata.openGraph.image.src]
        : undefined
    }
  }
}

export default async function CatchAllPage({params}: RouteProps) {
  const {slug = []} = await params
  const url = slug.length > 0 ? `/${slug.join('/')}` : '/'
  const page = await cms.get({url})

  if (!page) notFound()

  if (page._type === 'Blog') {
    return <BlogView />
  }

  if (page._type === 'Post') {
    const postSlug = slug[slug.length - 1]
    if (!postSlug) notFound()
    return <PostView slug={postSlug} />
  }

  const regularPage = await cms.get({url, type: Page})
  if (!regularPage) notFound()
  return <PageView page={regularPage} />
}
```

Note (info): Upon completion of these steps, your website should be extended with these features:

- A single catch-all route resolves all page URLs in the Pages root, including nested regular pages.
- Regular pages are modeled with recursive Page entries, for example an "About"-page with Team and History underneath it.
- The Blog/Post flow from step 4 still works, including post navigation and the back-to-blog CTA.

Note (warning): It's worth mentioning that a catch-all route is not always the preferred way of working. It improves the flexibility of your website but it makes it impossible for Next.js to have a per-page javascript-bundle, so it might make the initial page loads of your website a bit heavier. Go for the solution that best fits your specific needs.
