Overview

SEO-friendly React

Next.js serves content-complete HTML pages to clients and crawlers, instead of the traditional empty HTML-shell SPAs built by Vite or, before that, Create React App.

Such complete pages may 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 when appropriate.
  • ubiquitous and widespread support, by both deploy platforms and LLMs.

temporary scope of this guide: Pages router

For now, the guide only focuses on the Pages router version of Next.js. It excludes the App router and React server components (RSC).

Initialization

We may scaffold the project with the create-next-app helper:

npx create-next-app

It prompts us to pick options such as:

  • turbopack instead of webpack
  • typescript
  • a linter
  • Tailwind CSS
  • a src/ directory, or everything at root level
  • the pages router or the app router

File-based routing

pages router

The pages directory holds routes as files and nested files.

A file creates a route of the same name. For example, users.tsx in pages/ creates the users route.

users

If the file lives in a nested directory, the directory names appear in the path. E.g. for settings/main.tsx:

settings/main

Index files don't come with a name. Instead, they match the path of their parent. E.g. for users/index.tsx:

users ## index file for users/

route conflict

As seen above, a standard route may clash with one created by a nested index:

users ## users.tsx
users ## users/index.tsx

Next.js detects such conflict and requests us to resolve it.

Special files

_app.tsx and _document.tsx are two special files, that we may optionally provide. In absence, Next.js provides its own version of those files. They both live in pages/.

_app.tsx

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

It is the designated file to import global stylesheets, which Next.js doesn't allow elsewhere:

import "../styles/global.css"

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

We may use distinct favicons dynamically.

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 may add the lang attribute. We may also import Google fonts outside of Next.js control. Finally, we may also 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 from a data-source

from slugs to pages

The aim is to build pages from a data-source other than source-code files. The data source may be some local files or some data served by a remote server. We determine a list of pages, their content, where should they exist in the URL, and which React component they use.

pipeline overview

The pipeline runs at build-time. The whole process may be described as so:

  • We determine on which route the generated pages will live. We create a React file at the correct location in the file tree, and we give it a special name, such as [chapter].tsx to signal a special page.
  • We read the data-source to determine and register a list of slugs that we are about to make pages from. For example, we read a list of markdown files and determine a slug for each, and we register the list.
  • Then, we establish a data-gathering function that fetches data according to the provided slug, and returns it as a structured object. For example, it reads a markdown file and returns its content in a structured object.
    • Implementation detail: Such object has a props property, which itself is an object as well, and each property it holds is set to be given to the designated React component as a regular React prop.
  • Finally, we designate a React component to be used as a template, as it receives the gathered data and make use of it. It may notably set <title> in <Head> to ensure the generated page has an appropriate title.
  • Next.js ensures there is a fully-fledged HTML page for each slug.

determine the slugs

What is expected: an array of objects, each one representing a page. Each page comes with a params object. This object comes with one or more slugs. The single slug scenario is the most simple to reason about. We provide the slug as a property, whose name may be semantic or not, but the property name has to match the file, e.g. book for [book].tsx.

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

[book].tsx

export function getStaticPaths() {
    const books = getBooks()

    const paths = books.map((book) => ({
        params: { book: book.slug },
    }))

    return { paths, fallback: false }
}

gather data for a given slug

getStaticProps is expected to return this kind of structure:

// {
//     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
}> {
    const { book: bookSlug } = params
    const books = getBooks()
    const book = books.find((b) => b.slug === bookSlug)
    if (!book) {
        throw new Error(`Book not found: ${bookSlug}`)
    }
    const data = book.data // gather data here

    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 traditional empty HTML-shell SPAs built by Vite or, before that, Create React App.

Such complete pages may 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 when appropriate.
  • ubiquitous and widespread support, by both deploy platforms and LLMs.

temporary scope of this guide: Pages router

For now, the guide only focuses on the Pages router version of Next.js. It excludes the App router and React server components (RSC).

Initialization

We may scaffold the project with the create-next-app helper:

npx create-next-app

It prompts us to pick options such as:

  • turbopack instead of webpack
  • typescript
  • a linter
  • Tailwind CSS
  • a src/ directory, or everything at root level
  • the pages router or the app router

File-based routing

pages router

The pages directory holds routes as files and nested files.

A file creates a route of the same name. For example, users.tsx in pages/ creates the users route.

users

If the file lives in a nested directory, the directory names appear in the path. E.g. for settings/main.tsx:

settings/main

Index files don't come with a name. Instead, they match the path of their parent. E.g. for users/index.tsx:

users ## index file for users/

route conflict

As seen above, a standard route may clash with one created by a nested index:

users ## users.tsx
users ## users/index.tsx

Next.js detects such conflict and requests us to resolve it.

Special files

_app.tsx and _document.tsx are two special files, that we may optionally provide. In absence, Next.js provides its own version of those files. They both live in pages/.

_app.tsx

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

It is the designated file to import global stylesheets, which Next.js doesn't allow elsewhere:

import "../styles/global.css"

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

We may use distinct favicons dynamically.

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 may add the lang attribute. We may also import Google fonts outside of Next.js control. Finally, we may also 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 from a data-source

from slugs to pages

The aim is to build pages from a data-source other than source-code files. The data source may be some local files or some data served by a remote server. We determine a list of pages, their content, where should they exist in the URL, and which React component they use.

pipeline overview

The pipeline runs at build-time. The whole process may be described as so:

  • We determine on which route the generated pages will live. We create a React file at the correct location in the file tree, and we give it a special name, such as [chapter].tsx to signal a special page.
  • We read the data-source to determine and register a list of slugs that we are about to make pages from. For example, we read a list of markdown files and determine a slug for each, and we register the list.
  • Then, we establish a data-gathering function that fetches data according to the provided slug, and returns it as a structured object. For example, it reads a markdown file and returns its content in a structured object.
    • Implementation detail: Such object has a props property, which itself is an object as well, and each property it holds is set to be given to the designated React component as a regular React prop.
  • Finally, we designate a React component to be used as a template, as it receives the gathered data and make use of it. It may notably set <title> in <Head> to ensure the generated page has an appropriate title.
  • Next.js ensures there is a fully-fledged HTML page for each slug.

determine the slugs

What is expected: an array of objects, each one representing a page. Each page comes with a params object. This object comes with one or more slugs. The single slug scenario is the most simple to reason about. We provide the slug as a property, whose name may be semantic or not, but the property name has to match the file, e.g. book for [book].tsx.

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

[book].tsx

export function getStaticPaths() {
    const books = getBooks()

    const paths = books.map((book) => ({
        params: { book: book.slug },
    }))

    return { paths, fallback: false }
}

gather data for a given slug

getStaticProps is expected to return this kind of structure:

// {
//     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
}> {
    const { book: bookSlug } = params
    const books = getBooks()
    const book = books.find((b) => b.slug === bookSlug)
    if (!book) {
        throw new Error(`Book not found: ${bookSlug}`)
    }
    const data = book.data // gather data here

    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/*"]
        }
    }
}