Overview

SEO-friendly React

Next.js serves content-complete HTML pages to clients and crawlers, instead of the empty-shell SPAs pages built by Vite or Create React App, which are served mostly empty.

Such complete pages rank better in search-engines results and in chatbots results.

other benefits

  • built-in router that offers client-side navigation when appropriate.
  • static page generation at build time when appropriate.
  • ubiquitous, widespread support, known to deploy platforms and LLMs.

temporary scope of this guide: Pages router

The guide currently focuses on the Pages router. It excludes the App router and React server components (RSC).

Initialization

We scaffold the project with create-next-app:

npx create-next-app

We pick options such as:

  • using TypeScript or JavaScript
  • using a linter
  • using Tailwind CSS
  • adding a src/ directory, or everything at root level
  • picking between the pages router or the app router

File-based routing

We describe the pages router, which works with a pages/ directory.

pages directory content

The pages/ directory contains:

  • route files and route directories
  • wildcard files such as [book].tsx
  • special files (see Special files)

route files and route directories

A route file creates a route of the same name. E.g. for pages/users.tsx:

abc.com/users

If the file is in a nested directory, the directory is also part of the path. E.g. for pages/settings/main.tsx:

abc.com/settings/main

Index files don't add to the path. Instead, they match the path of their parent. E.g. for pages/users/index.tsx:

abc.com/users

route conflict

As seen above, a route file can clash with a nested index file:

users.tsx
users/index.tsx
## abc.com/users

Next.js detects such conflicts and requests us to keep only one of those files.

Special files

_app.tsx and _document.tsx are special files in pages/. They are optional for the developer: If we don't provide/implement them, Next.js provides them in their default version.

_app.tsx

It serves as a global wrapper: it matches every routes.

import global stylesheets

It is the designated file to import global stylesheets, which Next.js only allows in this file:

import "../styles/global.css"

load local fonts

We may import and expose a local font: see Expose a local font.

favicon

We can switch between separate favicon dynamically.

boilerplate

We must add some boilerplate: The file must have a default export and feed pageProps to Component.

export default function App({ Component, pageProps }: AppProps) {
    return (
        <>
            <Component {...pageProps} />
        </>
    )
}

_document.tsx

It serves a limited role. Since we have control over the <html> element, we can

  • add the lang attribute.
  • import Google fonts outside of Next.js control.
  • import a static favicon.

Next.js does not expose an index.html file. _document.tsx is the only place where we have direct control over the <html> and <body> elements, though technically we rely on <Html> and other Next.js provided wrappers, and the content we set in <Head> is not exhaustive.

import { Html, Head, Main, NextScript } from "next/document"
const FAVICON_URL = "/favicon.ico"

export default function Document() {
    return (
        <Html lang="en">
            <Head>
                <link
                    href="https://fonts.googleapis.com/css2?family=PT+Mono&display=swap"
                    rel="stylesheet"
                />
                <link rel="icon" href={FAVICON_URL} />
            </Head>
            <body>
                <Main />
                <NextScript />
            </body>
        </Html>
    )
}

Build pages programmatically

programmatic pages : pages that don't match a route file

Route files hardcode a page, one that match their name: one page for one file.

Sometimes, we want to create pages programmatically.

from slugs to pages

The goal is to build pages programmatically, from a data-source, that provides slugs for the pages along with their content. The data source can be any data: local files or data received from a server.

We determine:

  • a list of pages
  • where they stand in the URL
  • their content
  • which React component they use.

pipeline overview

  • We determine where the generated pages live in the URL. If we want them to live scoped under the books/ path, we create a books/ directory.

  • We then create a (wildcard) React file under that directory, with a special name that uses brackets, such as [book].tsx, to signal a special page.

    • (Optional) We can also add a wildcard directory in addition to the wildcard file, such as [book]/[chapter].tsx. In this scenario, we generate a list of books programmatically, each one have its own list of chapters generated programmatically.
  • In this file, we read the data-source to determine a list of slugs that we are to make pages for. For example, we read a list of markdown files and compute a list of slugs from them. We register the slug list.

  • Then, we establish a data-gathering function that fetches data according to a slug, and returns the data. We must return the data in a specific, structured format. For example, we reads a markdown file and returns its content in a structured object.

    • Implementation detail: the structured object has a root props property, which itself is an object as well, and as its name implies, hold a series of props, each one being given to the template React component as a React prop.
  • Finally, we default export a React component to make it become the template React component. It receives the gathered data . It can set <title> in <Head> to ensure the generated page has an appropriate title.

other details

  • The pipeline runs at build-time.
  • Next.js ensures there is a fully-fledged HTML page for each slug.

determine and provide the slugs

What Next.js expects: an array of paths (paths), each member being a path and about to receive its own page.

Each path has a params object, which comes with the slug(s).

The single slug scenario is when the path is made of a single slug. We provide the slug, keyed to the wildcard file name, such as book for [book].tsx.

//  [
//    { params: { book: "typescript"}},
//    { params: { book: "javascript"}},
//    { params: { book: "clang"}}
//  ]

Note: if we provide several slugs in the params object, it denotes that at least one slug is to match a wildcard directory, in addition to the slug matching the wildcard file.

provide the slugs (implementation example)

[book].tsx

export function getStaticPaths() {
  	// make use of a data source, usually an array
    const books = getBooks() 

    // build the array of paths, here by looping over the data source array
    const paths = books.map((book) => ({
        params: { book: book.slug },
    }))
		
    // return it, along with options
    return { paths, fallback: false }
}

gather data for a given slug

getStaticProps is expected to return this shape, with props being the main content, a series of props given to the React component:

// {
//     props: {
//         book: {
//             title: book.title,
//             slug: book.slug,
//             author: book.author,
//             lastUpdate: book.lastUpdate,
//         },
//     },
// )

[book].tsx

export async function getStaticProps({ params }: { params: { book: string } }): Promise<{
    props: BookUIProps
}> {
  	// 1.0 get slug from params (book name)
    const bookSlug = params.book
    
    // 2.0 fetch data according to the slug (book content)
    const books = getBooks()
    const book = books.find((b) => b.slug === bookSlug)
    if (!book) throw new Error(`Book not found: ${bookSlug}`)
    const data = book.data
		
    // 3.0 returned the object with props
    return {
        props: {
            book: {
                title: book.title,
                slug: book.slug,
                author: book.author,
                lastUpdate: book.lastUpdate,
            },
        },
    }
}

make use of the data

[book].tsx

export default function BookUI({ book }) {
    const { title, slug, author, lastUpdate } = book
    return <>{/*...*/}</>
}

Local fonts

overview and setup

We place the font files, such as the woff2 files, in a directory such as src/fonts.

We provide them to Next.js with localFont({}). We call localFont({}) in _app.tsx, and provide the paths to the fonts in a src array. We also set the name of the CSS variable that will hold the font stack in variable.

We assign the result to a variable, whose name is important: it determines the CSS-facing font-name, that is, the one declared in @font-face and that we may use in font-family:

import localFont from "next/font/local"

const altinnDin = localFont({
    src: [
        {
            path: "../fonts/Altinn-DINExp.woff2",
            weight: "400",
            style: "normal",
        },
        {
            path: "../fonts/Altinn-DINExp-Italic.woff2",
            weight: "400",
            style: "italic",
        },
        {
            path: "../fonts/Altinn-DINExp-Bold.woff2",
            weight: "700",
            style: "normal",
        },
    ],
    variable: "--font-altinn-din", // set the CSS variable's name.
})

Next.js generates the @font-face rules to import each face. It sets font-display: swap by default. To remove layout shift on font swap, it modifies the fallback font such as Arial to fit the metrics of the local font we added.

@font-face {
    font-family: altinnDin;
    src: url("../media/Altinn_DINExp-s.p.61719b9f.woff2") format("woff2");
    font-display: swap;
    font-weight: 400;
    font-style: normal;
}

set the font immediately with a Next.js-generated class

Next.js generates a CSS class, which, when added to an element, makes it use the local font (along with a fallback font). The class becomes available at myFont.className.

.altinndin_6897fd0e-module__QFpdhW__className {
    font-family: altinnDin, altinnDin Fallback;
}

If we add the class to a high-level wrapper, the font effectively becomes the site's global font. For example, in _app.tsx:

<div className={altinnDin.className}>
    <Component {...pageProps} />
</div>

expose the font as a CSS variable, by using a Next.js-generated class

The class, when added to an element, merely defines a CSS variable, whose name was defined in localFont({}). The class becomes available at myFont.variable.

.altinndin_6897fd0e-module__QFpdhW__variable {
    --font-altinn-din: "altinnDin", "altinnDin Fallback";
}

We add the class to a high-level wrapper. For example, in _app.tsx:

<div className={altinnDin.variable}>
    <Component {...pageProps} />
</div>

It's up to elements to set font-family with that variable:

h1 {
    font-family: var(--font-altinn-din);
}

Misc

shorten import paths

We want to prevent overly long import paths, caused by deep file nesting. Instead, we want to start a path straight from the src directory.

We cannot start the path with the src string, because it would refer to a node package from node_modules. We cannot start it with /src because it would refer to the host computer's root directory. Instead, the convention is to create a hardcoded alias using the @ symbol instead, which refers to the src directory.

import { x } from "@/types/foo"
import { x } from "../../../../types/foo"

We define the path alias(es) in tsconfig.json, and Next.js automatically parses them and registers them.

{
    "compilerOptions": {
        "paths": {
            "@/*": ["./src/*"]
        }
    }
}
earlymorning logo

© Antoine Weber 2026 - All rights reserved

Overview

SEO-friendly React

Next.js serves content-complete HTML pages to clients and crawlers, instead of the empty-shell SPAs pages built by Vite or Create React App, which are served mostly empty.

Such complete pages rank better in search-engines results and in chatbots results.

other benefits

  • built-in router that offers client-side navigation when appropriate.
  • static page generation at build time when appropriate.
  • ubiquitous, widespread support, known to deploy platforms and LLMs.

temporary scope of this guide: Pages router

The guide currently focuses on the Pages router. It excludes the App router and React server components (RSC).

Initialization

We scaffold the project with create-next-app:

npx create-next-app

We pick options such as:

  • using TypeScript or JavaScript
  • using a linter
  • using Tailwind CSS
  • adding a src/ directory, or everything at root level
  • picking between the pages router or the app router

File-based routing

We describe the pages router, which works with a pages/ directory.

pages directory content

The pages/ directory contains:

  • route files and route directories
  • wildcard files such as [book].tsx
  • special files (see Special files)

route files and route directories

A route file creates a route of the same name. E.g. for pages/users.tsx:

abc.com/users

If the file is in a nested directory, the directory is also part of the path. E.g. for pages/settings/main.tsx:

abc.com/settings/main

Index files don't add to the path. Instead, they match the path of their parent. E.g. for pages/users/index.tsx:

abc.com/users

route conflict

As seen above, a route file can clash with a nested index file:

users.tsx
users/index.tsx
## abc.com/users

Next.js detects such conflicts and requests us to keep only one of those files.

Special files

_app.tsx and _document.tsx are special files in pages/. They are optional for the developer: If we don't provide/implement them, Next.js provides them in their default version.

_app.tsx

It serves as a global wrapper: it matches every routes.

import global stylesheets

It is the designated file to import global stylesheets, which Next.js only allows in this file:

import "../styles/global.css"

load local fonts

We may import and expose a local font: see Expose a local font.

