Glass Box Terrarium, explained from the inside

Last updated · Latest source on GitHub ↗

Draft: this page sits outside the guide's focus.

Nuxt ContentLesson

queryCollection patterns

Once content sits in a database, every page asks it something: one Document by its address, a list in date order, how many there are. Nuxt Content answers all of these with one query builder, and a few habits decide whether a page stays typed, small and inside its own Space.

Know queryCollection already? Skip to In Terrarium.

Outside the box · General

The concept

queryCollection('<name>') starts a query on one collection from content.config.ts. You chain methods that become SQL, then run it:

  • path(p) matches a page collection's generated path, the usual way to find the page for a route;
  • where(field, operator, value) filters, with SQL operators such as =, <, IN, LIKE and IS NOT NULL; andWhere and orWhere group conditions;
  • order(field, 'ASC' | 'DESC'), limit(n) and skip(n) sort and page;
  • select(...fields) returns only the fields you name;
  • all() returns every match, first() the first or null, and count() the number of matches.

The builder is typed from the collection's schema, so field names and results are checked. In a page you run it inside useAsyncData, so the server renders the result and the browser reuses it. On the server, in an API route, you pass the request event first: queryCollection(event, 'docs').

Full referenceNuxt Content docs: queryCollection

Looking inside

In Terrarium

A page never writes a collection name. Every name is a generated key, one per , and , and a page gets its Space's keys from useSpace (or, across Tenants, from the catalog): pagesKey for the routed pages Collection, and collections.<name> for each other one. Passing the Tenant as a literal, useSpace('journal'), types each key as that Tenant's own keys, so every query keeps the Journal's field types with no cast. The Journal's Space landing shows the usual shape: one useAsyncData, keyed by the route, holding every query the page needs.

layers/journal/app/pages/t/journal/[space]/index.vue ↗33–41as of
33const { data } = await useAsyncData(route.path, async () => {
34  const pages = await queryCollection(pagesKey).all()
35  const skills = await queryCollection(collections.skills).all()
36  // Newest-first, ordered in SQL rather than re-sorted client-side.
37  const sessions = await queryCollection(collections.sessions).order('endedAt', 'DESC').all()
38  const ledgerSize = await queryCollection(collections.frictions).count()
39  const routineCount = await queryCollection(collections.routines).count()
40  return { pages, skills, sessions, ledgerSize, routineCount }
41})

Sorting and counting happen in SQL. A Journal Document page filters and trims the same way, asking only for the pages in the onramp tour and only the four fields the tour needs:

layers/journal/app/pages/t/journal/[space]/[...slug].vue ↗38–41as of
38        await queryCollection(pagesKey)
39          .where('onramp', 'IS NOT NULL')
40          .select('path', 'onramp', 'onrampLabel', 'onrampBlurb')
41          .all(),

The rest follow from the same few rules:

  • One Document by address: queryCollection(pagesKey).path(path).first(), where path is the Space-relative path useSpace returns. first() gives null for no match, and the page sets a 404 status itself.
  • Several Spaces of one Tenant, as on a Tenant's front page: resolve each Space with resolveSpaceRoute and query its key, then merge the results in code. No query spans two keys.
  • Nested fields: a field that holds an object is stored as JSON text, so Tinkerfund's search in layers/tinkerfund/app/composables/search.ts matches inside one with LIKE on that text.

Why it's built this way

The keys a request may read, and why only pages is routed: ADR-0006 (docs/adr/0006-runtime-routing-path-prefix.md).

The routing map the keys come from: ADR-0014 (docs/adr/0014-build-time-virtual-routing-module.md).

What a page's useAsyncData should return: ADR-0028 (docs/adr/0028-navigation-reads-server-payloads.md).

What to watch for

  • A key typed as string loses its fields. queryCollection is generic in its key, so a plain string widens the result to every collection's item type. Take keys from useSpace with a literal Tenant name, or from resolveSpaceRoute, as shared/routing.ts explains.
  • all() on a page collection returns whole Documents, parsed body included. A list that shows titles needs select, or every body travels to the browser with the page.
  • A query outside useAsyncData runs again in the browser. Its result isn't in the page's payload, so on the first load and on navigation it loads the browser database, as Content database explains.

Try it in your project

Write a list page that asks the database for exactly what it shows, then compare the size of the __NUXT_DATA__ script in the page's view-source with a plain all():

<script setup lang="ts">
const { data: posts } = await useAsyncData('posts', () =>
  queryCollection('blog')
    .order('date', 'DESC')
    .select('path', 'title', 'date')
    .limit(10)
    .all(),
)
</script>

Where next

Walkthroughs through this Lesson

Last updated · Latest source on GitHub ↗

Something went wrong

This content couldn’t be loaded. Reloading the page usually fixes it.

Technical details
route: /t/glassbox/drafts/nuxt-content/query-collection-patterns