Mastering Next.js: Best Practices for Clean, Scalable, and Type-Safe Development

Hamza Malik
12 min readAug 28, 2024

--

In modern web development, Next.js has emerged as a powerful framework for building robust and dynamic web applications. Its flexibility, coupled with features like server-side rendering (SSR) and static site generation (SSG), has made it a go-to choice for developers looking to create high-performance websites. However, to truly harness the full potential of Next.js, it’s essential to adhere to best practices that ensure code quality, maintainability, and scalability. This article explores a comprehensive set of best practices, encompassing SOLID principles, TypeScript guidelines, and Next.js-specific strategies, to help you elevate your Next.js projects to new heights of excellence.

Photo by Kevin Bhagat on Unsplash

Chapter 1: Object-Oriented Principles in Next.js Development

Object-oriented programming (OOP) is a programming paradigm based on the concept of “objects,” which can contain data in the form of fields (attributes or properties) and code in the form of procedures (methods or functions). OOP principles help in designing and implementing software systems that are modular, flexible, and easy to maintain.

Encapsulation

  • Encapsulation is the bundling of data with the methods that operate on that data, or the restriction of direct access to some of an object’s components. It helps in hiding the internal state of an object and only exposing a controlled interface for interacting with it.
  • Think of a treasure chest with a lock. The lock encapsulates the treasure inside, allowing only those with the key (the method) to access it. This protects the treasure from being accessed or modified by unauthorized entities.
class Post {
constructor(title, content) {
this._title = title; // Encapsulated property
this._content = content; // Encapsulated property
}
  getTitle() {
return this._title; // Getter method
}
setContent(content) {
this._content = content; // Setter method
}
getContent() {
return this._content;
}
}

Abstraction

  • Abstraction is the process of hiding the complex implementation details and showing only the essential features of an object. It helps in managing complexity by focusing on what an object does rather than how it does it.
  • Consider a car dashboard. It provides essential information to the driver, such as speed, fuel level, and engine temperature, without revealing the internal mechanisms of the car. This abstraction allows the driver to focus on driving without being overwhelmed by unnecessary details.
class Post {
constructor(title, content) {
this.title = title;
this.content = content;
}
  displayPost() {
console.log(`Title: ${this.title}`);
console.log(`Content: ${this.content}`);
}
}

Inheritance

  • Inheritance is the mechanism where a new class derives attributes and methods from an existing class. It promotes code reusability and allows for the creation of a hierarchy of classes, where each subclass inherits properties and behaviors from its superclass.
  • Imagine a family tree where each generation inherits certain traits and characteristics from the previous one. Similarly, in OOP, a subclass inherits attributes and methods from its superclass, forming a hierarchy of classes.
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
  displayInfo() {
console.log(`Name: ${this.name}`);
console.log(`Age: ${this.age}`);
}
}
class User extends Person {
constructor(name, age, email) {
super(name, age); // Call the superclass constructor
this.email = email;
}
displayUser() {
super.displayInfo(); // Call the superclass method
console.log(`Email: ${this.email}`);
}
}

Polymorphism

  • Polymorphism allows objects of different classes to be treated as objects of a common superclass. It enables flexibility and extensibility in code by allowing methods to be defined in the superclass and overridden in the subclasses.
  • Think of a shape class with a method called “draw.” The draw method can be implemented differently for each shape subclass (e.g., circle, square, triangle), allowing each shape to be drawn in its unique way while still being treated as a shape.
class Admin extends User {
constructor(name, age, email, role) {
super(name, age, email);
this.role = role;
}
  displayUser() {
super.displayUser();
console.log(`Role: ${this.role}`);
}
}
class Guest extends User {
constructor(name, age, email, status) {
super(name, age, email);
this.status = status;
}
displayUser() {
super.displayUser();
console.log(`Status: ${this.status}`);
}
}

By applying these object-oriented principles in your Next.js development, you can design more modular, flexible, and maintainable code, making it easier to build and scale your applications.

