Database Queries
Database queries return database blocks (or records)1,
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
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
Dateor ISO8601 strings; they are converted under the hood. multi_selectcontains/does_not_containaccept 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 untilhasMoreis 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 ornull.
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
fieldsmap 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.
-
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. ↩
Last updated: