Expert in tRPC, Drizzle ORM, and Zod for end-to-end type-safe full-stack TypeScript applications. Use when you need help with trpc fullstack.
Scanned 9/8/2026
Install to Claude Code
npx -y skills add anubhavg-icpl/vibe --skill trpc-fullstack --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Trpc Fullstack?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/anubhavg-icpl-trpc-fullstack)More formats (shields.io, HTML) on the badges page.
---
name: trpc-fullstack
description: Expert in tRPC, Drizzle ORM, and Zod for end-to-end type-safe full-stack TypeScript applications. Use when you need help with trpc fullstack.
license: CC-BY-NC-SA-4.0
metadata:
risk: unknown
source: community
kind: mode
category: backend
---
# tRPC & Type-Safe Stack Expert Mode
You are an expert in building type-safe full-stack applications using tRPC, Drizzle ORM, and Zod. You create seamless API experiences with complete type inference from database to client.
## Core Competencies
### Type-Safe Stack
- tRPC for type-safe APIs
- Drizzle ORM for type-safe database queries
- Zod for runtime validation
- End-to-end type inference
## Architecture Overview
```text
┌─────────────────────────────────────────────────────────────────┐
│ Client │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ React/Next.js/Solid │ │
│ │ ┌──────────────┐ │ │
│ │ │ trpc.useQuery│ ◄── Full TypeScript inference │ │
│ │ │ trpc.useMutation │ │
│ │ └──────┬───────┘ │ │
│ └─────────┼───────────────────────────────────────────────┘ │
│ │ Type-safe RPC calls │
└────────────┼────────────────────────────────────────────────────┘
│
┌────────────┼────────────────────────────────────────────────────┐
│ │ Server │
│ ┌─────────▼───────────────────────────────────────────────┐ │
│ │ tRPC Router │ │
│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │
│ │ │ Procedure │ │ Procedure │ │ Procedure │ │ │
│ │ │ + Zod │ │ + Zod │ │ + Zod │ │ │
│ │ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │ │
│ └────────┼───────────────┼───────────────┼────────────────┘ │
│ └───────────────┼───────────────┘ │
│ │ │
│ ┌────────────────────────▼────────────────────────────────┐ │
│ │ Drizzle ORM │ │
│ │ Type-safe queries with schema inference │ │
│ └────────────────────────┬────────────────────────────────┘ │
└───────────────────────────┼─────────────────────────────────────┘
│
┌───────────────────────────▼─────────────────────────────────────┐
│ PostgreSQL/MySQL/SQLite │
└─────────────────────────────────────────────────────────────────┘
```
## Project Setup
```bash
# Initialize project
pnpm create t3-app@latest my-app --trpc --drizzle
# Or manual setup
pnpm add @trpc/server @trpc/client @trpc/react-query @tanstack/react-query
pnpm add drizzle-orm postgres
pnpm add zod
pnpm add -D drizzle-kit @types/node typescript
```
## Drizzle Schema Definition
```typescript
// src/db/schema.ts
import {
pgTable,
uuid,
varchar,
text,
timestamp,
boolean,
integer,
pgEnum,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
// Enums
export const userRole = pgEnum("user_role", ["admin", "user", "moderator"]);
export const postStatus = pgEnum("post_status", ["draft", "published", "archived"]);
// Users table
export const users = pgTable(
"users",
{
id: uuid("id").primaryKey().defaultRandom(),
email: varchar("email", { length: 255 }).notNull().unique(),
name: varchar("name", { length: 255 }).notNull(),
role: userRole("role").default("user").notNull(),
avatarUrl: text("avatar_url"),
emailVerified: boolean("email_verified").default(false),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
},
(table) => ({
emailIdx: uniqueIndex("email_idx").on(table.email),
}),
);
// Posts table
export const posts = pgTable(
"posts",
{
id: uuid("id").primaryKey().defaultRandom(),
title: varchar("title", { length: 255 }).notNull(),
slug: varchar("slug", { length: 255 }).notNull().unique(),
content: text("content"),
excerpt: text("excerpt"),
status: postStatus("status").default("draft").notNull(),
authorId: uuid("author_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
publishedAt: timestamp("published_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
},
(table) => ({
authorIdx: index("author_idx").on(table.authorId),
statusIdx: index("status_idx").on(table.status),
slugIdx: uniqueIndex("slug_idx").on(table.slug),
}),
);
// Comments table
export const comments = pgTable(
"comments",
{
id: uuid("id").primaryKey().defaultRandom(),
content: text("content").notNull(),
postId: uuid("post_id")
.notNull()
.references(() => posts.id, { onDelete: "cascade" }),
authorId: uuid("author_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
parentId: uuid("parent_id").references((): any => comments.id),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => ({
postIdx: index("post_idx").on(table.postId),
}),
);
// Relations
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
comments: many(comments),
}));
export const postsRelations = relations(posts, ({ one, many }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
comments: many(comments),
}));
export const commentsRelations = relations(comments, ({ one, many }) => ({
post: one(posts, {
fields: [comments.postId],
references: [posts.id],
}),
author: one(users, {
fields: [comments.authorId],
references: [users.id],
}),
parent: one(comments, {
fields: [comments.parentId],
references: [comments.id],
}),
replies: many(comments),
}));
// Type exports
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;
```
## Database Connection
```typescript
// src/db/index.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
const connectionString = process.env.DATABASE_URL!;
// For queries
const queryClient = postgres(connectionString);
export const db = drizzle(queryClient, { schema });
// For migrations
const migrationClient = postgres(connectionString, { max: 1 });
export const migrationDb = drizzle(migrationClient);
```
## Zod Validation Schemas
```typescript
// src/validation/schemas.ts
import { z } from "zod";
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
import { users, posts, comments } from "@/db/schema";
// Auto-generate from Drizzle schema
export const insertUserSchema = createInsertSchema(users, {
email: z.string().email("Invalid email address"),
name: z.string().min(2, "Name must be at least 2 characters"),
});
export const selectUserSchema = createSelectSchema(users);
// Custom schemas with refinements
export const createPostSchema = z.object({
title: z.string().min(3, "Title must be at least 3 characters").max(255, "Title must be less than 255 characters"),
content: z.string().min(10, "Content must be at least 10 characters"),
excerpt: z.string().max(500).optional(),
status: z.enum(["draft", "published"]).default("draft"),
});
export const updatePostSchema = createPostSchema.partial().extend({
id: z.string().uuid(),
});
export const paginationSchema = z.object({
page: z.number().int().positive().default(1),
limit: z.number().int().min(1).max(100).default(20),
orderBy: z.enum(["createdAt", "updatedAt", "title"]).default("createdAt"),
order: z.enum(["asc", "desc"]).default("desc"),
});
export const createCommentSchema = z.object({
postId: z.string().uuid(),
content: z.string().min(1).max(2000),
parentId: z.string().uuid().optional(),
});
// Type exports
export type CreatePost = z.infer<typeof createPostSchema>;
export type UpdatePost = z.infer<typeof updatePostSchema>;
export type Pagination = z.infer<typeof paginationSchema>;
```
## tRPC Server Setup
```typescript
// src/server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import { type CreateNextContextOptions } from "@trpc/server/adapters/next";
import superjson from "superjson";
import { ZodError } from "zod";
import { db } from "@/db";
import { getServerSession } from "next-auth";
import { authOptions } from "@/auth";
// Context creation
export const createTRPCContext = async (opts: CreateNextContextOptions) => {
const session = await getServerSession(opts.req, opts.res, authOptions);
return {
db,
session,
req: opts.req,
res: opts.res,
};
};
// Initialize tRPC
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
// Export reusable parts
export const createTRPCRouter = t.router;
export const publicProcedure = t.procedure;
export const createCallerFactory = t.createCallerFactory;
// Middleware for authenticated routes
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
session: ctx.session,
user: ctx.session.user,
},
});
});
export const protectedProcedure = t.procedure.use(isAuthed);
// Admin middleware
const isAdmin = t.middleware(({ ctx, next }) => {
if (ctx.session?.user?.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN" });
}
return next({ ctx });
});
export const adminProcedure = protectedProcedure.use(isAdmin);
```
## tRPC Routers
```typescript
// src/server/routers/posts.ts
import { z } from "zod";
import { eq, desc, asc, and, ilike, sql } from "drizzle-orm";
import { createTRPCRouter, publicProcedure, protectedProcedure } from "../trpc";
import { posts, users, comments } from "@/db/schema";
import { createPostSchema, updatePostSchema, paginationSchema } from "@/validation/schemas";
import { TRPCError } from "@trpc/server";
export const postsRouter = createTRPCRouter({
// Get all posts with pagination
list: publicProcedure
.input(
paginationSchema.extend({
status: z.enum(["draft", "published", "archived"]).optional(),
search: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
const { page, limit, orderBy, order, status, search } = input;
const offset = (page - 1) * limit;
const conditions = [];
if (status) {
conditions.push(eq(posts.status, status));
}
if (search) {
conditions.push(ilike(posts.title, `%${search}%`));
}
const orderColumn = {
createdAt: posts.createdAt,
updatedAt: posts.updatedAt,
title: posts.title,
}[orderBy];
const [items, countResult] = await Promise.all([
ctx.db.query.posts.findMany({
where: conditions.length > 0 ? and(...conditions) : undefined,
with: {
author: {
columns: { id: true, name: true, avatarUrl: true },
},
comments: {
columns: { id: true },
},
},
orderBy: order === "asc" ? asc(orderColumn) : desc(orderColumn),
limit,
offset,
}),
ctx.db
.select({ count: sql<number>`count(*)` })
.from(posts)
.where(conditions.length > 0 ? and(...conditions) : undefined),
]);
return {
items: items.map((post) => ({
...post,
commentCount: post.comments.length,
})),
pagination: {
page,
limit,
total: Number(countResult[0]?.count ?? 0),
totalPages: Math.ceil(Number(countResult[0]?.count ?? 0) / limit),
},
};
}),
// Get single post by slug
bySlug: publicProcedure.input(z.object({ slug: z.string() })).query(async ({ ctx, input }) => {
const post = await ctx.db.query.posts.findFirst({
where: eq(posts.slug, input.slug),
with: {
author: true,
comments: {
with: {
author: {
columns: { id: true, name: true, avatarUrl: true },
},
},
orderBy: desc(comments.createdAt),
},
},
});
if (!post) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Post not found",
});
}
return post;
}),
// Create post (authenticated)
create: protectedProcedure.input(createPostSchema).mutation(async ({ ctx, input }) => {
const slug = input.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
const [post] = await ctx.db
.insert(posts)
.values({
...input,
slug: `${slug}-${Date.now()}`,
authorId: ctx.user.id,
publishedAt: input.status === "published" ? new Date() : null,
})
.returning();
return post;
}),
// Update post (authenticated + ownership check)
update: protectedProcedure.input(updatePostSchema).mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
const existingPost = await ctx.db.query.posts.findFirst({
where: eq(posts.id, id),
});
if (!existingPost) {
throw new TRPCError({ code: "NOT_FOUND" });
}
if (existingPost.authorId !== ctx.user.id && ctx.user.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN" });
}
const [updated] = await ctx.db
.update(posts)
.set({
...data,
updatedAt: new Date(),
publishedAt: data.status === "published" && !existingPost.publishedAt ? new Date() : existingPost.publishedAt,
})
.where(eq(posts.id, id))
.returning();
return updated;
}),
// Delete post
delete: protectedProcedure.input(z.object({ id: z.string().uuid() })).mutation(async ({ ctx, input }) => {
const post = await ctx.db.query.posts.findFirst({
where: eq(posts.id, input.id),
});
if (!post) {
throw new TRPCError({ code: "NOT_FOUND" });
}
if (post.authorId !== ctx.user.id && ctx.user.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN" });
}
await ctx.db.delete(posts).where(eq(posts.id, input.id));
return { success: true };
}),
});
```
```typescript
// src/server/routers/comments.ts
import { z } from "zod";
import { eq, desc } from "drizzle-orm";
import { createTRPCRouter, publicProcedure, protectedProcedure } from "../trpc";
import { comments } from "@/db/schema";
import { createCommentSchema } from "@/validation/schemas";
import { TRPCError } from "@trpc/server";
export const commentsRouter = createTRPCRouter({
byPost: publicProcedure.input(z.object({ postId: z.string().uuid() })).query(async ({ ctx, input }) => {
return ctx.db.query.comments.findMany({
where: eq(comments.postId, input.postId),
with: {
author: {
columns: { id: true, name: true, avatarUrl: true },
},
replies: {
with: {
author: {
columns: { id: true, name: true, avatarUrl: true },
},
},
},
},
orderBy: desc(comments.createdAt),
});
}),
create: protectedProcedure.input(createCommentSchema).mutation(async ({ ctx, input }) => {
const [comment] = await ctx.db
.insert(comments)
.values({
...input,
authorId: ctx.user.id,
})
.returning();
return comment;
}),
delete: protectedProcedure.input(z.object({ id: z.string().uuid() })).mutation(async ({ ctx, input }) => {
const comment = await ctx.db.query.comments.findFirst({
where: eq(comments.id, input.id),
});
if (!comment) {
throw new TRPCError({ code: "NOT_FOUND" });
}
if (comment.authorId !== ctx.user.id && ctx.user.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN" });
}
await ctx.db.delete(comments).where(eq(comments.id, input.id));
return { success: true };
}),
});
```
```typescript
// src/server/routers/index.ts
import { createTRPCRouter } from "../trpc";
import { postsRouter } from "./posts";
import { commentsRouter } from "./comments";
import { usersRouter } from "./users";
export const appRouter = createTRPCRouter({
posts: postsRouter,
comments: commentsRouter,
users: usersRouter,
});
export type AppRouter = typeof appRouter;
```
## Client Setup
```typescript
// src/utils/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/routers";
export const trpc = createTRPCReact<AppRouter>();
// For server-side calls
import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
import superjson from "superjson";
export const serverTrpc = createTRPCProxyClient<AppRouter>({
transformer: superjson,
links: [
httpBatchLink({
url: `${process.env.NEXT_PUBLIC_APP_URL}/api/trpc`,
}),
],
});
```
```typescript
// src/app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { useState } from 'react';
import superjson from 'superjson';
import { trpc } from '@/utils/trpc';
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 1000,
refetchOnWindowFocus: false,
},
},
}));
const [trpcClient] = useState(() =>
trpc.createClient({
transformer: superjson,
links: [
httpBatchLink({
url: '/api/trpc',
headers() {
return {
// Add auth headers if needed
};
},
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}
```
## React Components with tRPC
```typescript
// src/components/PostList.tsx
'use client';
import { useState } from 'react';
import { trpc } from '@/utils/trpc';
export function PostList() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const { data, isLoading, error } = trpc.posts.list.useQuery({
page,
limit: 10,
search: search || undefined,
status: 'published',
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<input
type="search"
placeholder="Search posts..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<ul>
{data?.items.map((post) => (
<li key={post.id}>
<h2>{post.title}</h2>
<p>By {post.author.name}</p>
<p>{post.commentCount} comments</p>
</li>
))}
</ul>
<div>
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
>
Previous
</button>
<span>Page {page} of {data?.pagination.totalPages}</span>
<button
onClick={() => setPage(p => p + 1)}
disabled={page >= (data?.pagination.totalPages ?? 1)}
>
Next
</button>
</div>
</div>
);
}
```
```typescript
// src/components/CreatePostForm.tsx
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { trpc } from '@/utils/trpc';
import { createPostSchema, type CreatePost } from '@/validation/schemas';
export function CreatePostForm() {
const utils = trpc.useUtils();
const { register, handleSubmit, formState: { errors }, reset } = useForm<CreatePost>({
resolver: zodResolver(createPostSchema),
defaultValues: {
status: 'draft',
},
});
const createPost = trpc.posts.create.useMutation({
onSuccess: () => {
// Invalidate and refetch posts list
utils.posts.list.invalidate();
reset();
},
onError: (error) => {
// Handle Zod validation errors from server
if (error.data?.zodError) {
console.error('Validation errors:', error.data.zodError);
}
},
});
const onSubmit = (data: CreatePost) => {
createPost.mutate(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="title">Title</label>
<input id="title" {...register('title')} />
{errors.title && <span>{errors.title.message}</span>}
</div>
<div>
<label htmlFor="content">Content</label>
<textarea id="content" {...register('content')} />
{errors.content && <span>{errors.content.message}</span>}
</div>
<div>
<label htmlFor="status">Status</label>
<select id="status" {...register('status')}>
<option value="draft">Draft</option>
<option value="published">Published</option>
</select>
</div>
<button type="submit" disabled={createPost.isLoading}>
{createPost.isLoading ? 'Creating...' : 'Create Post'}
</button>
{createPost.error && (
<div className="error">{createPost.error.message}</div>
)}
</form>
);
}
```
## Optimistic Updates
```typescript
// src/components/LikeButton.tsx
'use client';
import { trpc } from '@/utils/trpc';
export function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
const utils = trpc.useUtils();
const likeMutation = trpc.posts.like.useMutation({
// Optimistically update the UI
onMutate: async ({ postId }) => {
// Cancel outgoing refetches
await utils.posts.byId.cancel({ id: postId });
// Snapshot previous value
const previousPost = utils.posts.byId.getData({ id: postId });
// Optimistically update
utils.posts.byId.setData({ id: postId }, (old) => {
if (!old) return old;
return {
...old,
likes: old.likes + 1,
likedByMe: true,
};
});
return { previousPost };
},
// Rollback on error
onError: (err, { postId }, context) => {
if (context?.previousPost) {
utils.posts.byId.setData({ id: postId }, context.previousPost);
}
},
// Refetch after mutation
onSettled: (_, __, { postId }) => {
utils.posts.byId.invalidate({ id: postId });
},
});
return (
<button
onClick={() => likeMutation.mutate({ postId })}
disabled={likeMutation.isLoading}
>
❤️ {initialLikes}
</button>
);
}
```
## Server-Side Rendering (Next.js App Router)
```typescript
// src/app/posts/page.tsx
import { createCaller } from '@/server/trpc';
import { createTRPCContext } from '@/server/trpc';
import { PostList } from '@/components/PostList';
export default async function PostsPage() {
// Create server-side caller
const ctx = await createTRPCContext({ req: {} as any, res: {} as any });
const caller = createCaller(ctx);
// Prefetch data
const initialPosts = await caller.posts.list({
page: 1,
limit: 10,
status: 'published',
});
return (
<div>
<h1>Posts</h1>
{/* Pass initial data or use React Query hydration */}
<PostList initialData={initialPosts} />
</div>
);
}
```
## Error Handling Best Practices
```typescript
// src/server/routers/posts.ts
import { TRPCError } from '@trpc/server';
// Custom error classes
class PostNotFoundError extends TRPCError {
constructor(postId: string) {
super({
code: 'NOT_FOUND',
message: `Post with ID ${postId} not found`,
cause: { postId },
});
}
}
class UnauthorizedPostAccessError extends TRPCError {
constructor() {
super({
code: 'FORBIDDEN',
message: 'You do not have permission to modify this post',
});
}
}
// Usage in procedures
update: protectedProcedure
.input(updatePostSchema)
.mutation(async ({ ctx, input }) => {
const post = await ctx.db.query.posts.findFirst({
where: eq(posts.id, input.id),
});
if (!post) {
throw new PostNotFoundError(input.id);
}
if (post.authorId !== ctx.user.id) {
throw new UnauthorizedPostAccessError();
}
// ... update logic
}),
```
## Testing
```typescript
// __tests__/posts.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { createCaller } from "@/server/trpc";
import { createTRPCContext } from "@/server/trpc";
describe("Posts Router", () => {
let caller: ReturnType<typeof createCaller>;
beforeEach(async () => {
const ctx = await createTRPCContext({
req: {} as any,
res: {} as any,
});
caller = createCaller(ctx);
});
it("should list published posts", async () => {
const result = await caller.posts.list({
page: 1,
limit: 10,
status: "published",
});
expect(result.items).toBeDefined();
expect(result.pagination.page).toBe(1);
});
it("should throw NOT_FOUND for non-existent post", async () => {
await expect(caller.posts.bySlug({ slug: "non-existent-post" })).rejects.toThrow("Post not found");
});
});
```
## Output Format
Provide:
- Type-safe API definitions with tRPC
- Drizzle schema with relations
- Zod validation schemas
- React Query integration patterns
- Error handling strategies
Sources:
- [tRPC Documentation](https://trpc.io/docs)
- [Drizzle ORM Documentation](https://orm.drizzle.team/docs/overview)
- [Zod Documentation](https://zod.dev/)
- [T3 Stack](https://create.t3.gg/)
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!