Chapter 2: SOLID Principles

Single Responsibility Principle (SRP)

  • The SRP states that a class should have only one reason to change, meaning it should have only one responsibility or job. This principle helps in making classes more focused, easier to understand, and less prone to bugs.
  • Imagine a baker who is responsible for baking bread. If the baker suddenly decides to also take on the responsibility of delivering the bread to customers, the quality of the bread might suffer. By focusing on just baking, the baker can ensure that the bread is always baked to perfection.
  • Here is the example of SRP implementation in Next.js. I want to create a Card component. I will create a folder Card, that consists of the component itself and the necessary constants.
  1. Card.tsx as the component.
import React from "react";
import { CardProps } from "./constants";
const Card: React.FC<CardProps> = ({ title, desc, color }) => {
return (
<div
data-testid="card"
style={{ backgroundColor: `#${color}` }}
className="p-12 w-[348px] h-full lg:h-[438px] rounded-[30px] shadow-lg"
>
<h2 className="text-lg font-bold mb-3">{title}</h2>
<p>{desc}</p>
</div>
);
};
export default Card;

2. constants.ts as the constants places.

export type CardProps = {
title: string;
desc: string;
color: string;
};

By seperating those two, it will improve the overall readability of the code.

Open/Closed Principle (OCP)

  • The OCP states that software entities should be open for extension but closed for modification. This means that you should be able to add new functionality to a system without changing existing code.
  • Consider a car that is designed to be easily customizable with different accessories like a roof rack or spoiler. The car’s design allows these accessories to be added or removed without needing to modify the car’s core structure.
  • The implementation of OCP in Next.js is the use of component extension. We can extend a functional component and add new UI component without affecting the first/base component. That’s the implementation of open for extension, but close for modification.
# Card.tsx
type CardProps = {
title: string;
desc: string;
}
const Card: React.FC<CardProps> = ({title, desc}) => {
return (
<div>
<h1>{title}</h1>
<p>{desc}</p>
</div>
)
}
# ExtendedCard.tsx
type ExtendedCardProps = {
CardProps CardProps;
likes number;
}
const ExtendedCard: React.FC<ExtendedCardProps> = ({CardProps, likes}) => {
return (
<div>
<Card props={...CardProps} />
<p>{likes}</p>
</div>
)
}

ExtendedCard component extends the props and the component itself by adding new things into the Card component.

Liskov Substitution Principle (LSP)

  • The LSP states that objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program. In other words, subclasses should be able to substitute for their base classes without causing errors.
  • Think of a remote control car where you can swap out different types of batteries. As long as the batteries fit and provide the required power, you can use any brand or type of battery without affecting how the car operates.

Here is the example of LSP implementation in Next.js components. In this example, the DisabledButton component is a subclass of the Button component. It extends the functionality of the base Button component by adding the disabled attribute. Despite being a subclass, the DisabledButton component can be seamlessly substituted for the Button component without affecting the behavior of the application, thus adhering to the LSP.

// components/Button.tsx
import React from "react";
interface ButtonProps {
onClick: () => void;
}
const Button: React.FC<ButtonProps> = ({ onClick, children }) => {
return <button onClick={onClick}>{children}</button>;
};
export default Button;
// components/DisabledButton.tsx
import React from "react";
import Button from "./Button";
interface DisabledButtonProps {
onClick: () => void;
}
const DisabledButton: React.FC<DisabledButtonProps> = ({ onClick, children }) => {
return (
<Button onClick={onClick} disabled>
{children}
</Button>
);
};
export default DisabledButton;
// pages/index.tsx
import React from "react";
import Button from "../components/Button";
import DisabledButton from "../components/DisabledButton";
const Home: React.FC = () => {
const handleClick = () => {
console.log("Button clicked!");
};
return (
<div>
<h1>Hello, Next.js!</h1>
<Button onClick={handleClick}>Click me!</Button>
<DisabledButton onClick={handleClick}>Click me!</DisabledButton>
</div>
);
};
export default Home;

