Skip to main content

Next.js Architecture Overview

Next.js is a popular React framework that provides a robust architecture for building modern web applications. Understanding its architecture is crucial for effectively utilizing the framework and making informed decisions about application structure.

Introduction

Next.js abstracts away much of the complexity of building React applications by providing a well-defined architecture with sensible defaults. It extends React's capabilities with features like file-system based routing, server-side rendering, and built-in API routes, all while maintaining a developer-friendly experience.

In this guide, we'll explore the core architectural components of Next.js and how they work together to create performant, scalable web applications.

Core Architectural Components

1. Hybrid Rendering Engine

Next.js provides multiple rendering strategies in a single framework:

  • Server-Side Rendering (SSR): Generates HTML on each request
  • Static Site Generation (SSG): Generates HTML at build time
  • Incremental Static Regeneration (ISR): Updates static pages after deployment
  • Client-Side Rendering: Traditional React rendering in the browser

Let's look at how these are implemented in code:

Server-Side Rendering (SSR)

jsx
// pages/ssr-example.js
export default function SSRPage({ data }) {
return (
<div>
<h1>Server-side Rendered Page</h1>
<p>Data from server: {data}</p>
</div>
);
}

// This runs on every request
export async function getServerSideProps() {
// Fetch data from an API
const res = await fetch('https://api.example.com/data');
const data = await res.json();

// Pass data to the page via props
return { props: { data } };
}

Static Site Generation (SSG)

jsx
// pages/ssg-example.js
export default function SSGPage({ data }) {
return (
<div>
<h1>Static Generated Page</h1>
<p>Pre-rendered data: {data}</p>
</div>
);
}

// This runs at build time
export async function getStaticProps() {
// Fetch data from an API
const res = await fetch('https://api.example.com/data');
const data = await res.json();

// Pass data to the page via props
return { props: { data } };
}

Incremental Static Regeneration (ISR)

jsx
// pages/isr-example.js
export default function ISRPage({ data, time }) {
return (
<div>
<h1>Incremental Static Regeneration</h1>
<p>Data: {data}</p>
<p>Generated at: {time}</p>
</div>
);
}

export async function getStaticProps() {
const res = await fetch('https://api.example.com/data');
const data = await res.json();

return {
props: {
data,
time: new Date().toISOString(),
},
// Re-generate page at most once every 60 seconds
revalidate: 60
};
}

2. File-system Based Routing

Next.js uses the file system to define routes, which makes the architecture more intuitive and easier to maintain:

pages/
├── index.js # Route: /
├── about.js # Route: /about
├── blog/
│ ├── index.js # Route: /blog
│ └── [slug].js # Route: /blog/:slug (dynamic)
└── api/
└── hello.js # API Route: /api/hello

For a dynamic route like a blog post page:

jsx
// pages/blog/[slug].js
import { useRouter } from 'next/router';

export default function BlogPost({ post }) {
const router = useRouter();

// Handle the case where data is still being fetched
if (router.isFallback) {
return <div>Loading...</div>;
}

return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}

export async function getStaticPaths() {
// Fetch list of all blog posts
const res = await fetch('https://api.example.com/posts');
const posts = await res.json();

// Get the paths we want to pre-render
const paths = posts.map((post) => ({
params: { slug: post.slug }
}));

return {
paths,
fallback: true // Enable fallback for paths not generated at build time
};
}

export async function getStaticProps({ params }) {
// Fetch post data based on slug
const res = await fetch(`https://api.example.com/posts/${params.slug}`);
const post = await res.json();

return {
props: { post }
};
}

3. API Routes

Next.js integrates API routes directly into the application, allowing you to build full-stack applications without separate backend services:

jsx
// pages/api/users.js
export default async function handler(req, res) {
const { method } = req;

switch (method) {
case 'GET':
// Get data from your database
res.status(200).json({ users: ['John', 'Jane', 'Bob'] });
break;
case 'POST':
// Create new user in your database
const { name } = req.body;
// Insert logic here
res.status(201).json({ message: `User ${name} created` });
break;
default:
res.setHeader('Allow', ['GET', 'POST']);
res.status(405).end(`Method ${method} Not Allowed`);
}
}

4. Built-in Optimizations

Next.js includes several built-in optimizations:

Automatic Image Optimization

jsx
// Using the Image component for automatic optimization
import Image from 'next/image';

export default function ProfilePage() {
return (
<div>
<h1>User Profile</h1>
<Image
src="/profile.jpg"
alt="User profile picture"
width={300}
height={300}
priority
/>
</div>
);
}

Font Optimization

jsx
// pages/_document.js
import Document, { Html, Head, Main, NextScript } from 'next/document';

