# Collections (/docs/schema/collections)

---
title: Collections
description: A collection is one table you declare in a file. QUESTPIE reads that file and builds a typed CRUD object, REST routes, an admin screen and a client, then moves all four together when you change it.
kind: guide
package: questpie
---

You have written one collection. This page is the rest of the builder: which
method to reach for, what each one changes, and what a second call to the same
method does.

## Declare it

Put a file under your `collections/` directory. Import `collection` from
`#questpie/factories`, not from `questpie`. The generated factory knows
which modules you enabled, so module field types such as `f.richText()` appear
on `f`. The bare export from `questpie` sees only the built-in types.

```ts title="src/questpie/server/collections/posts.ts"
import { collection } from "#questpie/factories";

export const posts = collection("posts")
	.fields(({ f }) => ({
		title: f.text(255).label("Title").required(),
		slug: f.text(120).required(),
		body: f.textarea(),
		published: f.boolean().default(false),
	}))
	.title(({ f }) => f.title)
	.access({ read: true })
	.options({ versioning: true });
```

Then build the surface and create the table:

```bash
questpie generate   # registers posts, writes the types, routes and client
questpie push       # creates the table in your dev database
```

`questpie dev` watches and regenerates as you edit. In production you write a
migration with `questpie migrate:generate` instead of pushing.

## What that file produced

| Surface    | Where it shows up                                                                      |
| ---------- | -------------------------------------------------------------------------------------- |
| Typed CRUD | `app.collections.posts`, and `ctx.collections.posts` inside hooks, routes and jobs     |
| REST       | `/posts` and its sub-routes, under your handler's base path, `/api` in the starters    |
| Admin      | `/admin/collections/posts`, a list and a form, from `@questpie/admin`                  |
| OpenAPI    | An entry in the spec and the Scalar reference at `/api/docs`, from `@questpie/openapi` |
| Client     | `client.collections.posts`, the same calls carrying the same types                     |

```ts
const { docs } = await app.collections.posts.find({
	where: { published: true },
});
const post = await app.collections.posts.create({
	title: "Hello",
	slug: "hello",
});
```

<Callout type="info" title="`find()` hands back a page, not an array">
	It resolves to `{ docs, totalDocs, totalPages, page, limit, pagingCounter,
	hasPrevPage, hasNextPage, prevPage, nextPage }`. Your rows are on `docs`.
</Callout>

## The chain

`collection(name)` returns a builder. Every method returns a new builder, and
the app calls `.build()` for you when it registers the collection. The last
column says what a second call to the same method does.

| Method                         | What it declares                            | Called twice           |
| ------------------------------ | ------------------------------------------- | ---------------------- |
| `.fields(({ f }) => …)`        | Columns, validation, admin controls         | Adds, overrides by key |
| `.title(({ f }) => f.title)`   | Which field labels a row                    | Replaces               |
| `.options(opts)`               | Timestamps, soft delete, versioning, schema | Replaces               |
| `.access(rules)`               | Who may read, create, update, delete        | Replaces               |
| `.hooks(hooks)`                | Code that runs around each operation        | Appends                |
| `.indexes(({ table }) => […])` | Drizzle indexes and constraints             | Replaces               |
| `.validation(opts)`            | Tuning for the generated Zod schemas        | Replaces               |
| `.searchable(config)`          | What the search adapter indexes             | Replaces               |
| `.upload(opts)`                | Turns the collection into a file store      | Replaces               |
| `.collaborative(config)`       | One collaborative document per row          | Replaces               |
| `.set(key, value)`             | Arbitrary state, the extension seam         | Replaces that key      |
| `.merge(other)`                | Folds in another builder of the same name   | Both apply             |

`.admin()`, `.list()`, `.form()`, `.actions()` and `.preview()` are missing from
that table on purpose. `@questpie/admin` contributes them through `.set()`, so
they exist only when that module is on, and the admin docs cover them.

### Fields add up, the rest replace

