Back to skills
SKILL.md
Trpc Fullstack
ASecurityExpert in tRPC, Drizzle ORM, and Zod for end-to-end type-safe full-stack TypeScript applications. Use when you need help with trpc fullstack.
- 8 stars
- 0 votes
- 0 copies
- 1 view
- Added September 8, 2026
Works with
Security analysis
96/100- Installs packages at runtime which could introduce malicious dependencies
npx -y skills add anubhavg-icpl/vibe --skill trpc-fullstack --agent claude-codeAre you the author of Trpc Fullstack?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/anubhavg-icpl-trpc-fullstack)---
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/)
Attribution
Comments
Loading comments…