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.