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.
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:
- Push your code to GitHub
- Connect your repository to Vercel
- Set up environment variables
- 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! 🚀