Building a Modern Web App with Next.js and TypeScript
As a full-stack developer, I’ve worked with various frameworks, but Next.js has become my go-to choice for building modern web applications. In this tutorial, I’ll share my experience and best practices for building production-ready apps.
π― Why Next.js?
Next.js offers several advantages that make it perfect for modern web development:
- Server-Side Rendering (SSR) - Better SEO and initial load performance
- API Routes - Build your backend alongside your frontend
- File-based Routing - Intuitive and simple routing system
- TypeScript Support - First-class TypeScript integration
- Image Optimization - Built-in image optimization
- Built-in CSS Support - Support for CSS Modules, Tailwind, and more
π Getting Started
Prerequisites
Before we begin, make sure you have:
- Node.js 18+ installed
- Basic knowledge of React and TypeScript
- A code editor (VS Code recommended)
Project Setup
# Create a new Next.js project with TypeScript
npx create-next-app@latest my-app --typescript --tailwind --app
# Navigate to project directory
cd my-app
# Install additional dependencies
npm install axios zod react-hook-form
π Project Structure
Here’s the structure I use for most projects:
my-app/
βββ app/
β βββ (routes)/
β β βββ page.tsx
β β βββ layout.tsx
β βββ api/
β β βββ users/
β β βββ route.ts
β βββ globals.css
βββ components/
β βββ ui/
β β βββ Button.tsx
β β βββ Card.tsx
β βββ shared/
β βββ Navbar.tsx
βββ lib/
β βββ utils.ts
β βββ validators.ts
βββ types/
β βββ index.ts
βββ public/
βββ images/
π¨ Building Components with TypeScript
Let’s create a reusable Button component with proper TypeScript typing:
// components/ui/Button.tsx
import { ButtonHTMLAttributes, ReactNode } from 'react';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline';
size?: 'sm' | 'md' | 'lg';
children: ReactNode;
isLoading?: boolean;
}
export const Button = ({
variant = 'primary',
size = 'md',
children,
isLoading = false,
className = '',
...props
}: ButtonProps) => {
const baseStyles = 'rounded-lg font-medium transition-colors';
const variants = {
primary: 'bg-blue-600 hover:bg-blue-700 text-white',
secondary: 'bg-gray-200 hover:bg-gray-300 text-gray-900',
outline: 'border-2 border-blue-600 hover:bg-blue-50 text-blue-600',
};
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
};
return (
<button
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
disabled={isLoading}
{...props}
>
{isLoading ? 'Loading...' : children}
</button>
);
};
π Creating API Routes
Next.js makes it easy to create API endpoints:
// 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 validatedData = userSchema.parse(body);
// Process the data (e.g., save to database)
// const user = await prisma.user.create({ data: validatedData });
return NextResponse.json(
{ success: true, data: validatedData },
{ status: 201 }
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ success: false, errors: error.errors },
{ status: 400 }
);
}
return NextResponse.json(
{ success: false, message: 'Internal server error' },
{ status: 500 }
);
}
}
π Form Handling with React Hook Form
Here’s a complete form implementation:
// components/ContactForm.tsx
'use client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Button } from '@/components/ui/Button';
const contactSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
message: z.string().min(10, 'Message must be at least 10 characters'),
});
type ContactFormData = z.infer<typeof contactSchema>;
export const ContactForm = () => {
const { register, handleSubmit, formState: { errors } } = useForm<ContactFormData>();
const onSubmit = async (data: ContactFormData) => {
try {
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (response.ok) {
alert('Message sent successfully!');
}
} catch (error) {
console.error('Error submitting form:', error);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label className="block mb-2">Name</label>
<input
{...register('name')}
className="w-full px-4 py-2 border rounded-lg"
/>
{errors.name && (
<p className="text-red-500 text-sm mt-1">{errors.name.message}</p>
)}
</div>
<div>
<label className="block mb-2">Email</label>
<input
{...register('email')}
type="email"
className="w-full px-4 py-2 border rounded-lg"
/>
{errors.email && (
<p className="text-red-500 text-sm mt-1">{errors.email.message}</p>
)}
</div>
<div>
<label className="block mb-2">Message</label>
<textarea
{...register('message')}
rows={4}
className="w-full px-4 py-2 border rounded-lg"
/>
{errors.message && (
<p className="text-red-500 text-sm mt-1">{errors.message.message}</p>
)}
</div>
<Button type="submit">Send Message</Button>
</form>
);
};
π― Best Practices
1. Type Safety
Always define proper TypeScript types/interfaces for your data:
// types/index.ts
export interface User {
id: string;
name: string;
email: string;
createdAt: Date;
}
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
}
2. Environment Variables
Use .env.local for sensitive data:
# .env.local
DATABASE_URL="postgresql://..."
NEXT_PUBLIC_API_URL="https://api.example.com"
3. Loading States
Always provide feedback for async operations:
const [isLoading, setIsLoading] = useState(false);
const fetchData = async () => {
setIsLoading(true);
try {
const data = await api.getData();
setData(data);
} finally {
setIsLoading(false);
}
};
4. Error Handling
Implement proper error boundaries and try-catch blocks.
π Performance Optimization
Image Optimization
import Image from 'next/image';
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority
placeholder="blur"
/>
Code Splitting
import dynamic from 'next/dynamic';
const DynamicComponent = dynamic(() => import('@/components/Heavy'), {
loading: () => <p>Loading...</p>,
ssr: false,
});
π Conclusion
Next.js with TypeScript provides a robust foundation for building modern web applications. Key takeaways:
- β Use TypeScript for type safety
- β Leverage Next.js features (SSR, API Routes, Image Optimization)
- β Implement proper error handling
- β Follow best practices for performance
- β Keep components reusable and well-typed
π Resources
Happy coding! π
follow my sosial media