Skip to main content

React & NextJS Best Practices

NextJS 15 and React Server Components

We use NextJS 15 with React Server Components (RSC) as our primary frontend framework. This document outlines our best practices and patterns for working with this stack.

Server Components vs. Client Components

When to Use Server Components

Server Components should be your default choice for most components. Use them for:

  • Data fetching directly from the database or APIs
  • Components that don't need client-side interactivity
  • Accessing backend resources directly
  • Rendering static or server-rendered content
  • Keeping sensitive information (API keys, tokens) secure
// Example of a Server Component
// user-profile.tsx
import { getUserById } from '@/lib/db';

async function UserProfile({ userId }: { userId: string }) {
// This data fetching happens on the server and never reaches the client
const user = await getUserById(userId);

if (!user) {
return <div>User not found</div>;
}

return (
<div className="user-profile">
<h2>{user.name}</h2>
<p>{user.email}</p>
{/* Other user information */}
</div>
);
}

export default UserProfile;

When to Use Client Components

Use Client Components when you need:

  • Interactivity (event handlers, state, effects)
  • Browser-only APIs
  • Custom hooks
  • React Class components

To mark a component as a Client Component, add the "use client" directive at the top of the file:

"use client"
// Example of a Client Component
// user-form.tsx
import { useState } from 'react';

function UserForm({ onSubmit }) {
const [name, setName] = useState('');
const [email, setEmail] = useState('');

const handleSubmit = (e) => {
e.preventDefault();
onSubmit({ name, email });
};

return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name"
/>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
<button type="submit">Submit</button>
</form>
);
}

export default UserForm;

Component Pattern

Follow this pattern for organizing client and server components:

app/users/
├── page.tsx # Server Component (page)
├── user-form.tsx # Client Component (interactive form)
└── user-container.tsx # Server Component (fetches data and passes to client)

Data Fetching

Server Component Data Fetching

For data fetching in Server Components:

  • Use native async/await directly in your components
  • Implement proper error handling
  • Use NextJS's cache and revalidation features
// Good example of data fetching in a Server Component
async function ProductList() {
try {
const products = await getProducts();

return (
<div className="products-grid">
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
} catch (error) {
console.error('Failed to fetch products:', error);
return <ErrorMessage message="Failed to load products" />;
}
}

API Routes

For client-side data fetching or third-party API access:

  • Use the App Router's Route Handlers in the app/api directory
  • Implement proper validation and error handling
  • Use appropriate HTTP methods and status codes
// app/api/users/route.ts
import { NextResponse } from 'next/server';
import { validateUserData } from '@/lib/validation';
import { createUser } from '@/lib/db';

export async function POST(request: Request) {
try {
const data = await request.json();

// Validate the data
const validationResult = validateUserData(data);
if (!validationResult.success) {
return NextResponse.json(
{ error: validationResult.errors },
{ status: 400 }
);
}

// Create the user
const user = await createUser(data);

return NextResponse.json({ user }, { status: 201 });
} catch (error) {
console.error('Failed to create user:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

Routing and Layouts

App Router Structure

Organize your NextJS app using the App Router convention:

app/
├── layout.tsx # Root layout (applied to all pages)
├── page.tsx # Home page
├── favicon.ico # Favicon
├── global.css # Global styles
├── (auth)/ # Route group (doesn't affect URL)
│ ├── login/ # /login route
│ │ └── page.tsx
│ └── register/ # /register route
│ └── page.tsx
├── dashboard/ # /dashboard route
│ ├── layout.tsx # Dashboard layout
│ ├── page.tsx # Dashboard index page
│ └── [id]/ # Dynamic route
│ └── page.tsx # /dashboard/:id
└── api/ # API routes
└── users/
└── route.ts # GET, POST handlers for /api/users

Layouts

  • Use layouts to share UI between multiple pages
  • Keep layouts lightweight and focused on structure
  • Implement nested layouts for complex UIs
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="dashboard-layout">
<DashboardSidebar />
<main className="dashboard-content">
{children}
</main>
</div>
);
}

Performance Optimization

Image Optimization

Always use the Next.js Image component for optimal image loading:

import Image from 'next/image';

function ProductImage({ product }) {
return (
<Image
src={product.imageUrl}
alt={product.name}
width={500}
height={300}
priority={product.featured}
quality={85}
className="product-image"
/>
);
}

Route Optimization

  • Use proper route segmentation and parallel routes
  • Implement loading states and error boundaries
  • Use the streaming capabilities of React Server Components
// app/products/[id]/page.tsx
import { Suspense } from 'react';
import { getProductById } from '@/lib/products';
import ProductDetails from '@/components/product-details';
import ProductReviews from '@/components/product-reviews';
import LoadingSpinner from '@/components/ui/loading-spinner';

export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProductById(params.id);

if (!product) {
notFound();
}

return (
<div className="product-page">
<ProductDetails product={product} />

<Suspense fallback={<LoadingSpinner />}>
{/* This can be loaded in parallel without blocking the main content */}
<ProductReviews productId={params.id} />
</Suspense>
</div>
);
}

State Management

Recommendations

For state management, follow these guidelines based on complexity:

  1. Local State: Use React's useState and useReducer for component-level state
  2. Server State: Keep as much state on the server as possible using Server Components
  3. Form State: Use libraries like React Hook Form for complex forms
  4. Global State: For complex applications, consider Zustand or Jotai over Redux

Examples

Local state with useState:

"use client"
import { useState } from 'react';

function Counter() {
const [count, setCount] = useState(0);

return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}

Form state with React Hook Form:

"use client"
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const formSchema = z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
});

function ContactForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(formSchema),
});

const onSubmit = (data) => {
console.log(data);
// Submit to API
};

return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input {...register('name')} placeholder="Name" />
{errors.name && <p className="error">{errors.name.message}</p>}
</div>
<div>
<input {...register('email')} placeholder="Email" />
{errors.email && <p className="error">{errors.email.message}</p>}
</div>
<button type="submit">Submit</button>
</form>
);
}

Error Handling

Error Boundaries

Use NextJS's error.js files to create error boundaries:

// app/dashboard/error.tsx
"use client"
import { useEffect } from 'react';
import Button from '@/components/ui/button';

export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
// Log the error to an error reporting service
console.error('Dashboard error:', error);
}, [error]);

return (
<div className="error-container">
<h2>Something went wrong!</h2>
<p>The dashboard could not be loaded</p>
<Button onClick={() => reset()}>Try again</Button>
</div>
);
}

Not Found Pages

Create custom not-found.js files for 404 errors:

// app/dashboard/[id]/not-found.tsx
import Link from 'next/link';
import Button from '@/components/ui/button';

export default function ProductNotFound() {
return (
<div className="not-found">
<h2>Product Not Found</h2>
<p>Could not find the requested product</p>
<Link href="/dashboard">
<Button>Back to Dashboard</Button>
</Link>
</div>
);
}

Styling Approaches

We use the following styling approaches in order of preference:

  1. Mantine Props: For consistent UI across the app
  2. CSS Modules: For component-specific styling
  3. Global CSS: For theme variables and global styles

CSS Modules Example

// Button.module.css
.button {
padding: 0.5rem 1rem;
border-radius: 0.25rem;
font-weight: 600;
}

.primary {
background-color: var(--color-primary);
color: white;
}

.secondary {
background-color: var(--color-secondary);
color: white;
}

// Button.tsx
import styles from './Button.module.css';
import { cva } from 'class-variance-authority';

const buttonVariants = cva(styles.button, {
variants: {
variant: {
primary: styles.primary,
secondary: styles.secondary,
},
},
defaultVariants: {
variant: 'primary',
},
});

export function Button({
variant,
children,
...props
}) {
return (
<button className={buttonVariants({ variant })} {...props}>
{children}
</button>
);
}

Testing

For NextJS applications, we implement:

  1. Unit Tests: For testing utility functions and hooks
  2. Component Tests: For testing UI components
  3. Integration Tests: For testing pages and complex components
  4. E2E Tests: For testing critical user flows

Example Test with Jest and React Testing Library

// Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';

describe('Button', () => {
it('renders correctly', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});

it('handles click events', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});

it('renders different variants', () => {
const { rerender } = render(<Button variant="primary">Primary</Button>);
expect(screen.getByText('Primary')).toHaveClass('primary');

rerender(<Button variant="secondary">Secondary</Button>);
expect(screen.getByText('Secondary')).toHaveClass('secondary');
});
});

Conclusion

By following these best practices, we can ensure that our NextJS applications are performant, maintainable, and provide an excellent user experience. Always consider the server-first approach with React Server Components, and only use Client Components when necessary.

If you have questions about any of these practices or need help implementing them, reach out to your Tech Lead or the Kirana Labs Tech Teams group.