Modern Web Stack Guide: Next.js 15 + TypeScript
Building modern web applications requires more than just knowing React. In this comprehensive guide, we'll explore how to leverage Next.js 15 with TypeScript to create production-ready applications.
Why Next.js 15?
Next.js 15 brings significant improvements that make it the go-to framework for React applications:
- App Router: File-based routing with layouts, server components, and streaming
- Server Components: Reduce JavaScript bundle size and improve performance
- Server Actions: Type-safe mutations without API routes
- Partial Pre-rendering: Combine static and dynamic content
- Turbopack: Lightning-fast bundler for development
Project Setup
Initialize Your Project
npx create-next-app@latest my-app --typescript --app --tailwind cd my-app npm install
Essential Dependencies
# UI Components npm install @radix-ui/react-dialog @radix-ui/react-dropdown-menu npm install class-variance-authority clsx tailwind-merge # Forms & Validation npm install react-hook-form @hookform/resolvers zod # State Management (if needed) npm install zustand # Database (Prisma example) npm install @prisma/client npm install -D prisma
TypeScript Best Practices
Strict Type Safety
Enable strict mode in tsconfig.json:
{ "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, "noUnusedLocals": true, "noUnusedParameters": true } }
Type-Safe API Routes
// app/api/users/route.ts import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; const userSchema = z.object({ name: z.string().min(2), email: z.string().email(), }); export async function POST(request: NextRequest) { try { const body = await request.json(); const { name, email } = userSchema.parse(body); // Your logic here return NextResponse.json({ success: true }); } catch (error) { if (error instanceof z.ZodError) { return NextResponse.json( { error: error.errors }, { status: 400 } ); } return NextResponse.json( { error: 'Internal server error' }, { status: 500 } ); } }
Server Components vs Client Components
When to Use Server Components
- Fetching data from databases
- Accessing backend resources
- Keeping sensitive information server-side
- Large dependencies that don't need client JavaScript
// app/products/page.tsx (Server Component) import { db } from '@/lib/db'; export default async function ProductsPage() { const products = await db.product.findMany(); return ( <div> {products.map(product => ( <ProductCard key={product.id} product={product} /> ))} </div> ); }
When to Use Client Components
- Using React hooks (useState, useEffect, etc.)
- Handling browser events
- Using browser-only APIs
- Interactive components
'use client'; import { useState } from 'react'; export function Counter() { const [count, setCount] = useState(0); return ( <button onClick={() => setCount(count + 1)}> Count: {count} </button> ); }
Data Fetching Patterns
Server-Side Data Fetching
// app/posts/[id]/page.tsx async function getPost(id: string) { const res = await fetch(`https://api.example.com/posts/${id}`, { next: { revalidate: 3600 }, // Revalidate every hour }); if (!res.ok) throw new Error('Failed to fetch post'); return res.json(); } export default async function PostPage({ params, }: { params: Promise<{ id: string }>; }) { const { id } = await params; const post = await getPost(id); return <article>{/* Render post */}</article>; }
Parallel Data Fetching
async function getData() { const [users, posts, comments] = await Promise.all([ fetch('/api/users').then(r => r.json()), fetch('/api/posts').then(r => r.json()), fetch('/api/comments').then(r => r.json()), ]); return { users, posts, comments }; }
Form Handling with React Hook Form + Zod
'use client'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; const formSchema = z.object({ email: z.string().email('Invalid email address'), password: z.string().min(8, 'Password must be at least 8 characters'), }); type FormData = z.infer<typeof formSchema>; export function LoginForm() { const { register, handleSubmit, formState: { errors, isSubmitting }, } = useForm<FormData>({ resolver: zodResolver(formSchema), }); const onSubmit = async (data: FormData) => { const response = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); if (response.ok) { // Handle success } }; return ( <form onSubmit={handleSubmit(onSubmit)}> <input {...register('email')} type="email" /> {errors.email && <span>{errors.email.message}</span>} <input {...register('password')} type="password" /> {errors.password && <span>{errors.password.message}</span>} <button type="submit" disabled={isSubmitting}> {isSubmitting ? 'Loading...' : 'Login'} </button> </form> ); }
Database Integration with Prisma
Schema Definition
// prisma/schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) email String @unique name String? posts Post[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Post { id String @id @default(cuid()) title String content String? published Boolean @default(false) author User @relation(fields: [authorId], references: [id]) authorId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }
Database Client
// lib/db.ts import { PrismaClient } from '@prisma/client'; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined; }; export const db = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== 'production') { globalForPrisma.prisma = db; }
Performance Optimization
Image Optimization
import Image from 'next/image'; export function ProductImage({ src, alt }: { src: string; alt: string }) { return ( <Image src={src} alt={alt} width={800} height={600} quality={85} placeholder="blur" blurDataURL="/placeholder.jpg" loading="lazy" /> ); }
Code Splitting
import dynamic from 'next/dynamic'; const HeavyComponent = dynamic(() => import('@/components/HeavyComponent'), { loading: () => <p>Loading...</p>, ssr: false, // Disable server-side rendering if not needed });
Testing Strategy
Unit Tests with Jest
// __tests__/utils.test.ts import { formatCurrency } from '@/lib/utils'; describe('formatCurrency', () => { it('formats USD correctly', () => { expect(formatCurrency(1234.56, 'USD')).toBe('$1,234.56'); }); it('handles zero correctly', () => { expect(formatCurrency(0, 'USD')).toBe('$0.00'); }); });
Integration Tests with Playwright
// e2e/login.spec.ts import { test, expect } from '@playwright/test'; test('user can login', async ({ page }) => { await page.goto('/login'); await page.fill('[name="email"]', 'user@example.com'); await page.fill('[name="password"]', 'password123'); await page.click('button[type="submit"]'); await expect(page).toHaveURL('/dashboard'); });
Deployment Checklist
- ✅ Environment variables configured
- ✅ Database migrations run
- ✅ Build passes without errors
- ✅ TypeScript types validate
- ✅ Tests pass
- ✅ Performance metrics meet targets (Lighthouse score > 90)
- ✅ SEO metadata configured
- ✅ Analytics integrated
- ✅ Error tracking setup (Sentry, etc.)
Conclusion
Next.js 15 with TypeScript provides a robust foundation for building modern web applications. By following these patterns and best practices, you'll create maintainable, performant applications that scale.
Ready to build your next project? Get in touch and let's discuss your requirements.