Monday, 3 November 2025

Building a Type-Safe Full-Stack Application with tRPC, Next.js, and Prisma (2025 Guide)

Building a Type-Safe Full-Stack Application with tRPC, Next.js, and Prisma

Type-safe full-stack architecture with tRPC, Next.js, and Prisma showing end-to-end TypeScript type flow

In 2025, type safety has evolved from a development luxury to a production necessity. The combination of tRPC, Next.js, and Prisma represents the pinnacle of full-stack type safety, enabling developers to build robust applications with end-to-end TypeScript coverage. This comprehensive guide explores how to create a completely type-safe full-stack application where your frontend, backend, and database schema are seamlessly connected through automatic type inference. Whether you're building a SaaS platform, e-commerce site, or internal tool, mastering this stack will eliminate entire classes of runtime errors and dramatically accelerate your development velocity.

🚀 Why End-to-End Type Safety Matters in 2025

Traditional full-stack development often suffers from type mismatches between frontend and backend, leading to runtime errors and development friction. The tRPC + Next.js + Prisma stack solves this by creating a unified type system that spans your entire application.

  • Zero API Contracts: Automatic type sharing between frontend and backend
  • Development Speed: Instant feedback and autocomplete across the entire stack
  • Runtime Safety: Catch errors at compile time rather than in production
  • Maintainability: Refactor with confidence across frontend and backend
  • Developer Experience: Superior IDE support and documentation

💻 Complete Project Setup and Configuration

Let's start with a complete project setup that establishes our type-safe foundation.

// package.json - Complete dependencies
{
  "name": "type-safe-fullstack",
  "version": "1.0.0",
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "db:generate": "prisma generate",
    "db:push": "prisma db push",
    "db:studio": "prisma studio",
    "type-check": "tsc --noEmit"
  },
  "dependencies": {
    "@prisma/client": "^5.6.0",
    "@tanstack/react-query": "^5.0.0",
    "@trpc/client": "^11.0.0",
    "@trpc/next": "^11.0.0",
    "@trpc/react-query": "^11.0.0",
    "@trpc/server": "^11.0.0",
    "next": "14.0.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "superjson": "^2.0.0",
    "zod": "^3.22.0"
  },
  "devDependencies": {
    "@types/node": "^20.0.0",
    "@types/react": "^18.2.0",
    "@types/react-dom": "^18.2.0",
    "prisma": "^5.6.0",
    "typescript": "^5.2.0"
  }
}

// tsconfig.json - Strict TypeScript configuration
{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "dom.iterable", "es6"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"],
      "~/*": ["./*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}
  

🛠️ tRPC Setup with Advanced Configuration

Setting up tRPC correctly is crucial for type safety. Here's a complete configuration with error handling and middleware.

// src/server/trpc.ts - tRPC configuration
import { initTRPC, TRPCError } from '@trpc/server';
import { type CreateNextContextOptions } from '@trpc/server/adapters/next';
import superjson from 'superjson';
import { ZodError } from 'zod';
import { prisma } from './prisma';

// Context creation
export const createTRPCContext = (opts: CreateNextContextOptions) => {
  return {
    prisma,
    req: opts.req,
    res: opts.res,
    user: null, // Would come from auth in real app
  };
};

// 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,
      },
    };
  },
});

// Middlewares
export const createTRPCRouter = t.router;
export const publicProcedure = t.procedure;

// Authentication middleware
const isAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({
    ctx: {
      ...ctx,
      user: ctx.user, // user is now non-null
    },
  });
});

export const protectedProcedure = t.procedure.use(isAuthed);
  

🚀 Next.js API Route Configuration

Setting up the tRPC API route in Next.js to handle both HTTP and WebSocket requests.

// src/pages/api/trpc/[trpc].ts - tRPC API handler
import { createNextApiHandler } from '@trpc/server/adapters/next';
import { appRouter } from '../../../server/routes/_app';
import { createTRPCContext } from '../../../server/trpc';