class MyDocument extends Document {
render() {
return (
<Html>
<Head>
<link
href="https://fonts.googleapis.com/css2?family=Inter&display=swap"
rel="stylesheet"
/>
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
}

export default MyDocument;

Next.js Application Flow

To understand Next.js architecture more fully, let's examine how a request flows through a Next.js application:

  1. Client sends request: A user navigates to a page in your Next.js app.

  2. Server receives request: The request is received by the Next.js server.

  3. Route resolution: Next.js looks up the corresponding page in the file system.

  4. Data fetching (if needed): For SSR or SSG pages, Next.js executes getServerSideProps or getStaticProps.

  5. React rendering: Next.js renders the React components with any fetched data.

  6. HTML generation: The rendered components are converted to HTML.

  7. Response to client: The HTML, along with JavaScript bundles and other assets, is sent to the client.

  8. Hydration: On the client, React "hydrates" the static HTML, making it interactive.

This process may vary depending on the rendering strategy used for a specific page.

Real-World Application Architecture

Let's look at a practical example of how a Next.js application might be structured:

my-nextjs-app/
├── components/ # Reusable React components
│ ├── Layout.js # Main layout component
│ ├── Navbar.js # Navigation bar
│ └── Footer.js # Footer component
├── pages/ # Routes and API endpoints
│ ├── _app.js # Custom App component
│ ├── index.js # Home page
│ ├── products/
│ │ ├── index.js # Products listing page
│ │ └── [id].js # Single product page
│ └── api/
│ └── products.js # Products API endpoint
├── public/ # Static files
│ ├── images/ # Image assets
│ └── favicon.ico # Favicon
├── styles/ # CSS styles
│ └── globals.css # Global styles
├── lib/ # Utility functions and shared logic
│ └── db.js # Database connection utilities
├── hooks/ # Custom React hooks
│ └── useUser.js # User authentication hook
├── context/ # React context providers
│ └── AuthContext.js # Authentication context
└── next.config.js # Next.js configuration

In this structure:

  • Pages directory defines the routes of your application
  • Components directory contains reusable React components
  • API directory defines serverless functions for backend functionality
  • Public directory holds static assets
  • Styles directory contains CSS files
  • Lib directory includes utility functions and shared logic

Practical Example: E-commerce Product Page

Let's build a simple product page to demonstrate the Next.js architecture in action:

jsx
// pages/products/[id].js
import { useState } from 'react';
import Head from 'next/head';
import Image from 'next/image';
import Layout from '../../components/Layout';

export default function ProductPage({ product }) {
const [quantity, setQuantity] = useState(1);

const addToCart = () => {
console.log(`Added ${quantity} of ${product.name} to cart`);
// Implementation would typically use context or state management
};

return (
<Layout>
<Head>
<title>{product.name} | My Store</title>
<meta name="description" content={product.description.substring(0, 160)} />
</Head>

<div className="product-container">
<div className="product-image">
<Image
src={product.image}
alt={product.name}
width={500}
height={500}
priority
/>
</div>

<div className="product-details">
<h1>{product.name}</h1>
<p className="price">${product.price.toFixed(2)}</p>
<div className="description">
<h3>Description:</h3>
<p>{product.description}</p>
</div>

<div className="actions">
<div className="quantity">
<label htmlFor="quantity">Quantity:</label>
<input
type="number"
id="quantity"
min="1"
value={quantity}
onChange={(e) => setQuantity(parseInt(e.target.value))}
/>
</div>

<button
className="add-to-cart"
onClick={addToCart}
>
Add to Cart
</button>
</div>
</div>
</div>
</Layout>
);
}

// Get product data at build time when possible
export async function getStaticProps({ params }) {
try {
// In a real app, this would fetch from an actual API or database
const response = await fetch(`https://api.mystore.com/products/${params.id}`);

if (!response.ok) {
// No product found, return 404 page
return { notFound: true };
}

const product = await response.json();

return {
props: {
product,
},
// Revalidate every hour
revalidate: 3600,
};
} catch (error) {
console.error('Error fetching product:', error);
return { notFound: true };
}
}

// Generate paths for all products at build time
export async function getStaticPaths() {
// Fetch top products to pre-render
const response = await fetch('https://api.mystore.com/products/top');
const topProducts = await response.json();

const paths = topProducts.map((product) => ({
params: { id: product.id.toString() },
}));

return {
paths,
fallback: 'blocking', // Generate other pages on demand
};
}

This example demonstrates:

  1. Dynamic routing with [id].js
  2. Data fetching with getStaticProps and getStaticPaths
  3. Incremental Static Regeneration with revalidate
  4. Image optimization with next/image
  5. Head management with next/head
  6. Client-side state with React's useState
  7. Layout composition using components

Summary

Next.js provides a robust architecture that combines the best of server-side and client-side rendering approaches. Its key architectural components include:

  • Hybrid rendering engine that supports SSR, SSG, ISR, and client-side rendering
  • File-system based routing for intuitive page organization
  • Integrated API routes for serverless backend functionality
  • Built-in optimizations for images, fonts, and JavaScript
  • Component-based structure inherited from React

Understanding this architecture allows you to make informed decisions about how to structure your Next.js applications and leverage the framework's features effectively.

Additional Resources

To deepen your understanding of Next.js architecture, check out these resources:

Exercises

  1. Rendering Comparison: Create a simple page using all four rendering methods (SSR, SSG, ISR, and CSR). Compare the performance and use cases for each.

  2. API Integration: Build a Next.js application that fetches data from a public API using both client-side and server-side approaches. Compare the differences in user experience.

  3. Full-Stack Feature: Implement a "contact form" that uses Next.js API routes to store submissions in a database or send emails.

  4. Performance Optimization: Take an existing Next.js application and optimize it using built-in features like Image optimization, font optimization, and proper code splitting.

Remember, the true power of Next.js comes from understanding its architecture and knowing which features to use for specific requirements in your applications.



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)