Starting a Next.js project without these packages is like building a house without power tools. Sure, you can do it, but why would you when better solutions exist?
After building 50+ Next.js applications, I've learned that the right packages at the start save weeks of work later. These aren't just nice-to-havesβthey're the difference between a prototype and a production-ready application.
If you're about to run npx create-next-app, stop. Read this first. These 10 packages will save you from countless headaches and make you look like a Next.js wizard.
1. Zod: Type-Safe Data Validation That Actually Works
Install: npm install zod
The Problem: User input is evil. Forms, API responses, environment variablesβnothing can be trusted. TypeScript types disappear at runtime, leaving you vulnerable.
The Solution: Zod provides runtime validation with TypeScript type inference. It's like having a bouncer at every data entry point.
Basic Usage
import { z } from 'zod';
// Define a schema
const UserSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email format"),
age: z.number().min(18, "Must be 18 or older"),
role: z.enum(['admin', 'user', 'guest']),
website: z.string().url().optional(),
});
// Type inference (no need to write types twice!)
type User = z.infer<typeof UserSchema>;
// Validate data
try {
const user = UserSchema.parse(formData);
// user is now typed and validated
} catch (error) {
if (error instanceof z.ZodError) {
console.log(error.errors); // Detailed error messages
}
}
Real-World Next.js API Route
// app/api/users/route.ts
import { z } from 'zod';
import { NextRequest, NextResponse } from 'next/server';
const CreateUserSchema = z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
password: z.string().min(8).regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
"Password must contain uppercase, lowercase, and number"
),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const validatedData = CreateUserSchema.parse(body);
// Now safely use validatedData
const user = await createUser(validatedData);
return NextResponse.json(user, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ errors: error.errors },
{ status: 400 }
);
}
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
Environment Variables Validation
// lib/env.ts
import { z } from 'zod';
const envSchema = z.object({
DATABASE_URL: z.string().url(),
NEXTAUTH_SECRET: z.string().min(32),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
NODE_ENV: z.enum(['development', 'production', 'test']),
});
// Validate on startup
export const env = envSchema.parse(process.env);
// Now you have type-safe, validated env vars
// env.DATABASE_URL is guaranteed to be a valid URL
Why it's essential: Catches bugs at runtime, provides amazing error messages, and eliminates the need to write validation logic manually. Used by Vercel, Clerk, and thousands of Next.js apps.
2. React Hook Form + Zod: Forms Without Tears
Install: npm install react-hook-form @hookform/resolvers
The Problem: Forms in React are notoriously painful. Managing state, validation, errors, and submissions manually is error-prone and verbose.
The Solution: React Hook Form provides performant, flexible forms with minimal re-renders. Combine it with Zod for bulletproof validation.
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const signupSchema = z.object({
username: z.string().min(3, "Username must be at least 3 characters"),
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
type SignupFormData = z.infer<typeof signupSchema>;
export default function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<SignupFormData>({
resolver: zodResolver(signupSchema),
});
const onSubmit = async (data: SignupFormData) => {
try {
const response = await fetch('/api/auth/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (response.ok) {
// Handle success
console.log('User created!');
}
} catch (error) {
console.error('Signup failed:', error);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<input
{...register('username')}
placeholder="Username"
className="w-full px-4 py-2 border rounded"
/>
{errors.username && (
<p className="text-red-500 text-sm mt-1">{errors.username.message}</p>
)}
</div>
<div>
<input
{...register('email')}
type="email"
placeholder="Email"
className="w-full px-4 py-2 border rounded"
/>
{errors.email && (
<p className="text-red-500 text-sm mt-1">{errors.email.message}</p>
)}
</div>
<div>
<input
{...register('password')}
type="password"
placeholder="Password"
className="w-full px-4 py-2 border rounded"
/>
{errors.password && (
<p className="text-red-500 text-sm mt-1">{errors.password.message}</p>
)}
</div>
<div>
<input
{...register('confirmPassword')}
type="password"
placeholder="Confirm Password"
className="w-full px-4 py-2 border rounded"
/>
{errors.confirmPassword && (
<p className="text-red-500 text-sm mt-1">{errors.confirmPassword.message}</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? 'Creating account...' : 'Sign Up'}
</button>
</form>
);
}
Why it's essential:
- Minimal re-renders (better performance)
- Built-in validation
- Easy error handling
- Works perfectly with Server Actions
- Reduces form code by 60%
3. ShadCN UI: Copy-Paste Components That Look Professional
Install: npx shadcn-ui@latest init
The Problem: Building UI from scratch is time-consuming. Component libraries like Material-UI or Chakra are great but add bloat and lock you into their ecosystem.
The Solution: ShadCN UI is not a libraryβit's a collection of copy-paste components built with Radix UI and Tailwind. You own the code completely.
# Install components as needed
npx shadcn-ui@latest add button
npx shadcn-ui@latest add dialog
npx shadcn-ui@latest add dropdown-menu
Example: Professional Modal Dialog
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
export function DeleteConfirmDialog({ onConfirm }: { onConfirm: () => void }) {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">Delete Account</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you absolutely sure?</DialogTitle>
<DialogDescription>
This action cannot be undone. This will permanently delete your
account and remove your data from our servers.
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-3">
<Button variant="outline">Cancel</Button>
<Button variant="destructive" onClick={onConfirm}>
Delete Account
</Button>
</div>
</DialogContent>
</Dialog>
);
}
Available Components:
- Buttons, Forms, Inputs
- Dialogs, Dropdowns, Tooltips
- Tables, Cards, Tabs
- Date Pickers, Calendars
- And 40+ more
Why it's essential:
- No runtime overhead (just your code)
- Fully customizable with Tailwind
- Accessible by default (Radix UI)
- Production-ready styling
- Copy once, modify forever
4. NextAuth.js: Authentication That Just Works
Install: npm install next-auth
The Problem: Building authentication from scratch is a security nightmare. Sessions, tokens, OAuth, password hashingβit's complex and risky.
The Solution: NextAuth.js handles everything: email/password, OAuth (Google, GitHub, etc.), magic links, JWTs, and more.
// app/api/auth/[...nextauth]/route.ts
import NextAuth from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import GitHubProvider from "next-auth/providers/github";
import CredentialsProvider from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
const handler = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
GitHubProvider({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
}),
CredentialsProvider({
name: "Credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await prisma.user.findUnique({
where: { email: credentials.email }
});
if (!user || !user.hashedPassword) {
return null;
}
const isValid = await bcrypt.compare(
credentials.password,
user.hashedPassword
);
if (!isValid) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
};
}
}),
],
session: {
strategy: "jwt",
},
pages: {
signIn: '/login',
signOut: '/logout',
error: '/error',
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string;
}
return session;
},
},
});
export { handler as GET, handler as POST };
Using Auth in Components
'use client';
import { useSession, signIn, signOut } from "next-auth/react";
export default function ProfileButton() {
const { data: session, status } = useSession();
if (status === "loading") {
return <div>Loading...</div>;
}
if (session) {
return (
<div className="flex items-center gap-3">
<span>Welcome, {session.user?.name}</span>
<button onClick={() => signOut()}>Sign Out</button>
</div>
);
}
return <button onClick={() => signIn()}>Sign In</button>;
}
Protecting Server Components
import { getServerSession } from "next-auth/next";
import { redirect } from "next/navigation";
export default async function ProtectedPage() {
const session = await getServerSession();
if (!session) {
redirect('/login');
}
return <h1>Welcome, {session.user?.name}!</h1>;
}
Why it's essential: Authentication is hard. NextAuth.js is battle-tested, secure, and handles edge cases you didn't know existed.
5. Prisma: The ORM That Makes Databases Fun
Install: npm install prisma @prisma/client && npx prisma init
The Problem: Writing raw SQL is error-prone. Traditional ORMs are clunky and slow. Type safety between database and code is manual.
The Solution: Prisma provides type-safe database access with an intuitive API, migrations, and works perfectly with Next.js Server Components.
// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
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
}
Type-Safe Database Queries
// lib/prisma.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
// app/api/posts/route.ts
import { prisma } from '@/lib/prisma';
import { NextResponse } from 'next/server';
export async function GET() {
const posts = await prisma.post.findMany({
where: { published: true },
include: {
author: {
select: {
name: true,
email: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
take: 10,
});
return NextResponse.json(posts);
}
export async function POST(request: Request) {
const body = await request.json();
const post = await prisma.post.create({
data: {
title: body.title,
content: body.content,
author: {
connect: { id: body.authorId },
},
},
});
return NextResponse.json(post, { status: 201 });
}
Why it's essential:
- Auto-generated TypeScript types
- Intuitive query API
- Built-in migrations
- Excellent Next.js integration
- Active development and community
6. TanStack Query (React Query): Server State Management Done Right
Install: npm install @tanstack/react-query
The Problem: Managing server state (API data) with useState/useEffect is a mess. Caching, refetching, loading statesβit's boilerplate hell.
The Solution: TanStack Query handles all server state concerns: caching, background updates, pagination, infinite scroll, and more.
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Using in Components
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
interface Post {
id: string;
title: string;
content: string;
}
export function PostList() {
const queryClient = useQueryClient();
// Fetch posts with automatic caching
const { data: posts, isLoading, error } = useQuery({
queryKey: ['posts'],
queryFn: async () => {
const response = await fetch('/api/posts');
if (!response.ok) throw new Error('Failed to fetch posts');
return response.json() as Promise<Post[]>;
},
staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: true,
});
// Mutation for creating posts
const createPost = useMutation({
mutationFn: async (newPost: Omit<Post, 'id'>) => {
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newPost),
});
return response.json();
},
onSuccess: () => {
// Invalidate and refetch posts
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
if (isLoading) return <div>Loading posts...</div>;
if (error) return <div>Error loading posts</div>;
return (
<div>
{posts?.map((post) => (
<div key={post.id}>
<h3>{post.title}</h3>
<p>{post.content}</p>
</div>
))}
<button onClick={() => createPost.mutate({ title: 'New Post', content: 'Content' })}>
Create Post
</button>
</div>
);
}
Why it's essential:
- Eliminates 90% of data-fetching boilerplate
- Automatic caching and background updates
- Built-in loading/error states
- Optimistic updates
- Works perfectly with Next.js Server Components
7. Zustand: State Management That Doesn't Hurt
Install: npm install zustand
The Problem: Redux is overkill. Context API causes unnecessary re-renders. You need simple global state without the ceremony.
The Solution: Zustand provides a minimal, fast state management solution with zero boilerplate.
// store/useAuthStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface User {
id: string;
name: string;
email: string;
}
interface AuthStore {
user: User | null;
token: string | null;
login: (user: User, token: string) => void;
logout: () => void;
updateUser: (user: Partial<User>) => void;
}
export const useAuthStore = create<AuthStore>()(
persist(
(set) => ({
user: null,
token: null,
login: (user, token) => set({ user, token }),
logout: () => set({ user: null, token: null }),
updateUser: (updates) =>
set((state) => ({
user: state.user ? { ...state.user, ...updates } : null,
})),
}),
{
name: 'auth-storage', // Persists to localStorage
}
)
);
// store/useCartStore.ts
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartStore {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clearCart: () => void;
total: () => number;
}
export const useCartStore = create<CartStore>((set, get) => ({
items: [],
addItem: (item) =>
set((state) => {
const exists = state.items.find((i) => i.id === item.id);
if (exists) {
return {
items: state.items.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
),
};
}
return { items: [...state.items, item] };
}),
removeItem: (id) =>
set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
updateQuantity: (id, quantity) =>
set((state) => ({
items: state.items.map((i) => (i.id === id ? { ...i, quantity } : i)),
})),
clearCart: () => set({ items: [] }),
total: () => {
const state = get();
return state.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
},
}));
Using in Components
'use client';
import { useAuthStore } from '@/store/useAuthStore';
import { useCartStore } from '@/store/useCartStore';
export function Header() {
const user = useAuthStore((state) => state.user);
const logout = useAuthStore((state) => state.logout);
const cartItems = useCartStore((state) => state.items);
const cartTotal = useCartStore((state) => state.total());
return (
<header>
{user ? (
<div>
<span>Welcome, {user.name}</span>
<button onClick={logout}>Logout</button>
</div>
) : (
<a href="/login">Login</a>
)}
<div>
Cart: {cartItems.length} items (${cartTotal.toFixed(2)})
</div>
</header>
);
}
Why it's essential:
- Minimal API (learn in 5 minutes)
- No providers needed
- Automatic optimization (no unnecessary re-renders)
- Middleware for persistence, devtools, etc.
- Perfect for small to medium state needs
8. Axios or Ky: Better HTTP Requests
Install: npm install axios or npm install ky
The Problem: The native fetch API is powerful but verbose. Error handling, interceptors, and timeouts require boilerplate.
The Solution: Axios (traditional) or Ky (modern, lightweight) provide cleaner APIs with built-in features.
Axios Example
// lib/api.ts
import axios from 'axios';
const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor (add auth token)
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Response interceptor (handle errors globally)
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Redirect to login
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default api;
// Usage
import api from '@/lib/api';
export async function fetchUsers() {
const response = await api.get('/users');
return response.data;
}
export async function createUser(userData: any) {
const response = await api.post('/users', userData);
return response.data;
}
Ky Example (Modern Alternative)
// lib/api.ts
import ky from 'ky';
const api = ky.create({
prefixUrl: process.env.NEXT_PUBLIC_API_URL,
timeout: 10000,
hooks: {
beforeRequest: [
(request) => {
const token = localStorage.getItem('token');
if (token) {
request.headers.set('Authorization', `Bearer ${token}`);
}
},
],
afterResponse: [
async (request, options, response) => {
if (response.status === 401) {
window.location.href = '/login';
}
},
],
},
});
export default api;
// Usage
const users = await api.get('users').json();
const newUser = await api.post('users', { json: userData }).json();
Why it's essential:
- Cleaner syntax than fetch
- Built-in interceptors
- Automatic JSON parsing
- Timeout handling
- TypeScript support
9. Date-fns: Date Manipulation Without the Pain
Install: npm install date-fns
The Problem: JavaScript Date objects are awful. Moment.js is deprecated and massive (67KB). You need modern date utilities.
The Solution: date-fns is modular, tree-shakeable, and provides 200+ functions for date manipulation.
import {
format,
formatDistance,
formatRelative,
addDays,
subDays,
isAfter,
isBefore,
parseISO,
startOfDay,
endOfDay,
differenceInDays
} from 'date-fns';
// Formatting
const date = new Date();
format(date, 'yyyy-MM-dd'); // "2025-12-24"
format(date, 'MMMM dd, yyyy'); // "December 24, 2025"
format(date, "h:mm a"); // "3:45 PM"
// Relative time
formatDistance(subDays(date, 3), date, { addSuffix: true });
// "3 days ago"
formatRelative(subDays(date, 3), date);
// "last Friday at 3:45 PM"
// Date math
const tomorrow = addDays(date, 1);
const lastWeek = subDays(date, 7);
// Comparisons
isAfter(date, lastWeek); // true
isBefore(date, tomorrow); // true
// Parsing
const parsed = parseISO('2025-12-24T15:30:00');
// Day boundaries
const dayStart = startOfDay(date); // 2025-12-24 00:00:00
const dayEnd = endOfDay(date); // 2025-12-24 23:59:59
// Differences
differenceInDays(date, subDays(date, 10)); // 10
Real-World Next.js Usage
// components/PostCard.tsx
import { formatDistance } from 'date-fns';
interface Post {
id: string;
title: string;
createdAt: Date;
}
export function PostCard({ post }: { post: Post }) {
return (
<article>
<h3>{post.title}</h3>
<time>
Posted {formatDistance(post.createdAt, new Date(), { addSuffix: true })}
</time>
</article>
);
}
// lib/utils.ts
import { format, isToday, isYesterday, isThisYear } from 'date-fns';
export function smartDateFormat(date: Date): string {
if (isToday(date)) {
return `Today at ${format(date, 'h:mm a')}`;
}
if (isYesterday(date)) {
return `Yesterday at ${format(date, 'h:mm a')}`;
}
if (isThisYear(date)) {
return format(date, 'MMM d');
}
return format(date, 'MMM d, yyyy');
}
Why it's essential:
- Tree-shakeable (only import what you use)
- Immutable (no date mutation bugs)
- TypeScript-first
- No timezone headaches
- 2KB vs Moment.js 67KB
10. Framer Motion: Animations That Wow
Install: npm install framer-motion
The Problem: CSS animations are limited. Complex gestures, layout animations, and scroll-triggered effects require JavaScript.
The Solution: Framer Motion makes advanced animations simple with a declarative API built for React.
typescript
'use client';
import { motion, AnimatePresence } from 'framer-motion';
import { useState } from 'react';
// Fade in animation
export function FadeInText() {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<h1>Welcome to our site!</h1>
</motion.div>
);
}
// Hover and tap animations
export function AnimatedButton() {
return (
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="px-6 py-3 bg-blue-600 text-white rounded"
>
Click me
</motion.button>
);
}
// List animations
export function TodoList({ todos }: { todos: string[] }) {
return (
<ul>
<AnimatePresence>
{todos.map((todo, index) => (
<motion.li
key={todo}
initial={{ opacity: 0, x: -50 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 50 }}
transition={{ delay: index * 0.1 }}
>
{todo}
</motion.li>
))}
Top comments (0)