export default createNextApiHandler({
  router: appRouter,
  createContext: createTRPCContext,
  onError:
    process.env.NODE_ENV === 'development'
      ? ({ path, error }) => {
          console.error(
            `❌ tRPC failed on ${path ?? '<no-path>'}: ${error.message}`
          );
        }
      : undefined,
  responseMeta({ ctx, paths, type, errors }) {
    // Cache API responses for 1 minute
    const allPublic = paths && paths.every((path) => path.includes('public'));
    const allOk = errors.length === 0;
    const isQuery = type === 'query';

    if (ctx?.res && allPublic && allOk && isQuery) {
      return {
        headers: {
          'cache-control': `s-maxage=60, stale-while-revalidate=300`,
        },
      };
    }
    return {};
  },
});

// src/utils/trpc.ts - Frontend tRPC client
import { httpBatchLink, loggerLink } from '@trpc/client';
import { createTRPCNext } from '@trpc/next';
import superjson from 'superjson';
import { type AppRouter } from '../server/routes/_app';

function getBaseUrl() {
  if (typeof window !== 'undefined') return '';
  if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
  return `http://localhost:${process.env.PORT ?? 3000}`;
}

export const trpc = createTRPCNext<AppRouter>({
  config() {
    return {
      transformer: superjson,
      links: [
        loggerLink({
          enabled: (opts) =>
            process.env.NODE_ENV === 'development' ||
            (opts.direction === 'down' && opts.result instanceof Error),
        }),
        httpBatchLink({
          url: `${getBaseUrl()}/api/trpc`,
        }),
      ],
    };
  },
  ssr: true,
});
  

🎯 Type-Safe Frontend Components

Leveraging tRPC's type inference to build completely type-safe React components.

// src/components/PostList.tsx - Type-safe post listing
import { useState } from 'react';
import { trpc } from '../utils/trpc';

export function PostList() {
  const [search, setSearch] = useState('');
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    status,
    error,
  } = trpc.post.list.useInfiniteQuery(
    {
      limit: 10,
      search: search || undefined,
    },
    {
      getNextPageParam: (lastPage) => lastPage.nextCursor,
      staleTime: 5 * 60 * 1000, // 5 minutes
    }
  );

  if (status === 'loading') {
    return <div>Loading posts...</div>;
  }

  if (status === 'error') {
    return <div>Error: {error.message}</div>;
  }

  return (
    <div className="space-y-6">
      <div className="flex gap-4">
        <input
          type="text"
          placeholder="Search posts..."
          value={search}
          onChange={(e) => setSearch(e.target.value)}
          className="px-4 py-2 border rounded-lg"
        />
      </div>

      <div className="space-y-4">
        {data.pages.map((page, pageIndex) => (
          <div key={pageIndex} className="space-y-4">
            {page.posts.map((post) => (
              <PostCard key={post.id} post={post} />
            ))}
          </div>
        ))}
      </div>

      {hasNextPage && (
        <button
          onClick={() => fetchNextPage()}
          disabled={isFetchingNextPage}
          className="px-4 py-2 bg-blue-500 text-white rounded-lg disabled:opacity-50"
        >
          {isFetchingNextPage ? 'Loading more...' : 'Load More'}
        </button>
      )}
    </div>
  );
}

// src/components/CreatePostForm.tsx - Type-safe form with validation
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { trpc } from '../utils/trpc';

const createPostSchema = z.object({
  title: z.string().min(1, 'Title is required').max(255),
  content: z.string().optional(),
  tagIds: z.array(z.string()).optional(),
});

type CreatePostInput = z.infer<typeof createPostSchema>;

