better-convex-patterns
Patterns and best practices for better-convex library (cRPC + TanStack Query + Better Auth).
When to Use
This skill should be used when:
- •Working with Convex backend in any project
- •Implementing cRPC procedures
- •Setting up authentication with Better Auth
- •Using TanStack Query with Convex
- •Working with Ents relationships
- •Implementing RSC prefetching patterns
cRPC Procedure Builder
Basic Pattern
typescript
import { cRPC } from "better-convex/server";
const c = cRPC();
// Query with input validation
export const getUser = c
.input(z.object({ userId: z.string() }))
.output(z.object({ name: z.string(), email: z.string() }))
.query(async ({ ctx, input }) => {
return await ctx.db.get(input.userId);
});
// Mutation with middleware
export const updateUser = c
.input(z.object({ userId: z.string(), data: z.record(z.unknown()) }))
.use(authMiddleware)
.use(rateLimitMiddleware)
.mutation(async ({ ctx, input }) => {
return await ctx.db.patch(input.userId, input.data);
});
Middleware Chains
typescript
// Composable middleware
const authMiddleware = c.middleware(async ({ ctx, next }) => {
const session = await ctx.auth.getSession();
if (!session) throw new Error("Unauthorized");
return next({ ctx: { ...ctx, session } });
});
const adminMiddleware = c.middleware(async ({ ctx, next }) => {
if (!ctx.session?.user?.isAdmin) throw new Error("Forbidden");
return next({ ctx });
});
// Chain: auth -> admin -> handler
export const adminAction = c
.use(authMiddleware)
.use(adminMiddleware)
.mutation(async ({ ctx }) => { /* admin only */ });
Auth Guards
authQuery / authMutation Patterns
typescript
import { authQuery, authMutation } from "better-convex/server";
// Requires authenticated user
export const getMyProfile = authQuery({
handler: async (ctx) => {
// ctx.user is guaranteed to exist
return await ctx.db
.query("users")
.withIndex("by_user_id", (q) => q.eq("userId", ctx.user.id))
.unique();
},
});
// Mutation with auth
export const updateMyProfile = authMutation({
args: { name: v.string() },
handler: async (ctx, args) => {
const user = await ctx.db
.query("users")
.withIndex("by_user_id", (q) => q.eq("userId", ctx.user.id))
.unique();
if (!user) throw new Error("User not found");
return await ctx.db.patch(user._id, { name: args.name });
},
});
TanStack Query Integration
useQuery with Real-Time Sync
typescript
import { useQuery } from "better-convex/react";
import { api } from "../convex/_generated/api";
function UserProfile({ userId }: { userId: string }) {
// Automatically syncs with Convex real-time
const { data: user, isLoading, error } = useQuery(
api.users.getUser,
{ userId }
);
if (isLoading) return <Skeleton />;
if (error) return <Error error={error} />;
return <Profile user={user} />;
}
useMutation with Optimistic Updates
typescript
import { useMutation } from "better-convex/react";
function LikeButton({ postId }: { postId: string }) {
const like = useMutation(api.posts.like);
return (
<button
onClick={() => {
like.mutate(
{ postId },
{
onSuccess: () => toast.success("Liked!"),
onError: (e) => toast.error(e.message),
}
);
}}
disabled={like.isPending}
>
{like.isPending ? "..." : "Like"}
</button>
);
}
Ents Relationships
Defining Relationships
typescript
// convex/schema.ts
import { defineSchema, defineTable, defineEnt } from "better-convex/server";
const schema = defineSchema({
users: defineEnt({
name: v.string(),
email: v.string(),
})
.edges("posts", { ref: "authorId" })
.edges("comments", { ref: "userId" }),
posts: defineEnt({
title: v.string(),
content: v.string(),
authorId: v.id("users"),
})
.edge("author", { to: "users", field: "authorId" })
.edges("comments", { ref: "postId" }),
});
Fluent Queries
typescript
// Get user with all their posts
const userWithPosts = await ctx.table("users")
.get(userId)
.edge("posts");
// Get post with author and comments
const postWithDetails = await ctx.table("posts")
.get(postId)
.edge("author")
.edge("comments", (q) => q.order("desc").take(10));
RSC Prefetching
Fire-and-Forget Pattern
typescript
// app/users/[id]/page.tsx
import { preloadQuery } from "better-convex/nextjs";
export default async function UserPage({ params }: { params: { id: string } }) {
// Fire-and-forget: doesn't block rendering
preloadQuery(api.users.getUser, { userId: params.id });
preloadQuery(api.posts.getByUser, { userId: params.id });
return <UserPageClient userId={params.id} />;
}
Awaited Preloading
typescript
// When you need data for server rendering
export default async function UserPage({ params }: { params: { id: string } }) {
const user = await fetchQuery(api.users.getUser, { userId: params.id });
return (
<>
<title>{user.name}</title>
<UserPageClient userId={params.id} initialUser={user} />
</>
);
}
Common Patterns
Pagination
typescript
export const listPosts = c
.input(z.object({
cursor: z.string().optional(),
limit: z.number().default(20),
}))
.query(async ({ ctx, input }) => {
const posts = await ctx.db
.query("posts")
.order("desc")
.paginate({ cursor: input.cursor, numItems: input.limit });
return posts;
});
Soft Delete
typescript
export const deletePost = authMutation({
args: { postId: v.id("posts") },
handler: async (ctx, args) => {
await ctx.db.patch(args.postId, {
deletedAt: Date.now(),
});
},
});
// Query excludes soft-deleted
export const listPosts = c.query(async ({ ctx }) => {
return await ctx.db
.query("posts")
.filter((q) => q.eq(q.field("deletedAt"), undefined))
.collect();
});