`.fields()` merges by key so you can start from a collection someone else
wrote. Redeclaring a key replaces that field cleanly, dropping its old
localized and relation state. `.hooks()` collects each stage into an array so a
module can add its own without knocking out yours. Everything else takes the
last call, which is why `.options()` wants one call with every option in it.

### Merging two builders

`.merge(other)` folds a second builder of the same name into this one. Fields,
options, relations and access are merged per key and the argument wins, hooks
from both sides concatenate, and `title`, `searchable`, `upload` and
`validation` take the argument's value wherever it has one. So merge first and
chain your own calls after it, which is how you extend a collection a module
ships. See [Modules](/docs/code/modules).

### Indexes

`.indexes()` receives the built columns and returns Drizzle index values. It is
stored as a callback and run at build time.

```ts
import { uniqueIndex } from "questpie/drizzle-pg-core";

collection("posts").indexes(({ table }) => [
	uniqueIndex("posts_slug_idx").on(table.slug),
]);
```

### Searchable

A collection is indexed only after an explicit `.searchable(…)` call.
`.searchable({})` is the safe title-only projection. Content, metadata and
facets are opt-in, because a shared index cannot enforce per-field access
inside a projected value. See [Search](/docs/infrastructure/search).

## Types come from the fields

`$infer` reads the row, insert and update shapes off the definition, so no hand
written interface can drift from the table. It is type-only, and its runtime
value is an empty object.

```ts
type Post = typeof posts.$infer.select; // the full row, including _title
type NewPost = typeof posts.$infer.insert;
type PostPatch = typeof posts.$infer.update; // Partial<NewPost>
```

Rows carry `_title`, computed on read from the field you named in `.title()`
and resolved through localization and virtuals. Name no title field and it
falls back to `id`. It labels the row in the admin, and `find({ search })`
matches against it. Narrow `columns` to a list of your own and it drops out
with everything else you did not ask for.

## Registration

Codegen scans `collections/`. The name you pass to `collection()` is required,
and it is the key, with kebab-case turned to camelCase. So
`collection("blog-posts")` registers as `app.collections.blogPosts` whatever
the file is called. Named and default exports both work, and one file may
export several collections, each becoming its own entity.

`questpie add collection my-thing` writes the file and runs codegen for you.
Run `questpie add --list` to see the other scaffolds.

<Callout type="warn" title="An unset access rule is not public">
	A rule you never wrote falls back to your app's `defaultAccess`, and past that
	to requiring a session, so anonymous reads are rejected. Write `read: true` to
	open a collection on purpose.
</Callout>

## Where each topic lives

| Topic                                               | Page                                                            |
| --------------------------------------------------- | --------------------------------------------------------------- |
| Timestamps, soft delete, versioning, publish stages | [Options](/docs/schema/collections/options)                     |
| Every CRUD method and the REST route behind it      | [Reading and writing](/docs/schema/collections/crud)            |
| Collections that store file bytes                   | [Uploads](/docs/schema/collections/uploads)                     |
| Every `f.*` type and its column                     | [Fields](/docs/schema/fields)                                   |
| Foreign keys, hydration, nested writes              | [Relations](/docs/schema/relations)                             |
| Rules that allow, deny or filter                    | [Access control](/docs/schema/access-control)                   |
| Each hook signature and the transaction lifecycle   | [Hooks](/docs/schema/hooks)                                     |
| Tuning the generated Zod schemas                    | [Validation](/docs/schema/validation)                           |
| Rows that leave the list but not the table          | [Soft delete](/docs/schema/soft-delete)                         |
| Two people editing one field                        | [Collaborative documents](/docs/schema/collaborative-documents) |
| Extending a collection a module ships               | [Modules](/docs/code/modules)                                   |
| One row instead of many                             | [Globals](/docs/schema/globals)                                 |

## Next

**[Globals](/docs/schema/globals)** is the same builder for the things your
site has exactly one of, with `get` and `update` in place of the CRUD set.