export function CreatePostForm() {
  const utils = trpc.useContext();
  const { data: tags } = trpc.tag.list.useQuery();
  
  const createPost = trpc.post.create.useMutation({
    onSuccess: () => {
      utils.post.list.invalidate();
      reset();
    },
  });

  const {
    register,
    handleSubmit,
    formState: { errors },
    reset,
  } = useForm<CreatePostInput>({
    resolver: zodResolver(createPostSchema),
  });

  const onSubmit = (data: CreatePostInput) => {
    createPost.mutate(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4 p-6 border rounded-lg">
      <h2 className="text-lg font-semibold">Create New Post</h2>
      
      <div>
        <label className="block text-sm font-medium mb-1">Title</label>
        <input
          {...register('title')}
          className="w-full px-3 py-2 border rounded-lg"
          placeholder="Post title"
        />
        {errors.title && (
          <p className="text-red-500 text-sm mt-1">{errors.title.message}</p>
        )}
      </div>

      <button
        type="submit"
        disabled={createPost.isLoading}
        className="px-4 py-2 bg-blue-500 text-white rounded-lg disabled:opacity-50"
      >
        {createPost.isLoading ? 'Creating...' : 'Create Post'}
      </button>
    </form>
  );
}
  

🔒 Advanced Patterns and Best Practices

Beyond the basics, these advanced patterns will make your type-safe application production-ready.

  • Error Handling: Structured error types and client-side error boundaries
  • Authentication: Type-safe session management and protected procedures
  • Caching Strategies: Optimistic updates and query invalidation
  • Testing: End-to-end type-safe testing utilities
  • Performance: Code splitting and bundle optimization

⚡ Key Takeaways

  1. End-to-End Type Safety: Automatic type sharing eliminates API contract mismatches
  2. Development Velocity: Instant feedback and autocomplete across the entire stack
  3. Runtime Confidence: Compile-time error catching prevents production issues
  4. Maintainability: Refactoring becomes safe and predictable
  5. Developer Experience: Superior IDE support reduces cognitive load
  6. Performance: Built-in optimizations like batching and caching
  7. Scalability: Modular router structure supports growing applications

❓ Frequently Asked Questions

How does tRPC compare to GraphQL or REST APIs?
tRPC provides automatic type safety without the complexity of GraphQL schemas or the manual type definitions of REST. It's ideal for TypeScript-focused teams building full-stack applications where you control both frontend and backend. GraphQL excels at public APIs and complex data requirements, while REST remains the universal standard for web APIs.
Can I use tRPC with existing REST APIs or databases?
Yes, tRPC can coexist with existing APIs. You can gradually migrate endpoints to tRPC or use it for new features while maintaining existing REST APIs. For databases, Prisma supports most major databases, and you can use tRPC with any data source by creating custom procedures that don't rely on Prisma.
What about authentication and authorization in tRPC?
tRPC supports authentication through middleware. You can create protected procedures that require authentication and access user context in your resolvers. The type system ensures that protected procedures can only be called with proper authentication, and user data is type-safe throughout your application.
How do I handle file uploads with tRPC?
While tRPC works best with JSON data, you can handle file uploads by using Next.js API routes for file handling and tRPC for metadata. Alternatively, use base64 encoding for small files or create a separate file upload service that integrates with your tRPC API through procedure calls.
Is tRPC suitable for large-scale production applications?
Absolutely. tRPC is used in production by companies like Cal.com, Ping.gg, and others. It scales well through router composition, middleware chains, and proper architecture. The type safety actually becomes more valuable as the application grows, preventing entire classes of errors in large codebases.
How do I deploy a tRPC + Next.js application?
Deployment is straightforward with platforms like Vercel, Netlify, or any Node.js hosting provider. Since it's a standard Next.js application, you get all the benefits of Next.js deployment including automatic API route handling, static generation, and server-side rendering. Just ensure your database connections are properly configured for your deployment environment.

💬 Have you built applications with tRPC, Next.js, and Prisma? Share your experiences, challenges, or tips in the comments below! If you found this guide helpful, please share it with your team or on social media to help others master type-safe full-stack development.

About LK-TECH Academy — Practical tutorials & explainers on software engineering, AI, and infrastructure. Follow for concise, hands-on guides.

No comments:

Post a Comment