---
title: Cloud
description: Configure how the qino cloud UI publishes, searches, sorts, hides fields, and saves uploads.
---

<Callout type="warn">
The cloud UI is in development and not available yet. Setting `cloud` won't do anything for now: `qino build` writes the options to the config file, but nothing reads them yet.
</Callout>

The qino cloud UI lets editors change content without touching code. Its only contract with your project is `qino/_generated/config.json`. Set `cloud` on [`initQino`](https://www.qino.works/docs/api/init-qino) and `qino build` writes that file, including the cloud options below. See [Config file](https://www.qino.works/docs/guide/cli#config-file).


```ts title="qino/index.ts"
import { initQino } from "@qino/cms";

export default initQino({
  contentFolder: "src/content",
  mediaFolder: "public",
  cloud: {
    publish: { mode: "open_pull_request", reviewBeforePublish: true },
  },
});
```

## Publish

`publish.mode` decides what happens when an editor publishes:

- `"open_pull_request"`: the cloud UI pushes a branch and opens a pull request. Branches start with `branchPrefix`, `"qino/"` by default.
- `"commit_to_default_branch"`: the cloud UI commits to the repository's default branch. `branchPrefix` is a type error here.

`publish.reviewBeforePublish` is required. When `true`, an approver signs off in qino before the author can publish, in either mode.

Pass an object instead of `true` to configure review. `bypassIfAdmin` lets admins publish before every entry is approved, `false` by default:

```ts title="qino/index.ts"
publish: {
  mode: "open_pull_request",
  reviewBeforePublish: { bypassIfAdmin: true },
},
```

In `config.json`, `reviewBeforePublish` is `false`, or its settings with defaults filled in: `true` becomes `{ "bypassIfAdmin": false }`.

Until an editor publishes, edits are saved as drafts in the cloud UI, not in the repository.

## Primitives

Each primitive takes its own `cloud` option. Setting it without `cloud` on `initQino` throws at definition.

```ts title="qino/collections/posts.ts"
export const postCollection = qino.defineCollection({
  directory: "posts",
  extension: ".md",
  schema: z.object({
    title: z.string(),
    draft: z.boolean(),
    cover: z.string(),
    notes: z.string().optional(),
  }),
  media: { cover: { accepted: "image/webp", maxSize: "5MB" } },
  cloud: {
    displayAs: "Blog posts",
    displayEntryAs: "{title}",
    saveEntryAs: "{title}",
    searchableBy: ["title"],
    sortableBy: ["title", "draft"],
    hiddenFields: ["notes"],
    media: { saveFileAs: "posts/{field}/{name}-{hash}" },
  },
});
```

| Option             | Collections | Trees | Items | Default                     | Description                                                                                                                                |
| ------------------ | ----------- | ----- | ----- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `displayAs`        | yes         | yes   | yes   | The id                      | Name of the primitive in the cloud UI, such as `"Blog posts"`. A plain string, without tokens                                              |
| `displayEntryAs`   | yes         | yes   |       | `"{<titleField>}"` on trees | Label of entries in the cloud UI, from their fields, such as `"{firstName} {lastName}"`. Unset on collections, the cloud UI shows the slug |
| `saveEntryAs`      | yes         | yes   |       |                             | Name of new entries, from their fields, such as `"{firstName}-{lastName}"`. Unset, editors type the slug                                   |
| `searchableBy`     | yes         | yes   |       | `[]`                        | Top-level fields the cloud UI searches. Empty, there's no search                                                                           |
| `sortableBy`       | yes         |       |       | `[]`                        | Top-level string, number, or boolean fields the cloud UI sorts by. Empty, no sorting                                                       |
| `hiddenFields`     | yes         | yes   | yes   | `[]`                        | Top-level optional fields the cloud UI doesn't show. Their values are kept on save                                                         |
| `media.saveFileAs` | yes         | yes   | yes   | `"{id}/{name}-{hash}"`      | File path an upload is saved to when publishing, relative to `mediaFolder`                                                                 |

Trees are ordered by their order file, so they have no `sortableBy`. Items are a single file, so they have no `saveEntryAs` or `displayEntryAs`, and only take `displayAs`, `hiddenFields`, and `media`.

Which fields hold media, and the files they accept, isn't cloud config: it's the primitive's own [`media`](https://www.qino.works/docs/concepts/media), which works without `cloud`. The cloud UI reads it from `config.json` to offer a media picker on each media field and accept only matching uploads, in fields and in the body.

Fields are typed against the schema input, not its output, since the cloud UI reads files on disk: with a transform that renames `created-on` to `createdOn`, use `"created-on"`. Fields whose input is `unknown`, such as `z.coerce.date()`, can't be sorted by. `qino build` checks the same rules against the schema's JSON Schema, and also fails when a `sortableBy` field allows a non-scalar value in any union branch.

### Naming new entries

`saveEntryAs` names the file of an entry an editor creates, from its fields. Each `{token}` is a top-level field, filled with the value the editor enters, then slugified. Text outside tokens is kept:

| `saveEntryAs`              | Fields                                 | File                  |
| -------------------------- | -------------------------------------- | --------------------- |
| `"{firstName}-{lastName}"` | `firstName: "Jane"`, `lastName: "Doe"` | `jane-doe.json`       |
| `"post-{title}"`           | `title: "Hello, World!"`               | `post-hello-world.md` |

The cloud UI adds the extension, and in trees the parent folder, from where the entry is added in the outline: `"{title}"` names a child "Caching" under `guides` `guides/caching.mdx`. When the name is taken, it adds `-2`, `-3`, and so on. The name is set once, when the entry is created. Unset, editors type the slug.

Tokens must be fields every entry has: required, without a default, in every variant of a union schema, and a string, number, or boolean. `saveEntryAs` is type-checked from its literal, so an unknown or optional field, a `/` or `.`, or a pattern without a token is a type error. `qino build` checks the same rules, including for a `saveEntryAs` passed as a `string` variable, which types can't check.

### Naming primitives and entries

`displayAs` names the primitive itself, such as `"Blog posts"` for the `posts` collection, where the cloud UI lists primitives and titles their pages. It's a plain string: `qino build` fails on an empty one or one with a `{field}`. Unset, it's the id: the `directory`, or the `file` of an item. An item is a single entry, so `displayAs` names that entry too.

`displayEntryAs` is how the cloud UI labels each entry of a collection or tree, from its fields. It uses the same `{token}` syntax as `saveEntryAs`, but values aren't slugified, and text outside tokens can be anything:

| `displayEntryAs`            | Fields                                 | Label           |
| --------------------------- | -------------------------------------- | --------------- |
| `"{firstName} {lastName}"`  | `firstName: "Jane"`, `lastName: "Doe"` | `Jane Doe`      |
| `"{lastName}, {firstName}"` | `firstName: "Jane"`, `lastName: "Doe"` | `Doe, Jane`     |
| `"Post: {title}"`           | `title: "Welcome"`                     | `Post: Welcome` |

Unset, collections' entries show their slug, and trees fall back to `titleField`, which titles nodes in `getTree` too. Tokens follow the same rules as `saveEntryAs`'s: required fields every entry has, and strings, numbers, or booleans. They're type-checked from the literal, and `qino build` checks them too.

### Hidden fields

`hiddenFields` only takes optional fields, `.optional()` or `.default()` in zod, so editors can still create entries: a required field is a type error, and `qino build` fails on one without a default. Hidden fields can also be in `searchableBy` and `sortableBy`, but never in `saveEntryAs` or `displayEntryAs`, whose fields are required.

### Media fields

The cloud UI edits each field of the primitive's [`media`](https://www.qino.works/docs/concepts/media) with a media picker, and accepts uploads of its type and size. `markdown` rules apply to images added to the body. A media path must also be a field of the schema input, as with relations: `qino build` fails when a transform renames it.

The cloud UI writes the URL the file is served at, starting with `/`: picking the upload saved at `public/posts/cover/cover-1a2b3c4d.webp` writes `"/posts/cover/cover-1a2b3c4d.webp"`. That's the URL qino reads images from and [`qino check`](https://www.qino.works/docs/guide/cli#media) resolves against `mediaFolder`.

### Saving uploads

When an editor publishes, each uploaded file is saved to the path `media.saveFileAs` describes, relative to `mediaFolder`. As with entries' `saveEntryAs`, the cloud UI adds the extension: the uploaded file's, lowercased. With `mediaFolder: "public"` and `saveFileAs: "posts/{field}/{name}-{hash}"`, uploading `Cover.webp` to the `cover` field saves `public/posts/cover/cover-1a2b3c4d.webp`, served at `/posts/cover/cover-1a2b3c4d.webp`. Tokens:

| Token     | Value                                                                                                                                            |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `{id}`    | The primitive's `directory`, or its `file` without the extension for items                                                                       |
| `{field}` | The media path the file is uploaded to, slugified: `cover`, `gallery` for `"gallery[*]"`, `seo-image` for `"seo.image"`, `markdown` for the body |
| `{name}`  | The uploaded file's name without its extension, slugified                                                                                        |
| `{hash}`  | The first 8 hex characters of the file's SHA-256                                                                                                 |

With `{hash}`, an upload never overwrites another file, and each URL always serves the same content, so it can be cached for good. `qino build` fails on an unknown token, a `.` in the file name, a path ending with `/`, or a path starting with `/` or containing `..`.

## In config.json

`config.json` holds every value resolved, so the cloud UI doesn't reimplement defaults: `branchPrefix`, `reviewBeforePublish`'s settings, and `media.saveFileAs` are filled in, and lists default to `[]`. Each primitive's [`media`](https://www.qino.works/docs/concepts/media) sits next to its relations, with `maxSize` in bytes, so `"5MB"` becomes `5242880`. `displayAs` and `displayEntryAs` sit at the top level of the primitive, next to `titleField`. `displayAs` defaults to the id, and trees' `displayEntryAs` to `"{<titleField>}"`. Collections leave `displayEntryAs` out when unset.

```json title="qino/_generated/config.json"
{
  "cloud": {
    "publish": {
      "mode": "open_pull_request",
      "reviewBeforePublish": { "bypassIfAdmin": false },
      "branchPrefix": "qino/"
    }
  },
  "collections": {
    "posts": {
      "displayAs": "Blog posts",
      "displayEntryAs": "{title}",
      "media": {
        "cover": { "accepted": "image/webp", "maxSize": 5242880 }
      },
      "cloud": {
        "searchableBy": ["title"],
        "sortableBy": ["title", "draft"],
        "hiddenFields": ["notes"],
        "media": { "saveFileAs": "posts/{field}/{name}-{hash}" }
      }
    }
  }
}
```
