# Database Queries

Database queries return database blocks (or records)[^1], 

[^1]: I find the term *record* much more familiar when talking about databases. Notion has a strong focus on databases including some common features that DB engines have like relationships, or unique IDs. For this reason, we decided to follow this naming convention where we separate what notion calls a Block, into Record block and Content block. You can read more in design decissions.



This guide covers fetching database records through `NotionCMS.query()` and the `QueryBuilder`.

## Query basics

### Fetch records

```
typescript
const posts = await notion.query("blog").all()
```

By default, the `query` method returns raw records. The only modification that’s done on the records is the URLs for resources if you have configured local or remote caching. see [Configuration](https://www.notion.so/2812a789c1fc805593d7e7892933d662) 

### Fetch simple & advanced records

```
typescript
const posts = await notion
  .query("blog", { recordType: "simple" })
  .limit(10)
```

```
typescript
const posts = await notion
  .query("blog", { recordType: "advanced" })
  .limit(10)
```

These calls return strongly typed, simple and advanced records for more specific use cases.

## Record layers

| Layer | Description | Helper functions |
| --- | --- | --- |
| `simple` (default) | Flat JS objects with scalar-friendly values. Dates become `Date`, relations become ID arrays, files return `{ name, url }`. | `convertRecordToSimple`, `convertRecords` |
| `advanced` | Rich metadata: annotations, select colours, people profiles, rollup/function detail, file expiry. | `convertRecordToAdvanced`, `convertRecords` |
| `raw` | Original Notion payloads. | Not applicable |


Switch layers per query:

```
typescript
const advancedPosts = await notion
  .query("blog", { recordType: "advanced" })
  .filter("Status", "equals", "Published")
```

## Filtering

Filters are validated against registered metadata. Invalid operators throw helpful errors.

```
typescript
const posts = await notion
  .query("blog")
  .filter("Title", "contains", "Guide")
	.filter("PublishedAt", "on_or_after", new Date("2025-01-01"))
```

- Date fields accept `Date` or ISO8601 strings; they are converted under the hood.
- `multi_select` `contains`/`does_not_contain` accept a single option value.


## Sorting

```
typescript
const posts = await notion
  .query("blog")
  .sort("PublishedAt", "descending")
  .sort("Title", "ascending")
```

- Attempting to sort on an unknown field throws an error listing valid fields.


## Pagination

```
typescript
const page = await notion
  .query("blog")
  .limit(10)
  .paginate()

const nextPage = await notion
  .query("blog")
  .startAfter(page.nextCursor!)
  .limit(10)
  .paginate()
```

- `paginate(pageSize)` returns `{ results, hasMore, nextCursor }`.
- `startAfter(cursor)` resumes after a previous page.
- `all()` loops internally, fetching every page until `hasMore` is false.


## Single record helpers

```
typescript
const post = await notion
  .query("blog")
  .filter("Slug", "equals", "hello-world")
  .single()
```

- `single()` expects exactly one record; zero or many results throw errors.
- `maybeSingle()` returns the first record or `null`.


## Mixing raw IDs

If you did not register metadata, pass a Notion database ID directly:

```
typescript
const posts = await notion
  .query("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")
  .filter("Status", "equals", "Published")
```

- Filtering still works, but you lose type safety and operator validation.
- Consider manually providing a minimal `fields` map even without the generator.


## Converting after the fact

`convertRecords` transforms raw arrays obtained elsewhere:

```
typescript
import { convertRecords } from "@mikemajara/notion-cms"

const rawPages = await notion.databaseService.getAllDatabaseRecords(dbId)
const simple = await convertRecords(rawPages, "simple", {
  fileManager: notion["fileManager"] // internal, avoid in userland
})
```

Prefer using the QueryBuilder unless you have custom batching requirements.