favicon

We can switch between separate favicon dynamically.

boilerplate

We must add some boilerplate: The file must have a default export and feed pageProps to Component.

export default function App({ Component, pageProps }: AppProps) {
    return (
        <>
            <Component {...pageProps} />
        </>
    )
}

_document.tsx

It serves a limited role. Since we have control over the <html> element, we can

  • add the lang attribute.
  • import Google fonts outside of Next.js control.
  • import a static favicon.

Next.js does not expose an index.html file. _document.tsx is the only place where we have direct control over the <html> and <body> elements, though technically we rely on <Html> and other Next.js provided wrappers, and the content we set in <Head> is not exhaustive.

import { Html, Head, Main, NextScript } from "next/document"
const FAVICON_URL = "/favicon.ico"

export default function Document() {
    return (
        <Html lang="en">
            <Head>
                <link
                    href="https://fonts.googleapis.com/css2?family=PT+Mono&display=swap"
                    rel="stylesheet"
                />
                <link rel="icon" href={FAVICON_URL} />
            </Head>
            <body>
                <Main />
                <NextScript />
            </body>
        </Html>
    )
}

Build pages programmatically

programmatic pages : pages that don't match a route file

Route files hardcode a page, one that match their name: one page for one file.

Sometimes, we want to create pages programmatically.

from slugs to pages

The goal is to build pages programmatically, from a data-source, that provides slugs for the pages along with their content. The data source can be any data: local files or data received from a server.

We determine:

  • a list of pages
  • where they stand in the URL
  • their content
  • which React component they use.

pipeline overview

  • We determine where the generated pages live in the URL. If we want them to live scoped under the books/ path, we create a books/ directory.

  • We then create a (wildcard) React file under that directory, with a special name that uses brackets, such as [book].tsx, to signal a special page.

    • (Optional) We can also add a wildcard directory in addition to the wildcard file, such as [book]/[chapter].tsx. In this scenario, we generate a list of books programmatically, each one have its own list of chapters generated programmatically.
  • In this file, we read the data-source to determine a list of slugs that we are to make pages for. For example, we read a list of markdown files and compute a list of slugs from them. We register the slug list.

  • Then, we establish a data-gathering function that fetches data according to a slug, and returns the data. We must return the data in a specific, structured format. For example, we reads a markdown file and returns its content in a structured object.

    • Implementation detail: the structured object has a root props property, which itself is an object as well, and as its name implies, hold a series of props, each one being given to the template React component as a React prop.
  • Finally, we default export a React component to make it become the template React component. It receives the gathered data . It can set <title> in <Head> to ensure the generated page has an appropriate title.

other details

  • The pipeline runs at build-time.
  • Next.js ensures there is a fully-fledged HTML page for each slug.

determine and provide the slugs

What Next.js expects: an array of paths (paths), each member being a path and about to receive its own page.

Each path has a params object, which comes with the slug(s).

The single slug scenario is when the path is made of a single slug. We provide the slug, keyed to the wildcard file name, such as book for [book].tsx.

//  [
//    { params: { book: "typescript"}},
//    { params: { book: "javascript"}},
//    { params: { book: "clang"}}
//  ]

Note: if we provide several slugs in the params object, it denotes that at least one slug is to match a wildcard directory, in addition to the slug matching the wildcard file.

provide the slugs (implementation example)

[book].tsx

export function getStaticPaths() {
  	// make use of a data source, usually an array
    const books = getBooks() 

    // build the array of paths, here by looping over the data source array
    const paths = books.map((book) => ({
        params: { book: book.slug },
    }))
		
    // return it, along with options
    return { paths, fallback: false }
}

gather data for a given slug

getStaticProps is expected to return this shape, with props being the main content, a series of props given to the React component:

// {
//     props: {
//         book: {
//             title: book.title,
//             slug: book.slug,
//             author: book.author,
//             lastUpdate: book.lastUpdate,
//         },
//     },
// )

