# Your first collection (/docs/learn/first-collection)

---
title: Your first collection
description: One collection file becomes a database table, a set of REST routes, typed client methods and a screen in the admin.
kind: learn
package: questpie
---

This page adds a `products` collection to an app you already have running. You
write one file, run three commands, then reach the same table from three places.

## Adding the file

A **collection** is one database table plus the rules around it. Codegen finds it
by its location, so there is no central registry to edit.

To add one, run `questpie add` from the project root. It writes the file and runs
codegen for you:

```bash
bun questpie add collection products
```

That creates `src/questpie/server/collections/products.ts` with a single `title`
field. Replace it with the fields a product actually has:

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

export const products = collection("products")
	.fields(({ f }) => ({
		name: f.text(255).label("Name").required(),
		description: f.textarea().label("Description"),
		priceCents: f.number().label("Price in cents").required(),
		status: f
			.select([
				{ value: "draft", label: "Draft" },
				{ value: "live", label: "Live" },
			])
			.label("Status")
			.default("draft"),
	}))
	.title(({ f }) => f.name);
```

`.title()` tells the admin which field to print as the label of a row. Every
collection also gets `id`, `createdAt` and `updatedAt`. You never declare those.

## Picking field types

`f` is the field factory. These are the types you reach for first, all of them
from `questpie` itself with no module to enable.

| Field                 | Column         | Use it for                                                                       |
| --------------------- | -------------- | -------------------------------------------------------------------------------- |
| `f.text(255)`         | `varchar(255)` | Names, slugs, short strings.                                                     |
| `f.textarea()`        | `text`         | Long plain text, no length cap.                                                  |
| `f.number()`          | `integer`      | Counts and cents. Pass `{ mode: "decimal", precision: 10, scale: 2 }` for money. |
| `f.boolean()`         | `boolean`      | Flags.                                                                           |
| `f.select([...])`     | `varchar`      | A fixed set of values. They become a TypeScript union.                           |
| `f.relation("users")` | foreign key    | A link to another collection.                                                    |

<Callout type="info" title="`f.richText()` is not core">
	Rich text is a field type contributed by `@questpie/admin`. It appears on `f`
	only when that module is enabled. A headless project uses `f.textarea()`.
</Callout>

## Declaring who can read it

**Access rules** sit on the collection, next to the fields. A rule is `true`,
`false`, or a function returning one of those or a `where` clause that filters
rows.

Nothing you have written so far is public. With no rule and no app-level
`defaultAccess`, QUESTPIE requires an authenticated session and denies anonymous
requests. To open reads on live products while keeping writes to signed-in users,
chain `.access()`:

```ts title="src/questpie/server/collections/products.ts"
export const products = collection("products")
	.fields(({ f }) => ({
		// ...
	}))
	.title(({ f }) => f.name)
	.access({
		read: () => ({ status: "live" }),
		create: ({ session }) => !!session,
		update: ({ session }) => !!session,
		delete: false,
	});
```

Returning an object from `read` filters instead of denying, so anonymous callers
get the live rows and never see the drafts. You declare that once, and every
surface below obeys it.

## Generating and pushing

`questpie generate` reads the collections directory and rewrites
`src/questpie/server/.generated/`. `questpie add` ran it once already. Run it
again after every hand edit, then create the columns in your local database:

```bash
bun run questpie:generate
bun run db:push
```

<Callout type="warn" title="`push` is for local development">
	`questpie push` diffs your schema straight onto the database and bypasses
	migration history. `--force` acknowledges the warning, it does not make the
	command safe. For anything deployed, run `questpie migrate:create`, commit the
	migration, then apply it with `questpie migrate`.
</Callout>

## Opening it in the admin

The **admin** is `@questpie/admin`, a module you enable. It is not core.
`create-questpie` turns it on by default for TanStack Start and Next. Hono and
Elysia have no render layer, so the module is rejected there and those projects
stop at the API and the typed client.

Start the dev server and open `http://localhost:3000/admin/collections/products`.
The list uses the field you passed to `.title()`. The form picks a control per
field type, so `status` is a select holding your two options and `description` a
textarea.

That URL works before the sidebar does, because `@questpie/admin` routes by
collection name rather than by sidebar membership. The nav is a separate step.
The starter lists its sidebar items explicitly, and an explicit list turns off
the append of unlisted collections. Add one entry to get `products` in there:

```ts title="src/questpie/server/config/admin.ts"
{ sectionId: "main", type: "collection", collection: "products" },
```

## Calling it from the client

`createClient` from `questpie/client` is core, and typed from the same generated
`AppConfig`, so `products` exists on it as soon as codegen has run:

```ts
import { client } from "@/lib/client";

const { docs, totalDocs } = await client.collections.products.find({
	where: { status: "live" },
	orderBy: { createdAt: "desc" },
	limit: 10,
});

const created = await client.collections.products.create({
	name: "Cast iron pan",
	priceCents: 4900,
});
```

`find()` resolves to a paginated envelope carrying `docs`, `totalDocs`, `page`
and `hasNextPage`. `create()` resolves to the row.

## Hitting the REST route

The same collection is a set of routes under your handler's base path, `/api` in
the starter. These five carry the CRUD:

```bash
GET    /api/products
GET    /api/products/:id
POST   /api/products
PATCH  /api/products/:id
DELETE /api/products/:id
```

Query options are bracketed query parameters. The `read` rule still applies, so
this needs no session and returns live products only:

```bash
curl "http://localhost:3000/api/products?limit=5&orderBy[createdAt]=desc"
```

## Next

**[Run work in the background](/docs/learn/first-job)** takes the slow half of a
request off the request.
