#Convex
37 messages · Page 1 of 1 (latest)
Hi @weary terrace. I'm kapa.ai, your friendly support bot. I will try to answer your question by searching through the knowledge base. :wave:
For posts with comments, likes, bookmarks, and per-user state, there are two separate questions:
- How to split the data model
- How to split the Convex API / code
Below is what’s recommended in the docs and community threads.
1. Data modeling: tables & relationships
Convex encourages using separate tables for logical object types and linking them with Ids instead of nesting everything in one big document.[Other recommendations; Document IDs]
A common split for your case:
postscommentslikesbookmarks
Example relationships:
// post
await ctx.db.insert("posts", {
authorId: user._id,
title,
body,
});
// comment
await ctx.db.insert("comments", {
postId, // Id<"posts">
authorId, // Id<"users">
body,
});
// like
await ctx.db.insert("likes", {
postId, // Id<"posts">
userId, // Id<"users">
});
// bookmark
await ctx.db.insert("bookmarks", {
postId, // Id<"posts">
userId, // Id<"users">
});
This matches the guidance to separate logical types into tables and use Ids as references.[Other recommendations; Document IDs]
For counts (like total likes/bookmarks per post), Convex recommends either:
- Keeping a denormalized counter on
postsupdated in your mutations, or - Using the Convex Aggregate component for more complex aggregates.[Why no SELECT/COUNT]
2. API design & code organization
a. Separate API layer vs model layer
Best practice is:
- Put most logic in plain TypeScript helper functions under something like
convex/model/. - Keep
query,mutation,actionwrappers very thin and focused on validation/auth.[Use helper functions; Example 3]
Pattern:
convex/
model/
posts.ts // helpers: loadPostWithCounts, ensureCanViewPost, etc.
comments.ts
reactions.ts // likes + bookmarks helpers
posts.ts // thin API wrappers
comments.ts
reactions.ts
Example from docs: helpers in convex/model/... implement logic (auth checks, loading related docs), while the public query/mutation just forwards to them.[Example 3]
This separation also scales better in large codebases and matches what’s suggested in Discord about having a “db/model layer” and an “api layer” for feature code.[Larger codebase thread]
b. Public vs internal functions
For jobs (ctx.scheduler, crons) and ctx.runQuery/Mutation/Action, only call internal.* functions, and keep public api.* as your external surface with stricter validation/auth.[Only schedule internal]
Pattern:
posts.tsexports:getPostWithMeta(public query – validates auth, calls model helper)likePost/bookmarkPost(public mutations)likePostInternal/bookmarkPostInternal(internalMutation for jobs, if needed)
c. “Post with user state” API shape
Typically you want a single query per view:
// posts.ts (public API)
export const getPostWithUserState = query({
args: { postId: v.id("posts") },
handler: async (ctx, { postId }) => {
return await PostsModel.getPostWithUserState(ctx, { postId });
},
});
In convex/model/posts.ts:
- Load the post
- Load denormalized counts from
posts(if you store them) - Or compute counts via
likes/bookmarksin a bounded way:- Use indexes and limits or pagination; avoid unbounded
.collect()when tables may grow large.[Queries that scale; Best-practices database]
- Use indexes and limits or pagination; avoid unbounded
- Check if the current user has a like/bookmark for that post (small, indexed queries).
Return a combined object like:
{
post,
likeCount,
bookmarkCount,
viewerHasLiked: boolean,
viewerHasBookmarked: boolean,
}
Then the frontend can just call one Convex query per screen, following the “use a query for nearly every app read” guidance.[Zen of Convex]
3. Performance notes
- For lists of posts (feeds), paginate or at least
.take(N); avoid unbounded.collect().[Queries that scale; Best-practices db] - Keep each query/mutation working on < a few hundred records and finishing fast.[Zen of Convex]
- If you store huge text or blobs on posts, consider splitting into a
postBodies/bigTableto reduce read size, as suggested for large fields.[No SELECT/COUNT solutions]
If you describe a specific UI (e.g. “post detail screen with 50 comments and infinite scroll”), I can map that to a concrete set of query/mutation signatures within this pattern.
Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:
@weary terrace Kapa.ai is still learning and improving, please let me know how I did by reacting below.
@vivid lion what about caching?
Let me check the knowledge base for you again. One moment! :books:
Convex already gives you a lot “for free”; the main decisions are about what you read inside each query so caching is effective and not over‑invalidated.
1. How Convex caching works (for your posts API)
- Every
queryis:- Cached by (function name, args, auth/session).[Queries caching]
- Reactive: when any document it reads changes, Convex invalidates and recomputes it.[Queries that scale; Realtime]
- In authenticated apps, that cache is effectively per user/session if the query reads
ctx.db.auth. Shared caching between users only happens when:- The query does not read auth and
- It’s invoked via
ctx.runQuery(e.g. from an action).[Caching woes thread]
So for your “post with comments/likes/bookmarks + user state”:
- A query like
getPostWithUserState(postId)that:- Reads the post + global counts
- Reads viewer-specific like/bookmark rows
will be cached per viewer, but that’s usually fine.
2. Structuring data to get good caching
The key is: avoid queries depending on noisy / frequently updated fields unless they actually need them, because that causes constant invalidation.[Queries that scale; Solution example]
Patterns you can reuse:
-
Split frequently-changing state into separate tables
Example from heartbeats:
-
Instead of
users.lastSeencausing every user-related query to invalidate, they movedlastSeento a separateheartbeatstable and only queries that care about online status read it.[Solution example]Applied to you:
- Keep relatively stable data (title, body, author) on
posts. - Put very chatty data (e.g. presence, read receipts, fast-changing metrics) into separate tables so only views that need them subscribe to them.
- Keep relatively stable data (title, body, author) on
-
Denormalized counts
For like/bookmark counts, instead of counting on every query:
- Maintain
likesCount/bookmarksCountonpostsand update those in like/bookmark mutations.[No SELECT/COUNT solutions] - Then your post/feed queries just read one
postsdocument each and get cheap cache hits.
For more complex leaderboards or grouped counts, use the Aggregate component (pre-computed counts/sums in a denormalized structure).[Aggregate component]
- Maintain
-
Pagination and limits
Don’t
.collect()all comments/likes for a post; paginate or limit:- Keeps each query under “a few hundred records” and <100ms, which is the recommended shape for reactive queries.[Zen of Convex; Queries that scale]
- Reduces the set of documents each query depends on, so invalidation is more targeted.
3. When should you worry about caching?
Convex’s philosophy: don’t design around caching first.
- “Don’t think about caching until you’ve proven you have no other choice.”[What’s a dev to do?]
- Because queries are deterministic, Convex can cache and invalidate automatically; your main job is to:
- Keep queries/mutations small & fast.
- Structure data so that reactive invalidation is meaningful (as above).[Zen of Convex; Platform you need – caching]
If, later, you do find a heavy aggregation that’s recomputed too often:
- Precompute it via a mutation/action on a schedule and store the result in a summary/aggregate table; queries then just read that table. This “materialized view” pattern is explicitly recommended for expensive time‑series aggregations.[Caching expensive query thread; Representation section]
4. Client-side query caching helpers
For UI flows like feeds/infinite scroll (posts):
- Convex already caches query results on the server, but navigation can trigger fresh subscriptions.
- If you want “stickier” client-side caching across navigation, use the
ConvexQueryCacheProviderhelper, which keeps previous query results in the client (e.g. infinite feeds, chats).[Magic caching; useInfinite/usePaginated thread]
If you describe your main views (e.g. “feed of posts with like counts and whether I liked them”), I can suggest concrete query signatures and where to place per-user vs shared data so caching and invalidation work in your favor.
Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:
@weary terrace Kapa.ai is still learning and improving, please let me know how I did by reacting below.
@vivid lion my main view is a feed with likes, comments, bookmarks and only for authenticated users
Let me check the knowledge base for you again. One moment! :books:
For an authenticated-only feed with likes, comments, and bookmarks, you want:
- One main feed query per user.
- Data modeled in separate tables (posts, comments, likes, bookmarks).
- Queries kept small, indexed, and paginated so Convex’s realtime caching works well.[Other recommendations; Queries that scale]
Below is how to structure this in terms of API shape and caching behavior.
1. Data model (tables)
Use separate tables and link with Ids, as recommended.[Other recommendations]
posts– core post data (+ denormalized counters).comments– each comment withpostId.likes–(postId, userId)rows.bookmarks–(postId, userId)rows.
Keep counts (likes/comments/bookmarks) denormalized on posts and maintain them in mutations, instead of counting every time.[Why no SELECT/COUNT]
2. Main feed query: what it should return
Have one query like feed.getForCurrentUser that:
- Checks auth with
ctx.auth.getUserIdentity()and throws if unauthenticated.[Better Auth authorization] - Returns a page of posts (paginated) with:
- Core post fields.
- Denormalized counts (
likesCount,commentsCount,bookmarksCount). - Booleans for this viewer:
viewerHasLiked,viewerHasBookmarked.
You can compute viewerHasLiked/viewerHasBookmarked by querying likes/bookmarks for this user and post; just make sure those queries are bounded (index + limit) so the feed query stays under “a few hundred records.”[Zen of Convex; Queries that scale]
Keep the per-user state separate from the post document so only the views that care about it read those rows, which leads to more targeted invalidation.[Queries that scale – caching]
3. Caching & realtime for this feed
Convex queries are:
- Cached and reactive – whenever any document they read changes, the query is rerun and the client updates.[Realtime; Queries caching]
- For authenticated users, the cache key effectively includes auth, so
feed.getForCurrentUseris cached per user.
For this feed:
- When someone likes/bookmarks/comments on a post:
- Your like/bookmark/comment mutation should update:
- The
likes/bookmarks/commentstable row. - The denormalized counter on the corresponding
postsrow.[Why no SELECT/COUNT]
- The
- Any user subscribed to
feed.getForCurrentUserthat reads those documents will get updated automatically, without you managing cache invalidation.[Presence caching section]
- Your like/bookmark/comment mutation should update:
To keep this cheap:
- Paginate the feed instead of
.collect()all posts.[Queries that scale; Database best practices] - Make sure queries read only what’s needed (don’t attach noisy, fast-changing fields to big shared documents if they’re not necessary for the feed).[Queries that scale – caching]
4. Frontend usage (authenticated-only feed)
On the client:
- Use Convex auth state (
useConvexAuth,<Authenticated>/<Unauthenticated>) to only run the feed query when the user is logged in.[Better Auth authorization; Auth best practices] - Optionally wrap in a helper like
useAuthenticatedQueryWithStatusso the query is automatically skipped untilisAuthenticatedis true.[Auth query hook]
Example pattern from the docs (simplified):
// Client
const { isLoading, isAuthenticated } = useConvexAuth();
const feedPage = useQuery(
api.feed.getForCurrentUser,
isAuthenticated ? { cursor } : "skip"
);
This ensures:
- No unauthenticated access.
- No unnecessary backend calls before Convex has validated the user’s token.[Better Auth authorization]
If you want, describe how you want pagination to work (infinite scroll vs “Load more”), and I can outline how to shape the feed query itself (arguments and return type) within these constraints.
Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:
@weary terrace Kapa.ai is still learning and improving, please let me know how I did by reacting below.
@vivid lion I split the queries for my posts. UserInteractions (Liked, Bookmarked), comments, post
Let me check the knowledge base for you again. One moment! :books:
Splitting them is reasonable; the main guideline is how you split and how the client calls them.
From the docs/Stack posts, the recommended pattern is:
-
One primary query per view
For your feed view, aim for a single “main” query that returns everything the UI needs for each post, rather than always composing multiple queries on the client. [Zen of Convex]
But that main query can internally call helpers that hit different tables. -
Factor logic into helpers / “model layer”
Put the post, comments, and user‑interaction loading logic into reusable helper functions (e.g.Posts.loadFeedPosts,Interactions.loadForUserAndPosts,Comments.loadCountsForPosts) inconvex/model/*, and have thin query wrappers that call them. [Best practices – helper functions]
This keeps things maintainable even if you later change how you aggregate data. -
When to keep them separate vs combine
- Keep separate queries when:
- Different UI surfaces need different subsets (e.g. a lightweight feed that only needs post + counts, and a detail page that needs full comments).
In that case you might have:feed.getPostsPage(posts + counts + viewer like/bookmark flags)comments.getForPostPaginatedinteractions.getForPost(only on detail view)
- Combine into one query when:
- The same view always needs all three (post + user interactions + some comments), and the amount of data per post is bounded (a page of posts, and maybe only first N comments per post). This matches the style in the relationship helpers article where they join posts, comments, and categories in a single query with helper functions. [Joining data with functions]
- Different UI surfaces need different subsets (e.g. a lightweight feed that only needs post + counts, and a detail page that needs full comments).
-
Caching / invalidation tradeoff
- If you put user interactions and comments into the same query as posts, that query’s result will be invalidated whenever any of those underlying docs change (like or new comment), which is usually what you want for a feed. [Queries that scale – caching]
- If some parts change much more frequently than others (e.g. presence, typing indicators), consider separate tables + separate queries to avoid over‑invalidating the main feed. This is the same idea as splitting heartbeats from users to reduce cache churn. [Heartbeat example]
-
Keep each query small & paginated
Whatever split you choose, each query should:
- Use pagination /
takefor feeds and comments, notcollect()over unbounded sets. [Queries that scale – pagination; Best practices – only use collect with small results]- Touch at most a few hundred documents and finish quickly. [Zen of Convex]
Practical suggestion for your case:
-
For the feed screen, expose one query like
feed.getPagethat:- Returns a page of posts.
- Includes denormalized counts on posts (likes/comments/bookmarks).
- Includes
viewerHasLiked/viewerHasBookmarkedflags for each post (via helpers hittinglikes/bookmarkstables). [Why no SELECT/COUNT – denormalized counters; Joining data via helpers]
-
For post detail, you can either:
- Reuse the same
feed.getPageentry plus a separatecomments.getForPostPaginated, or - Have a dedicated
posts.getDetailthat assembles post + interactions + first page of comments.
- Reuse the same
Your current separation (posts / interactions / comments) is fine; just try to avoid making the client manually orchestrate 3–4 queries for the same view if you can easily compose them server‑side into one query per screen.
Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:
@weary terrace Kapa.ai is still learning and improving, please let me know how I did by reacting below.
@vivid lion so if I just use one query for everything is it better?
Let me check the knowledge base for you again. One moment! :books:
Using one query per view (per screen) is generally the best practice in Convex, as long as that query stays small and efficient.
What Convex recommends
- Convex encourages you to “use a query for nearly every app read” and keep those queries light & fast (working with at most a few hundred records, finishing quickly). [Zen of Convex]
- You’re expected to join related data in code (posts + comments + categories, etc.) inside a single query using helper functions, instead of many little queries from the client. [Joining data with functions]
- For large result sets (feeds, comments), you should paginate or limit, not
.collect()everything. [Queries that scale – pagination; Only use collect with small results]
So for your feed of posts including:
- post data
- counts (likes/comments/bookmarks)
- per-user interactions (liked/bookmarked)
- maybe a small number of comments per post
…it’s usually better to expose one query like feed.getPage that returns everything the feed UI needs, rather than making the client call 3–4 separate queries and merge them.
When “one query” is not better
You should split into multiple queries when:
- The combined data would be too big or exceed read limits (e.g. all comments for many posts at once) → then paginate separately. [Queries that scale – pagination]
- Some data changes much more frequently than the rest and would cause unnecessary cache invalidation for the whole view (similar to splitting heartbeats from users). [Queries that scale – caching]
Practical takeaway for your feed
For your main authenticated feed:
- Yes: prefer a single paginated query that returns posts + counts +
viewerHasLiked/viewerHasBookmarked. - Maybe separate: full comment threads, or very fast‑changing data, into their own paginated queries.
If you describe exactly what each feed item needs to render (e.g. “first 2 comments only” vs “all comments”), I can say more concretely whether it should go into that single query or be a separate one.
Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:
@weary terrace Kapa.ai is still learning and improving, please let me know how I did by reacting below.
@vivid lion how should i get the like count?
Let me check the knowledge base for you again. One moment! :books:
For a like count on posts, Convex’s recommended pattern is to denormalize the count instead of running COUNT-style queries every time.
There are three main options in the docs:
1. Store a likesCount field on posts (simple & common)
Add a numeric field and update it in your like/unlike mutations:
// schema.ts
posts: defineTable({
body: v.string(),
likesCount: v.number(),
}),
likes: defineTable({
postId: v.id("posts"),
userId: v.id("users"),
}),
In your mutation that inserts a like:
export const likePost = mutation({
args: {
postId: v.id("posts"),
userId: v.id("users"),
},
handler: async (ctx, args) => {
await ctx.db.insert("likes", {
postId: args.postId,
userId: args.userId,
});
const post = await ctx.db.get(args.postId);
if (!post) throw new Error("Post not found");
await ctx.db.patch(args.postId, { likesCount: post.likesCount + 1 });
},
});
This is exactly the pattern suggested for “Why doesn’t Convex have COUNT?” — keep a count on the main document and update it whenever you insert/delete the related rows, so reads are cheap.[Why no COUNT]
You then just read post.likesCount in your feed query.
2. Use a trigger to keep a count table in sync
If you don’t want to embed the count on posts, you can have a separate postLikeCounts table and maintain it via a trigger on the likes table:
- The docs’ trigger example shows keeping a
userCountdocument updated whenusersis inserted/deleted.[Denormalizing a count]
Pattern:
triggers.register("likes", async (ctx, change) => {
const { postId } = change.newDoc ?? change.oldDoc!;
const countDoc = (await ctx.db
.query("postLikeCounts")
.withIndex("by_post", q => q.eq("postId", postId))
.unique())!;
if (change.operation === "insert") {
await ctx.db.patch(countDoc._id, { count: countDoc.count + 1 });
} else if (change.operation === "delete") {
await ctx.db.patch(countDoc._id, { count: countDoc.count - 1 });
}
});
Then your feed query joins posts with postLikeCounts.count.
3. Use the Sharded Counter / Aggregate components (for high write throughput)
For very hot like counts where many users may like in parallel:
-
Sharded Counter: efficient high‑throughput counters, using many shards per key.[Sharded Counter; Sharding the counter]
- You’d have a counter per
postId, increment in like mutation, and read viacountin your query.
- You’d have a counter per
-
Aggregate component: denormalized counts/sums over keys, supports counts and more complex aggregations.[Aggregate – what for]
Trusted staff explicitly suggest Sharded Counter or Aggregate as options for “high efficiency counting.”[Discord filtering thread]
What to choose for your feed
For a typical social-style feed:
- Use option 1:
likesCountonpostsupdated in like/unlike mutations. - If a single post may get very high like write throughput (and you care about write latency), consider swapping the field out for a Sharded Counter keyed by
postId.