January 15, 2024
10 min read
Ian Lintner

Building a Modern Next.js Portfolio with TypeScript and tRPC

Learn how to build a full-stack portfolio website using Next.js 14, TypeScript, tRPC, and Drizzle ORM. This comprehensive guide covers everything from setup to deployment.

Next.jsTypeScripttRPC
Share this article:

Building a Modern Next.js Portfolio with TypeScript and tRPC

In this comprehensive guide, we'll build a modern, full-stack portfolio website using the latest technologies including Next.js 14, TypeScript, tRPC, and Drizzle ORM.

Overview

This project demonstrates how to create a professional portfolio website with:

  • Next.js 14 with App Router for modern React development
  • TypeScript for type safety throughout the stack
  • tRPC for end-to-end type-safe API calls
  • NextAuth.js for authentication
  • Tailwind CSS for styling
  • Drizzle ORM for database access

System Architecture

Request Lifecycle (Blog Page)

Getting Started

Prerequisites

Before we begin, make sure you have the following installed:

  • Node.js 18+
  • pnpm
  • PostgreSQL database (local or cloud)

Project Setup

# Create a new Next.js project
pnpm create next-app@latest portfolio --ts --tailwind --eslint --app

# Navigate to the project directory
cd portfolio

# Install dependencies
pnpm add @trpc/server @trpc/client @trpc/react-query @trpc/next
pnpm add @tanstack/react-query zod
pnpm add drizzle-orm drizzle-kit pg @vercel/postgres
pnpm add next-auth bcryptjs
pnpm add -D @types/bcryptjs

Database Setup with Drizzle

First, set up Drizzle and the schema.

// drizzle.config.ts
import { defineConfig } from 'drizzle-kit'

export default defineConfig({
  schema: './src/server/db/schema.ts',
  out: './drizzle',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
})
// src/server/db/schema.ts
import { pgTable, serial, text, timestamp, varchar, integer } from 'drizzle-orm/pg-core'

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  name: varchar('name', { length: 255 }),
  email: varchar('email', { length: 255 }).notNull().unique(),
  password: text('password'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
})

export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  title: varchar('title', { length: 255 }).notNull(),
  content: text('content'),
  excerpt: text('excerpt'),
  slug: varchar('slug', { length: 255 }).notNull().unique(),
  published: integer('published').default(0).notNull(),
  publishedAt: timestamp('published_at'),
  authorId: integer('author_id').notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
})
// src/server/db.ts
import { drizzle } from 'drizzle-orm/vercel-postgres'
import { sql } from '@vercel/postgres'
import * as schema from './db/schema'

export const db = drizzle(sql, { schema })

Generate and apply migrations:

pnpm db:generate
pnpm db:migrate

tRPC Setup

Next, let's configure tRPC for type-safe API calls:

// src/server/api/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server'
import { type CreateNextContextOptions } from '@trpc/server/adapters/next'
import { type Session } from 'next-auth'
import { getServerAuthSession } from '@/server/auth'
import { db } from '@/server/db'
import superjson from 'superjson'
import { ZodError } from 'zod'

type CreateContextOptions = {
  session: Session | null
}

const createInnerTRPCContext = (opts: CreateContextOptions) => {
  return {
    session: opts.session,
    db,
  }
}

export const createTRPCContext = async (opts: CreateNextContextOptions) => {
  const { req, res } = opts
  const session = await getServerAuthSession({ req, res })

  return createInnerTRPCContext({
    session,
  })
}

const t = initTRPC.context<typeof createTRPCContext>().create({
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError:
          error.cause instanceof ZodError ? error.cause.flatten() : null,
      },
    }
  },
})

export const createTRPCRouter = t.router
export const publicProcedure = t.procedure

Creating API Routes

Let's create our post management routes:

// src/server/api/routers/post.ts
import { z } from 'zod'
import { createTRPCRouter, publicProcedure } from '@/server/api/trpc'
import { db } from '@/server/db'
import { posts, users } from '@/server/db/schema'
import { desc, eq } from 'drizzle-orm'
import { TRPCError } from '@trpc/server'

export const postRouter = createTRPCRouter({
  getAll: publicProcedure
    .input(z.object({
      limit: z.number().min(1).max(100).nullish(),
      published: z.boolean().optional().default(true),
    }))
    .query(async ({ input }) => {
      const limit = input.limit ?? 10
      const rows = await db
        .select({
          id: posts.id,
          title: posts.title,
          slug: posts.slug,
          excerpt: posts.excerpt,
          createdAt: posts.createdAt,
          authorName: users.name,
        })
        .from(posts)
        .leftJoin(users, eq(posts.authorId, users.id))
        .where(eq(posts.published, input.published ? 1 : 0))
        .orderBy(desc(posts.createdAt))
        .limit(limit)

      return { posts: rows }
    }),

  getBySlug: publicProcedure
    .input(z.object({ slug: z.string() }))
    .query(async ({ input }) => {
      const rows = await db
        .select()
        .from(posts)
        .where(eq(posts.slug, input.slug))
        .limit(1)

      if (rows.length === 0) {
        throw new TRPCError({ code: 'NOT_FOUND', message: 'Post not found' })
      }
      return rows[0]
    }),
})

Frontend Implementation

Now let's create the blog listing page:

// src/app/blog/page.tsx
'use client'

import { api } from '@/utils/trpc'
import Link from 'next/link'
import { Calendar, Clock, Tag } from 'lucide-react'

export default function BlogPage() {
  const { data: postsData, isLoading } = api.post.getAll.useQuery({
    limit: 10,
    published: true,
  })

  if (isLoading) {
    return <div>Loading...</div>
  }

  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-4xl font-bold mb-8">Blog</h1>
      
      <div className="grid gap-8">
        {postsData?.posts.map((post) => (
          <article key={post.id} className="border rounded-lg p-6">
            <div className="flex items-center gap-4 text-sm text-gray-600 mb-3">
              <div className="flex items-center gap-1">
                <Calendar className="h-4 w-4" />
                <span>{new Date(post.createdAt).toLocaleDateString()}</span>
              </div>
              <div className="flex items-center gap-1">
                <Clock className="h-4 w-4" />
                <span>5 min read</span>
              </div>
            </div>
            
            <h2 className="text-2xl font-bold mb-3">
              <Link href={`/blog/${post.slug}`} className="hover:text-blue-600">
                {post.title}
              </Link>
            </h2>
            
            <p className="text-gray-600 mb-4">{post.excerpt}</p>
            
            {post.tags.length > 0 && (
              <div className="flex gap-2">
                {post.tags.map((tagRelation) => (
                  <span key={tagRelation.tag.id} className="bg-gray-100 px-2 py-1 rounded text-sm">
                    {tagRelation.tag.name}
                  </span>
                ))}
              </div>
            )}
          </article>
        ))}
      </div>
    </div>
  )
}

Deployment

For deployment, we recommend using Vercel with a PostgreSQL database:

  1. Push your code to GitHub
  2. Connect your repository to Vercel
  3. Set up environment variables
  4. Deploy!

Conclusion

We've built a modern, full-stack portfolio website with type safety throughout the entire stack. This architecture provides excellent developer experience and maintainability.

The combination of Next.js, TypeScript, tRPC, and Drizzle ORM creates a powerful foundation for any web application, not just portfolios.

Next Steps

  • Add image upload functionality
  • Implement search and filtering
  • Add comments system
  • Set up monitoring and analytics
  • Implement automated testing

Happy coding! 🚀

I

Ian Lintner

Full Stack Developer

Published on

January 15, 2024

Building a Modern Next.js Portfolio with TypeScript and tRPC | Ian Lintner