How to generate a menu based on the files in the pages directory in Next.js

9.1k views Asked by At

I am trying to create a menu component that reads the contents of the pages folder at build time. However I haven't had any success. Here is what I have tried:

import path from "path";
import * as ChangeCase from "change-case";

export default class Nav extends React.Component {
    render() {
        return (
            <nav>
                {this.props.pages.map((page) => (
                    <a href={page.link}>{page.name}</a>
                ))}
            </nav>
        );
    }

    async getStaticProps() {
        let files = fs.readdirSync("../pages");
        files = files.filter((file) => {
            if (file == "_app.js") return false;
            const stat = fs.lstatSync(file);
            return stat.isFile();
        });

        const pages = files.map((file) => {
            if (file == "index.js") {
                const name = "home";
                const link = "/";
            } else {
                const link = path.parse(file).name;
                const name = ChangeCase.camelCase(link);
            }
            console.log(link, name);
            return {
                name: name,
                link: link,
            };
        });

        return {
            props: {
                pages: pages,
            },
        };
    }
}

This does not work, the component does not receive the pages prop. I have tried switching to a functional component, returning a promise from getStaticProps(), switching to getServerSideProps(), and including the directory reading code into the render method.

The first two don't work because getStaticProps() and getServerSideProps() never get called unless the component is a page, and including the code in the render method fails because fs is not defined or importable since the code might run on the front end which wouldn't have fs access.

I've also tried adding the code to a getStaticProps() function inside _app.js, with the hopes of pushing the pages to the component via context, but it seems getStaticProps() doesn't get called there either.

I could run the code in the getStaticProps function of the pages that include the menu, but I would have to repeat that for every page. Even if I extract the logic into a module that gets called from the getStaticProps, so something like:

// ...

export async function getStaticProps() {
    return {
        props: {
            pages: MenuMaker.getPages(),
            // ...
        }
    }
}

and then pass the pages to the navigation component inside the page via the Layout component:

export default function Page(props) {
    return (
        <Layout pages={props.pages}></Layout>
    )
}

then that's still a lot of boilerplate to add to each page on the site.

Surely there is a better way... It can't be that there is no way to add static data to the global state at build time, can it? How do I generate a dynamic menu at build time?

2

There are 2 answers

2
user14845949 On

You can try this:

const fg = require('fast-glob');
const pages = await fg(['pages/**/*.js'], { dot: true });
0
kaan_a On

I managed to get this working by exporting a function from next.config.js and setting an environment variable that contains the menu structure. I abstracted the menu loading code into it's own file. After seeing the result, I understand better why I was not able to find an example of anyone doing something similar:

The menu is not ordered the way I would like. I could sort it alphabetically, or by the modification date but realistically it almost always needs to be manually sorted in relation to the subject of the pages. I could use an integer, either tacked on to the filename or somewhere in the file (perhaps in a comment line). But in retrospect I think that just hard coding the links in a component is probably the best way after all since it offers much more flexibility and probably isn't going to be much more work even in the very long run.

That being said I am sharing my solution as it is a way to initialize an app wide static state. It's not ideal, you will have to restart the dev server if you wish to recalculate the variables here, which is why I'm still interested in other possible solutions, but it does work. So here it is:

next.config.js

const menu = require("./libraries/menu.js");

module.exports = (phase, { defaultConfig }) => {
    return {
        // ...
        env: {
            // ...
            menu: menu.get('pages'),
            // ...
        },
        // ...
    };
};

libraries/menu.js

const fs = require("fs");
const path = require("path");
const ccase = require("change-case");

module.exports = {
    get: (pagePath) => {
        if (pagePath.slice(-1) != "/") pagePath += "/";
        let files = fs.readdirSync(pagePath);
        files = files.filter((file) => {
            if (file == "_app.js") return false;
            const stat = fs.lstatSync(pagePath + file);
            return stat.isFile();
        });

        return files.map((file) => {
            if (file == "index.js") {
                return {
                    name: "Home";
                    link: "/";
                };
            } else {
                link = path.parse(file).name;
                return {
                    link: link;
                    name: ccase.capitalCase(link);
                };
            }
        });
    },
};

Then the actual menu is generated from the environment variable in a component that can be included in the layout:

components/nav.js

import Link from "next/link";

export default class Nav extends React.Component {
    render() {
        return (
            <nav>
                {process.env.menu.map((item) => (
                    <Link key={item.link} href={item.link}>
                        <a href={item.link}>
                            {item.name}
                        </a>
                    </Link>
                ))}
            </nav>
        );
    }
}