Interface Segregation Principle (ISP)

  • The ISP states that clients should not be forced to depend on interfaces they do not use. Instead, interfaces should be segregated into smaller, more focused interfaces that are specific to the client’s needs.
  • Imagine a smartphone that comes with a variety of apps pre-installed. Instead of having one app that does everything, each app is designed for a specific task, such as messaging, email, or maps, making the phone more efficient and easier to use.

In this example, the Card component follows the Interface Segregation Principle by depending only on the interfaces it needs (TextCardProps, ImageCardProps, VideoCardProps), allowing for better maintainability and flexibility in the application.

// Card/constants.ts
interface TextCardProps {
type: "text";
text: string;
}
interface ImageCardProps {
type: "image";
imageUrl: string;
}
interface VideoCardProps {
type: "video";
videoUrl: string;
}
type CardProps = TextCardProps | ImageCardProps | VideoCardProps;
// Card/Card.tsx
import React from "react";
import { CardProps } from "../interfaces";
const Card: React.FC<CardProps> = ({ type, ...props }) => {
switch (type) {
case "text":
return <div>{(props as TextCardProps).text}</div>;
case "image":
return <img src={(props as ImageCardProps).imageUrl} alt="Card" />;
case "video":
return <video src={(props as VideoCardProps).videoUrl} controls />;
default:
return null;
}
};
export default Card;
// pages/Home.tsx
import React from "react";
import Card from "../components/Card";
const Home: React.FC = () => {
return (
<div>
<h1>Cards</h1>
<Card type="text" text="This is a text card" />
<Card type="image" imageUrl="https://example.com/image.jpg" />
<Card type="video" videoUrl="https://example.com/video.mp4" />
</div>
);
};
export default Home;

Dependency Inversion Principle (DIP)

  • The DIP states that high-level modules should not depend on low-level modules. Instead, both should depend on abstractions. This helps in decoupling modules, making them more reusable and easier to test.
  • Consider a house built with bricks. Instead of each brick being dependent on the one below it, they all depend on the foundation. If the foundation is strong, the house remains stable even if individual bricks are replaced.

In Next.js, you can implement the Dependency Inversion Principle (DIP) by using React’s context API or dependency injection. Here, theStoreProvider component is responsible for providing the Redux store to the rest of the application. This allows to decouple the creation of the store from the components that use it, promoting easier testing and reusability.

// providers/StoreProvider.tsx
"use client";
import React, { useRef } from "react";
import { Provider } from "react-redux";
import { makeStore, AppStore } from "../context/hamburgerContext/store";
export default function StoreProvider({ children }: { children: React.ReactNode }) {
const storeRef = useRef<AppStore>();
if (!storeRef.current) {
// Create the store instance the first time this renders
storeRef.current = makeStore();
storeRef.current.dispatch({ type: "INITIALIZE_HAMBURGER" });
}
return <Provider store={storeRef.current}>{children}</Provider>;
}
// pages/_app.tsx
import "@/styles/globals.css";
import "@/styles/carousel.style.css";
import type { AppProps } from "next/app";
import TranslateProvider from "../providers/TranslateProvider";
import BaseLayout from "../components/elements/layout/BaseLayout";
import StoreProvider from "../providers/StoreProvider";
export default function App({ Component, pageProps }: AppProps) {
return (
<TranslateProvider>
<StoreProvider>
<BaseLayout>
<Component {...pageProps} />
</BaseLayout>
</StoreProvider>
</TranslateProvider>
);
}

Benefits of SOLID Principle Implementations