[book].tsx

export async function getStaticProps({ params }: { params: { book: string } }): Promise<{
    props: BookUIProps
}> {
  	// 1.0 get slug from params (book name)
    const bookSlug = params.book
    
    // 2.0 fetch data according to the slug (book content)
    const books = getBooks()
    const book = books.find((b) => b.slug === bookSlug)
    if (!book) throw new Error(`Book not found: ${bookSlug}`)
    const data = book.data
		
    // 3.0 returned the object with props
    return {
        props: {
            book: {
                title: book.title,
                slug: book.slug,
                author: book.author,
                lastUpdate: book.lastUpdate,
            },
        },
    }
}

make use of the data

[book].tsx

export default function BookUI({ book }) {
    const { title, slug, author, lastUpdate } = book
    return <>{/*...*/}</>
}

Local fonts

overview and setup

We place the font files, such as the woff2 files, in a directory such as src/fonts.

We provide them to Next.js with localFont({}). We call localFont({}) in _app.tsx, and provide the paths to the fonts in a src array. We also set the name of the CSS variable that will hold the font stack in variable.

We assign the result to a variable, whose name is important: it determines the CSS-facing font-name, that is, the one declared in @font-face and that we may use in font-family:

import localFont from "next/font/local"

const altinnDin = localFont({
    src: [
        {
            path: "../fonts/Altinn-DINExp.woff2",
            weight: "400",
            style: "normal",
        },
        {
            path: "../fonts/Altinn-DINExp-Italic.woff2",
            weight: "400",
            style: "italic",
        },
        {
            path: "../fonts/Altinn-DINExp-Bold.woff2",
            weight: "700",
            style: "normal",
        },
    ],
    variable: "--font-altinn-din", // set the CSS variable's name.
})

Next.js generates the @font-face rules to import each face. It sets font-display: swap by default. To remove layout shift on font swap, it modifies the fallback font such as Arial to fit the metrics of the local font we added.

@font-face {
    font-family: altinnDin;
    src: url("../media/Altinn_DINExp-s.p.61719b9f.woff2") format("woff2");
    font-display: swap;
    font-weight: 400;
    font-style: normal;
}

set the font immediately with a Next.js-generated class

Next.js generates a CSS class, which, when added to an element, makes it use the local font (along with a fallback font). The class becomes available at myFont.className.

.altinndin_6897fd0e-module__QFpdhW__className {
    font-family: altinnDin, altinnDin Fallback;
}

If we add the class to a high-level wrapper, the font effectively becomes the site's global font. For example, in _app.tsx:

<div className={altinnDin.className}>
    <Component {...pageProps} />
</div>

expose the font as a CSS variable, by using a Next.js-generated class

The class, when added to an element, merely defines a CSS variable, whose name was defined in localFont({}). The class becomes available at myFont.variable.

.altinndin_6897fd0e-module__QFpdhW__variable {
    --font-altinn-din: "altinnDin", "altinnDin Fallback";
}

We add the class to a high-level wrapper. For example, in _app.tsx:

<div className={altinnDin.variable}>
    <Component {...pageProps} />
</div>

It's up to elements to set font-family with that variable:

h1 {
    font-family: var(--font-altinn-din);
}

Misc

shorten import paths

We want to prevent overly long import paths, caused by deep file nesting. Instead, we want to start a path straight from the src directory.

We cannot start the path with the src string, because it would refer to a node package from node_modules. We cannot start it with /src because it would refer to the host computer's root directory. Instead, the convention is to create a hardcoded alias using the @ symbol instead, which refers to the src directory.

import { x } from "@/types/foo"
import { x } from "../../../../types/foo"

We define the path alias(es) in tsconfig.json, and Next.js automatically parses them and registers them.

{
    "compilerOptions": {
        "paths": {
            "@/*": ["./src/*"]
        }
    }
}