AgentSkillsCN

Better Convex Patterns

Better Convex 模式

SKILL.md

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();
});

Resources