Modern Frontend Development: React, TypeScript, and Next.js Best Practices for 2025
The frontend development landscape evolves rapidly. What was best practice in 2020 might be outdated in 2025. After 10+ years of building production applications with React, TypeScript, and Next.js, I've learned that staying current isn't about chasing every new trend—it's about understanding which patterns and practices actually improve your codebase.
In this article, I'll share the modern frontend development practices I use today, the patterns that have stood the test of time, and the mistakes I've learned to avoid. Whether you're building a new application or modernizing an existing one, these insights will help you write better, more maintainable code.
TypeScript: Beyond Basic Types
TypeScript has become the standard for serious frontend development, and for good reason. But many developers only scratch the surface of what TypeScript can do.
Advanced Type Patterns I Use Daily
1. Discriminated Unions for State Management
Instead of optional properties that can lead to invalid states:
// ❌ Avoid this
interface User {
id: string
name?: string
email?: string
loading?: boolean
error?: string
}
// ✅ Use discriminated unions
type UserState =
| { status: 'loading' }
| { status: 'success'; user: { id: string; name: string; email: string } }
| { status: 'error'; error: string }
function UserProfile({ state }: { state: UserState }) {
switch (state.status) {
case 'loading':
return <Spinner />
case 'success':
return <div>{state.user.name}</div> // TypeScript knows user exists
case 'error':
return <div>Error: {state.error}</div>
}
}
2. Utility Types for Reusability
// Create reusable type utilities
type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
interface Product {
id: string
name: string
price: number
description: string
}
// Product with optional description
type ProductDraft = Optional<Product, 'description'>
3. Branded Types for Domain Safety
// Prevent mixing up similar types
type UserId = string & { readonly __brand: unique symbol }
type ProductId = string & { readonly __brand: unique symbol }
function getUser(id: UserId) { /* ... */ }
function getProduct(id: ProductId) { /* ... */ }
// TypeScript prevents this:
const userId: UserId = '123' as UserId
getProduct(userId) // ❌ Type error!
React 19: Embracing the New Patterns
React 19 introduces several improvements. Here's how I'm using them:
Server Components and the Component Model
Next.js 16 with React 19 makes Server Components the default. Understanding when to use Server vs. Client Components is crucial:
Server Components (default):
- Fetch data directly
- Access backend resources
- Keep sensitive information on the server
- Reduce client bundle size
// app/products/page.tsx (Server Component)
async function ProductsPage() {
const products = await fetchProducts() // Runs on server
return (
<div>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
)
}
Client Components (when you need interactivity):
- Use hooks (
useState,useEffect, etc.) - Handle browser APIs
- Add event listeners
'use client' // Explicitly mark as Client Component
function ProductCard({ product }) {
const [liked, setLiked] = useState(false)
return (
<div>
<h3>{product.name}</h3>
<button onClick={() => setLiked(!liked)}>
{liked ? '❤️' : '🤍'}
</button>
</div>
)
}
Actions and Form Handling
React 19's Actions simplify form handling:
'use server'
export async function createProduct(formData: FormData) {
const name = formData.get('name')
const price = formData.get('price')
// Validate and create product
await saveProduct({ name, price })
}
// In your component
function ProductForm() {
return (
<form action={createProduct}>
<input name="name" required />
<input name="price" type="number" required />
<button type="submit">Create Product</button>
</form>
)
}
Performance Optimization: Beyond the Basics
Performance isn't just about code splitting. Here are advanced techniques I use:
1. Selective Hydration
In Next.js, you can hydrate only the interactive parts:
import { Suspense } from 'react'
import dynamic from 'next/dynamic'
// Heavy interactive component
const ProductFilters = dynamic(() => import('./ProductFilters'), {
ssr: false, // Don't render on server
loading: () => <FiltersSkeleton />
})
function ProductsPage() {
return (
<div>
<ProductList /> {/* Server-rendered, fast */}
<Suspense fallback={<FiltersSkeleton />}>
<ProductFilters /> {/* Client-only, lazy loaded */}
</Suspense>
</div>
)
}
2. Optimistic Updates with React Query
import { useMutation, useQueryClient } from '@tanstack/react-query'
function useUpdateProduct() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateProduct,
onMutate: async (newProduct) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['products'] })
// Snapshot previous value
const previousProducts = queryClient.getQueryData(['products'])
// Optimistically update
queryClient.setQueryData(['products'], (old) =>
old.map(p => p.id === newProduct.id ? newProduct : p)
)
return { previousProducts }
},
onError: (err, newProduct, context) => {
// Rollback on error
queryClient.setQueryData(['products'], context.previousProducts)
},
onSettled: () => {
// Refetch to ensure consistency
queryClient.invalidateQueries({ queryKey: ['products'] })
},
})
}
3. Virtual Scrolling for Large Lists
When rendering thousands of items, virtual scrolling is essential:
import { useVirtualizer } from '@tanstack/react-virtual'
function VirtualizedProductList({ products }) {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: products.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100,
overscan: 5, // Render 5 extra items for smooth scrolling
})
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<ProductCard product={products[virtualRow.index]} />
</div>
))}
</div>
</div>
)
}
Error Handling and Resilience
Production applications need robust error handling:
Error Boundaries with Context
'use client'
import { ErrorBoundary } from 'react-error-boundary'
function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div role="alert">
<h2>Something went wrong:</h2>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
)
}
function App() {
return (
<ErrorBoundary
FallbackComponent={ErrorFallback}
onError={(error, info) => {
// Log to error tracking service
logErrorToService(error, info)
}}
onReset={() => {
// Reset app state if needed
}}
>
<YourApp />
</ErrorBoundary>
)
}
Graceful Degradation
function ProductImage({ src, alt }) {
const [error, setError] = useState(false)
if (error) {
return (
<div className="flex items-center justify-center bg-gray-200">
<span>Image unavailable</span>
</div>
)
}
return (
<img
src={src}
alt={alt}
onError={() => setError(true)}
loading="lazy"
/>
)
}
Testing: A Pragmatic Approach
Testing everything is impractical. Focus on what matters:
What to Test
✅ Test business logic:
// utils/calculateTotal.test.ts
describe('calculateTotal', () => {
it('calculates total with tax', () => {
expect(calculateTotal(100, 0.2)).toBe(120)
})
})
✅ Test complex interactions:
// components/ProductForm.test.tsx
it('validates required fields', async () => {
render(<ProductForm />)
fireEvent.click(screen.getByText('Submit'))
expect(await screen.findByText('Name is required')).toBeInTheDocument()
})
❌ Don't test implementation details:
// Don't test that useState was called
// Test the behavior instead
Testing with React Testing Library
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
test('user can add product to cart', async () => {
const user = userEvent.setup()
render(<ProductCard product={mockProduct} />)
const addButton = screen.getByRole('button', { name: /add to cart/i })
await user.click(addButton)
await waitFor(() => {
expect(screen.getByText(/added to cart/i)).toBeInTheDocument()
})
})
Code Organization: Scalable Patterns
As applications grow, organization becomes critical:
Feature-Based Structure
src/
app/ # Next.js app directory
(marketing)/ # Route groups
page.tsx
products/
[id]/
page.tsx
features/ # Feature modules
products/
components/
ProductCard.tsx
ProductList.tsx
hooks/
useProducts.ts
services/
productApi.ts
types/
product.ts
shared/ # Shared code
components/
Button.tsx
hooks/
useDebounce.ts
utils/
formatCurrency.ts
Barrel Exports for Clean Imports
// features/products/index.ts
export { ProductCard, ProductList } from './components'
export { useProducts, useProduct } from './hooks'
export type { Product, ProductFilters } from './types'
// Usage
import { ProductCard, useProducts, type Product } from '@/features/products'
Accessibility: Not Optional
Accessibility improves UX for everyone:
// ✅ Accessible button
<button
onClick={handleClick}
aria-label="Add product to cart"
aria-pressed={isInCart}
>
<CartIcon aria-hidden="true" />
<span className="sr-only">Add to cart</span>
</button>
// ✅ Accessible form
<form onSubmit={handleSubmit}>
<label htmlFor="product-name">
Product Name
<input
id="product-name"
name="name"
required
aria-describedby="name-error"
/>
</label>
{error && (
<div id="name-error" role="alert">
{error}
</div>
)}
</form>
Conclusion: Building for the Future
Modern frontend development isn't about using the latest library—it's about writing code that's maintainable, performant, and accessible. The practices I've shared here are ones I've refined over years of building production applications.
The key principles remain constant:
- Type safety prevents bugs before they happen
- Performance is a feature, not an afterthought
- Accessibility makes your app usable for everyone
- Testing focuses on what matters
- Organization scales with your team
As React, TypeScript, and Next.js continue to evolve, these foundational practices will serve you well, regardless of what new features are introduced.
Building a modern frontend application? I'd love to hear about your challenges and share more insights from my experience.