Modeling Related Data in Appwrite Without Regretting It Later
Appwrite relationships resolve server-side, but filters don't cross them. How to model related collections, denormalize the right fields, and paginate with cursors instead of offsets.
25 Aug 2025, 03:39 UTC

Your Appwrite project starts clean: an articles collection, an authors collection, and a many-to-one relationship linking them. Then the requirements arrive. "Show only articles by authors in the user's team." "Sort the feed by author reputation." "Infinite scroll the list." Each of these quietly collides with how Appwrite relationships and pagination actually work, and the teams that hit these limits late are the ones who modeled first and read the constraints second.
The thesis: Appwrite's relationship attributes are genuinely useful, but they reward a specific design discipline — denormalize what you filter on, paginate with cursors, and treat permissions as part of your data model, not an afterthought.
Relationships resolve server-side, but filters don't follow them
When you declare a many-to-one relationship from articles to authors, Appwrite resolves the related author document when you fetch an article. No client-side join, no second request. That's the good part.
The constraint: query filters and ordering operate on the parent document's own attributes. You generally cannot say "give me articles where author.teamId equals X" in a single query. If your access patterns filter or sort by a related field, that field needs to live on the parent document too — duplicated, kept in sync by your write path (often a function trigger on the author collection).
This feels like a step backward if you're coming from a relational database, but it's a deliberate trade: predictable query performance in exchange for giving up ad-hoc joins. The practical rule is to list your screens and their filters before creating collections. Every filter that crosses a relationship boundary becomes a denormalized attribute on the parent.
A worked example: articles, authors, and infinite scroll
Say your feed screen shows articles with author names, sorted newest first, loading 25 at a time. The model:
articles: title, body, publishedAt, plus a many-to-one relationshipauthorpointing atauthors, plus a denormalizedauthorNamestring attribute for display and filtering.authors: name, bio, teamId.
Fetching the first page from the Web SDK (run in your client app; the user needs read permission on both collections):
const page1 = await databases.listDocuments(
DATABASE_ID,
'articles',
[
Query.orderDesc('publishedAt'),
Query.orderDesc('$id'), // tiebreaker
Query.limit(25),
]
);The next page uses a cursor, not an offset:
const last = page1.documents[page1.documents.length - 1];
const page2 = await databases.listDocuments(
DATABASE_ID,
'articles',
[
Query.orderDesc('publishedAt'),
Query.orderDesc('$id'),
Query.cursorAfter(last.$id),
Query.limit(25),
]
);Two things in that snippet matter more than they look. First, cursorAfter rather than offset: with offset pagination, a new article inserted while the user scrolls shifts every subsequent page, so the user sees a duplicate or silently skips a post. Cursor pagination anchors to a specific document, so inserts before the cursor don't disturb the pages after it. Second, the $id tiebreaker: publishedAt is not unique. Two articles with the same timestamp can swap positions between requests, and a cursor built on an ambiguous sort order produces inconsistent pages. Always end your sort chain with a unique, immutable field.
Permissions silently shape your results
Appwrite enforces permissions at the document level, and relationship resolution respects them. If a user can read an article but not its author document, the query succeeds — the related author is simply omitted or reduced in the response. No error, no warning. If you build the UI assuming the author is always present, you'll ship a screen that intermittently renders blank bylines for some users and you'll debug it as a data problem when it's actually an access-control problem.
Design around it deliberately: either grant read on authors to anyone who can read articles (common for public bylines), or make the UI tolerate a missing relation and fall back to the denormalized authorName. This is another argument for denormalizing display fields — it makes the permission model survivable.
The trade-offs you accept
Relationship loading adds payload size for every resolved document, and deep nesting multiplies it. Eager-loading a tree of relations for a list screen is an easy way to turn a 25-row feed into a multi-megabyte response. Keep list queries shallow and fetch deeper relations only on detail screens. Denormalization also means your write path owns consistency: if an author renames, something must update authorName on their articles, and until it runs, the data is stale.
One caution before you copy any of this into production: relationship behavior, query method names, and depth limits have shifted across Appwrite releases, and self-hosted instances can differ from Cloud. Check the docs for your deployed server version, then verify empirically in a throwaway project: insert documents mid-pagination and confirm cursor pages stay stable while offset pages drift, and fetch a relation with a user who lacks read access on the related collection to see exactly what your API returns. Ten minutes of that testing will tell you more than any tutorial — including this one.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.