Implementing the SOLID principles in Next.js projects can bring several benefits, including:

  1. Improved Code Quality: By following SOLID principles, you can write cleaner, more organized code that is easier to understand, maintain, and debug. This leads to fewer bugs and faster development cycles.
  2. Better Scalability: SOLID principles encourage modular design, which makes it easier to add new features or scale your application as it grows. You can extend existing functionality without needing to modify the existing codebase, promoting code reuse and reducing the risk of introducing bugs.
  3. Enhanced Maintainability: With SOLID principles, each component of your Next.js project has a single responsibility, making it easier to isolate and fix issues when they arise. This also makes it easier for new developers to onboard and understand the codebase.
  4. Increased Flexibility: Following SOLID principles allows you to easily change or extend the behavior of your Next.js application without affecting other parts of the codebase. This flexibility is crucial for adapting to changing requirements or integrating new features.
  5. Easier Testing: SOLID principles promote code that is easier to test in isolation, leading to more reliable tests and a higher level of confidence in the correctness of your code. This is particularly important in Next.js projects, where complex interactions between components can make testing challenging.

Overall, implementing SOLID principles in Next.js projects can lead to more robust, maintainable, and scalable applications, ultimately improving the development experience and the quality of the final product.

Chapter 3: Next.js and Typescript Best Practices

In this chapter, we’ll delve into the best practices for using Next.js with TypeScript, combining the power of a robust React framework with the safety and clarity of static typing. We’ll explore how TypeScript enhances the development experience in Next.js, from setting up your project to structuring components and handling data. Let’s dive in!

1. Avoid any and type everything. Always declare variables or constants with a type other than any. When declaring variables or constants in Typescript without a typing, the typing of the variable/constant will be deduced by the value that gets assigned to it. In new projects, it is worth setting strict:true in the tsconfig.json file to enable all strict type checking options.

{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

2. Type Annotations for Props and State. When working with TypeScript in Next.js, it’s crucial to provide type annotations for both props and state in your components. This ensures that the data flowing into your components is of the correct type, reducing the chance of runtime errors.

Card component

CardProps type

3. Leverage Functional Components and React Hooks. Functional components and React hooks are a powerful combination in Next.js. Functional components are easier to read and maintain, while hooks like useState and useEffect provide a cleaner way to manage state and side effects.

example of our @/Nani functional component and using React hooks

4. Use TypeScript Utility Types (Partial, Required, Omit). TypeScript utility types such as Partial, Required, and Omit can help you create more precise type definitions. Partial allows you to make all properties of a type optional, Required ensures that all properties of a type are required, and Omit creates a new type by omitting specific properties from an existing type.

5. Set ESLint to Enable Code Formatting Consistency. ESLint is a code formatter that helps maintain consistent code style across your project. Setting it up in your Next.js project ensures that your code is formatted consistently, making it easier to read and maintain.

ESLint in our @/Nani Next.js project

6. Use “?” as Optional. In TypeScript, you can mark a property as optional by adding a “?” after its name in the type definition. This is useful when you have props or state properties that are not always required.

use of ? as an optional parameter

7. Conditional Rendering. Conditional rendering allows you to render different components or elements based on certain conditions. This can be done using standard JavaScript if statements or ternary operators within your JSX code.

Classname conditional rendering with ternary operator

8. Enable Global State Management with Redux to Avoid State Drilling. Redux is a popular state management library for React and Next.js applications. It allows you to maintain a global state that can be accessed from any component, avoiding the need to pass props down through multiple levels of the component tree (state drilling).

StoreProvider component for global state management

9. Separate Type, Interface, and Constants into a Single File. Keeping your type definitions, interfaces, and constants in a single file can help maintain a clean and organized project structure. This makes it easier to find and manage your type definitions as your project grows.

separate type and const from the component

10. Create a List of Items and Iterate Over Arrays for a List of Components. When rendering a list of items in Next.js, you can use the map function to iterate over an array and render a component for each item. This allows you to create dynamic lists that update automatically when the underlying data changes.

--

--

Hamza Malik
Hamza Malik

Written by Hamza Malik

0 Followers

Experienced Full-Stack Developer with over years of crafting seamless web experiences.

No responses yet