Skip to content

Latest commit

 

History

History
3376 lines (2744 loc) · 102 KB

tutorial.md

File metadata and controls

3376 lines (2744 loc) · 102 KB

Nextra Tutorial

Important

While this tutorial was initially written for v2 of Nextra, most of the content is still relevant for v3. We make sure to highlight the differences throughout the tutorial.

Tip

Some of the info in these notes can also be found in the official Nextra docs: nextra.site/docs
We recommend going through the docs if you get stuck or want a more in-depth understanding of the framework.

When using Nextra, you first have to choose:
either use a default theme or customize your own.

The vast majority of people will use the default theme. However, if you need a specific look & feel, you may have to create your own theme. We learned the hard way that it's much easier to start with a custom theme than using a default one and attempt to change it afterwards ... see: nextra#2926

For this demo, we'll start with the default theme and change it as needed.

0. Start a new project 🆕

Before creating a project, first install the dependencies using pnpm:

pnpm add next react react-dom nextra nextra-theme-docs

This will create a package.json file and install the dependencies under /node_modules.

Next add the following scripts to package.json to run the app:

"scripts": {
  "dev": "next",
  "build": "next build",
  "start": "next start"
},

Your package.json will look similar to:

{
  "scripts": {
    "dev": "next",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "^14.2.4",
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "nextra": "^2.13.4",
    "nextra-theme-docs": "^2.13.4"
  }
}

Next, create a Nextra config file called next.config.js and add the following code to it:

const withNextra = require('nextra')({
  theme: 'nextra-theme-docs',
  themeConfig: './theme.config.jsx',
  defaultShowCopyCode: true
})
 
module.exports = withNextra()

This defines the global configuration for the project. e.g: defaultShowCopyCode: true, which will add a copy button to code snippets.

Lastly, we need to create a corresponding theme.config.jsx file in project root directory. It is used to configure the Nextra site theme. Add the following code to it:

import { DocsThemeConfig } from "nextra-theme-docs";

const config: DocsThemeConfig = {
    logo: <span>Nextra Docs</span>,
    project: {
      link: 'https://github.com/shuding/nextra'
    }
  }

export default config

Save the file and continue.

Tip

Full theme config docs: nextra.site/docs/docs-theme.

Given that Nextra is a file-based system framework (based on Next.js), we create docs under the /pages directory. Create a file with the path pages/index.mdx and type/paste the following markdown:

# Welcome to our docs!
 
Hello, world!

Note

mdx files are markdown files that allow you to write JSX markup. Meaning you can import components and embed them within your markdown files.

Let's see the first page; Run:

pnpm run dev

and visit localhost:3000 in your web browser You should see your page!

Congrats! You've just set up your docs site!

1. Organize Your Content 📁

Now let's write some content! We are going to organize our documentation so it's easier to navigate.

In Nextra, the site and page structure can be configured via _meta.json files. These files will affect the layout of the theme, especially the sidebar/navigation bar.

For example, the title and order of a page shown in the sidebar should be configured in the _meta.json file as key-value pairs. Create the following structure in your project.

pages
|_ api_reference
    |_ _meta.json
    |_ about.mdx
|_ _meta.json
|_ about.mdx
|_ contact.mdx
|_ index.mdx

Define the pages _meta.json:

{
  "index": "My Homepage",
  "contact": "Contact Us",
  "api_reference": "API Reference",
  "about": "About Us"
}

And in the nested _meta.json file, inside api_reference.

{
  "about": "about"
}

Group pages together in directories to create a hierarchy of pages.

For a directory to have its own page, simply add an index.mdx file in the directory. If we need api_reference to have an introductory page, simply create the index.mdx file:

pages
|_ api_reference
    |_ _meta.json
    |_ about.mdx
    |_ index.mdx   // added this
|_ _meta.json
|_ about.mdx
|_ contact.mdx
|_ index.mdx

Fill each .mdx file with content then run: pnpm run dev to see your pages organized!

1.1 External Links and Hidden Routes 🔗

Customize the _meta.json to show external links and hide some routes.

In the top-level _meta.json, change it to the following:

{
  "index": "My Homepage",
  "contact": "Contact Us",
  "api_reference": "API Reference",
  "about": "About Us",
  "github_link": {
    "title": "Github",
    "href": "https://github.com/shuding/nextra",
    "newWindow": true
  }
}

This will add a new link to the sidebar that, once clicked, will redirect the person to the Github page.

We can also hide it; add the display property and set it to hidden, just like in CSS!

{
  "index": "My Homepage",
  "contact": "Contact Us",
  "api_reference": "API Reference",
  "about": "About Us",
  "github_link": {
    "title": "Github",
    "href": "https://github.com/shuding/nextra",
    "newWindow": true,
    "display": "hidden"
  }
}

1.2 Adding items to the navbar

The navbar is a great way to make key content visible. Show pages (links) in the navbar instead of sidebar using the "type": "page" property in the _meta.json file.

To add API Reference to the navbar, update the top-level _meta.json to:

{
  "index": {
    "title": "Homepage",
    "type": "page",
    "display": "hidden"
  },
  "api_reference": {
    "title": "API Reference",
    "type": "page"
  },
  "about": {
    "title": "About Us",
    "type": "page"
  },
  "contact": {
    "title": "Contact Us",
    "type": "page"
  },
  "github_link": {
    "title": "Github",
    "href": "https://github.com/shuding/nextra",
    "newWindow": true,
    "display": "hidden"
  }
}

This will make every single top-level file a page on the navbar. We've hidden the index.mdx, as it is rendered by default when we enter the site and we can return to it if we click on the website's logo. Inside api_reference, create a new file called person.mdx and write whatever you want in it. Let's change the _meta.json file inside this directory to the following.

{
  "about": "about",
  "---": {
    "type": "separator"
  },
  "person": "person"
}

The "---" with `"type": "separator" adds a separator between the two sidebar items.

If you run pnpm run dev, you will see that your site is organized differently. The top-level pages are in the navbar, and you can check the sidebar inside the api_reference folder contents.

Awesome! 🎉

The basic docs site with organized content is working!

2. Add Authentication 🔐

With the website working, we can add authentication to let people sign in and visit private pages.

Using Auth.js to streamline the authentication process. This framework was previously working solely on Next.js projects, but this new v5 release extends their compatibility to other frameworks.

Let's add it to our project!

Note

At the time of writing, only the beta version of auth.js is working. We'll update this doc when a full stable release is announced.

pnpm add next-auth@beta

Next create the secret which is used by auth.js to encrypt token and e-mail verification hashes.

Run:

npx auth secret

Your terminal will show this:

Need to install the following packages:
[email protected]
Ok to proceed? (y) y

Secret generated. Copy it to your .env/.env.local file (depending on framework):

AUTH_SECRET=<your_secret>

Copy the secret and create a file called .env.local. e.g:

AUTH_SECRET="xYZIaxb3/4vWVFHO91ei850HX1c+4xt4LDg+HrEua2g="

.env.local is where all environment variables are stored for authentication.

Now create the config files for Auth.js to work! Creating a file called auth.ts at the root and add the following code:

import NextAuth from "next-auth";

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [],
});

Then, create the following folders from the root of the project - /app/api/auth/[...nextauth]/. Nextra only works in the Pages Router. Authentication with Auth.js needs the App Router to work. For this, we need to create the folder hierarchy we've just mentioned.

After creating the folders, create a file called route.ts inside `[...nextauth].

import { handlers } from "@/auth" // Referring to the auth.ts we just created
export const { GET, POST } = handlers

Add a file called middleware.ts at the root of the project. This is to keep the session alive, this will update the session expiry every time its called.

export { auth as middleware } from "@/auth"

Note

If your imports are complaining about not being able to find the @auth module, Create a tsconfig.json file at the root of the project with the following code:

{
 "compilerOptions": {
   "target": "es5",
   "lib": ["dom", "dom.iterable", "esnext"],
   "allowJs": true,
   "skipLibCheck": true,
   "strict": true,
   "noEmit": true,
   "esModuleInterop": true,
   "module": "esnext",
   "moduleResolution": "bundler",
   "resolveJsonModule": true,
   "isolatedModules": true,
   "jsx": "preserve",
   "incremental": true,
   "plugins": [
     {
       "name": "next"
     }
   ],
   "paths": {
     "@/*": ["./*"]
   }
 },
 "include": [
   "next-env.d.ts",
   "**/*.ts",
   "**/*.tsx",
   ".next/types/**/*.ts",
   "app/lib/placeholder-data.js",
   "scripts/seed.js"
 ],
 "exclude": ["node_modules"]
}

That's it! We're ready to go!

2.1 Adding Github provider

You can now decide how you are going to authenticate the users into your application. You can either:

  • choose an OAuth provider delegated authentication where you delegate through a third party.
  • or you can set up your own identity provider and take care of it yourself.

We're going to choose the former, because it's easier. Auth.js docs explain it succinctly.

OAuth services spend significant amounts of money, time, and engineering effort building abuse detection (bot-protection, rate-limiting), password management (password reset, credential stuffing, rotation), data security (encryption/salting, strength validation), and much more. It is likely that your application would benefit from leveraging these battle-tested solutions rather than try to rebuild them from scratch.

Authenticate people through GitHub.

Let's add it to our providers array inside auth.ts

import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github"

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [GitHub],
});

Next, create an OAuth app. Navigate to github.com/settings/developers and click on OAuth Apps and click on New OAuth App.

Fill out the information. The default callback URL will be of the form of [origin]/api/auth/callback/[provider]. While developing, you may use localhost as the origin. e.g:

http://localhost:3000/api/auth/callback/github

In production it will be something like:

https://docs.company.com/api/auth/callback/github

After completing the form, you will be redirected to the page of your newly created OAuth app. Click on Generate a new client secret.

You will be shown the secret of the new app. Do not close the page, you will need to copy this secret and the shown client ID.

In your .env.local file, add these two copied strings, like so.

AUTH_SECRET=

AUTH_GITHUB_ID=
AUTH_GITHUB_SECRET=

And that's all the configuration we need! Now it's time to authenticate people into our app!

2.2 Letting people Sign In and Sign Out

With all the configuration out of the way, it's time to a way for people to sign in and sign out of our Nextra website.

Before continuing, let's change our site's structure a little bit. Make it like so.

pages
|_ reference_api          // yes, change from 'api_reference' to 'reference_api'
    |_ mega_private       // add this new folder
      |_ _meta.json
      |_ hello.mdx
    |_ _meta.json
    |_ about.mdx
    |_ mega_private.mdx  // This is the index page of `mega_private` that will show on the sidebar. Write whatever.
    |_ users.mdx
|_ _meta.json
|_ about.mdx
|_ api_reference.mdx
|_ contact.mdx
|_ index.mdx

Important

Change the root _meta.json file pertaining to "api_reference" to "reference_api", and the folder as well.

This is important because we are going to have a middleware that is going to match routes to perform authorization checks. Next.js projects usually have APIs under /api, which means the middleware was not going to perform verifications on anything under api.

Create a new folder mega_private inside api_reference. We'll use this later. You can write whatever you want in it. Keep the _meta.json inside mega_private simple, like so.

{
    "hello": "Hello page"    
}

Great!

next-auth provides a set of built-in pages for people to go through their authentication journey (sign in, sign up, sign out, error, etc...). Although you can customize your own pages, we are going to leverage these built-in pages to keep the tutorial simple.

To allow people to sign in, let's create a component to be shown in pages/index.mdx, the root page of the site.

For this, create a folder called components on the root of the project. Inside of components, create LoginOrUserInfo, and inside this one create with the path: components/LoginOrUserInfo/index.tsx and code:

import { signOut, signIn } from "next-auth/react";
import { DefaultSession } from "next-auth";

export function LoginOrUserInfo({ session } : Readonly<{session: DefaultSession}>) {
  if (session?.user) {
    return (
      <div>
        <span>
          Welcome <b>{session.user.name}</b>
        </span>{" "}
        <br />
        <button onClick={() => signOut()}>SIGN OUT</button>
      </div>
    );
  } else {
    return <button onClick={() => signIn()} 
    class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
    SIGN IN</button>;
  }
}

This exports a function LoginOrUserInfo that receives Session object (from next-auth). Inside this component, what we do is really simple: show his name and a SignOut button if they are logged in; otherwise, show a button to SignIn.

Leveraging both signIn and signOut functions from next-auth.

Warning

By creating these nested folders, we have to update the tsconfig.json file to encompass files that are nested on more than two levels.

"include": [
  "next-env.d.ts",
  "*/**/*.ts",       // add this line
  "**/*.ts",
  "**/*.tsx",
  ".next/types/**/*.ts",
  "app/lib/placeholder-data.js",
  "scripts/seed.js"
],

Now let's use our newly created component. Go to pages/index.mdx and add the following code to the top.

import { auth } from "@/auth.ts"
import { useData } from 'nextra/data'
import Link from 'next/link'
import { signOut } from "next-auth/react"
import { LoginOrUserInfo } from "@/components/LoginOrUserInfo"

export async function getServerSideProps(ctx) {
  const session = await auth(ctx)

return {
      props: {
        // We add an `ssg` field to the page props,
        // which will be provided to the Nextra `useData` hook.
        ssg: {
          session
        }
      }
    }
}

export const Info = () => {
  // Get the data from SSG, and render it as a component.
  const { session } = useData()
  return <LoginOrUserInfo session={session}/>

We are using getServerSideProps to fetch the session from the auth function from next-auth. For more information about this inside Nextra, visit https://nextra.site/docs/guide/ssg.

We need to return the session inside an object props and adding a named property called ssg with the data we want to send over the components we want to display. Nextra retrieves this data using the useData() hook.

In the Info component, we fetch the session object from the auth and pass it to the component we've created <LoginOrUserInfo>.

All that's left is using this component in our .mdx file. Simply add <Info/> (the component we've created inside .mdx) wherever you want in the page1

If you run the application, you should be able to sign in and sign out!

We now have access to JWT tokens in our application, where GitHub's OAuth provider is providing them for us.

Now let's start using it to start protecting routes!

3. Protecting routes

We now have a basic authentication flow in our site, with GitHub providing and managing the tokens for our application for us. It's in our interest to restrict certain routes to specific users with specific roles if we ever want to make our awesome documentation site available to different clients.

3.1 A word about middleware.ts

Normally, when developing applications that follow the normal Frontend -> Backend -> Database convention, you usually want to have security measures as close to the data as possible.

With React Server Components being introduced in Next.js, the line between server and client gets blurred. Data handling is paramount in understanding where information is processed and subsequently made available. So we always have to be careful to know how authentication and how protecting routes is implemented and where it is processed, so it's not subject to malicious actors.

Note

To learn more about Data Access Layers, please visit https://x.com/delba_oliveira/status/1800921612105011284 and https://nextjs.org/blog/security-nextjs-server-components-actions. These links provide great insights on how to implement a Data Access Layer to better encapsulate your data and minimize security pitfalls.

For this purpose, because Nextra statically generates pages, we are going to be using middleware to protect the routes. next-auth warns people to on middleware exclusively for authorization and to always ensure that the session is verified as close to your data fetching as possible. While that is true (and a reiteration of what was aforementioned), protecting the routes in our application through middleware is optimistically secure enough (as it runs on the server-side), as it runs on every request, including prefetched routes. Additionally, Next.js recommends doing so.

"This is important for keeping areas like the user dashboard protected while having other pages like marketing pages be public. It's recommended to apply Middleware across all routes and specify exclusions for public access.

3.2 Adding middleware.ts logic

As per Next.js's convention, to add this middleware, we need to create a middleware.ts file at the root of the project.

Create it and add the following code to it:

// middleware.ts

"server only";

import { auth } from "@/auth";
import { NextResponse } from "next/server";

// !!!! DO NOT CHANGE THE NAME OF THE VARIABLE !!!!
const privateRoutesMap: any = {};

export default auth(async (req, ctx) => {
  const currentPath = req.nextUrl.pathname;
  const isProtectedRoute = currentPath in privateRoutesMap

  if(isProtectedRoute) {
    // Check for valid session
    const session = req.auth

    // Redirect unauthed users
    if(!session?.user || !session.user.role) {
      return NextResponse.redirect(new URL('/api/auth/signin', req.nextUrl))
    }

    // Redirect users that don't have the necessary roles
    const neededRolesForPath = privateRoutesMap[currentPath]
    if(!(session.user.role && neededRolesForPath.includes(session.user.role))) {
      return NextResponse.redirect(new URL('/api/auth/signin', req.nextUrl))
    }
  }

  return NextResponse.next()
});

export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - api (API routes)
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     */
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
}

Let's break down what we just wrote:

  • privateRoutesMap is an object/map that will have the path as key and the array of roles needed to access the path as value. This variable will be changed on build-time, so do not change its name!
  • we export auth as default from middleware.ts. This function is called before every route server-side. Inside it, we check the path and the session cookie. With these, we can know if the route is protected or not. If it is, we check if the person can access it with the given role. If any of these conditions fail, the person is redirected to the Sign In page.
  • we also export a config object, with a matcher property. We use RegEx to configure which paths we want the middleware to run on.

Note

Your Typescript compiler may be complaining because role is not a property inside user. Don't worry, we'll fix this now.

3.3 Configuring auth.ts

middleware.ts assumes the JWT token to have a role in order to do role-based authorization. For it to have access to it (and throughout the whole application), we ought to head over to auth.ts and do additional configuration.

Open auth.ts and change it the following.

// auth.ts

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [
    GitHub({
      profile(profile) {
        // GitHub's OAuth apps don't allow you to define roles.
        // So `profile` here doesn't have a `role` property.
        // But on other providers, you'd add the role here through it.
        return {
          id: profile.id.toString(),
          name: profile.name ?? profile.login,
          email: profile.email,
          image: profile.avatar_url,
          role: "user",
        };
      },
    }),
  ],
  callbacks: {
    jwt({ token, user, account, profile }) {
      // Normally, it would be like this
      // if(user) return {...token, role: token.role}
      // return token

      // But because Github's provider is not passing the role
      // (it should, according to https://authjs.dev/guides/role-based-access-control#with-jwt -
      // maybe it's because v5 is still in beta), we're just gonna append it every time
      return {...token, role: "user"}
    },
    session({ session, token }) {
      session.user.role = token.role;
      return session;
    },
  },
});

As usual, let's break it down!

  • the GitHub provider accepts a profile() callback. In this callback, we receive a Profile returned by the OAuth provider (in our case, it's GitHub). We can return a subset using the profile's information to define user's information in our application. By default, it returns the id, email, name, image, but we can add more. That's what we did, by adding the role property.

Note

If you're using a custom provider, it is your responsibility to return the role so you can capture it in your Next.js/Nextra application.

  • we defined callbacks to add the role to our JWT tokens and session cookies. In order, the jwt callback receives the user information we've defined earlier. We append the role to the token. Afterwards, the session callback is invoked, where we use the role property we've just appended to the token and add it to the session cookie.

Note

In our code, we hardcode it every time. It is because, at the time of writing, next-auth is releasing a v5, which has some bugs, including the GitHub provider not properly downstreaming the user to the jwt and session callbacks. They receive the user as undefined.

Unfortunately, this not an isolated occurrence. https://stackoverflow.com/questions/76986309/nextauth-nextjs-13-unable-to-add-user-role describes our exact scenario and doesn't have an answer 😕.

Here are a few more examples. Hopefully it will be resolved in time:

If you're using a custom provider, it seems this is unlikely to happen to you. In the links above, people found solutions using custom providers and not the in-built ones, like we are using in this demo.

Important

To make the experience better for the user, we can implement a refresh token rotation system, where once a token expires, the application automatically refreshes it instead of asking the user to sign in again.

Read https://authjs.dev/guides/refresh-token-rotation to implement this with next-auth.

But that's not all! Because we are effectively extending the properties of the User, we can augment it through types, so Typescript doesn't complain about role being an undefined property.

In the same auth.ts file, add the following code at the top.

import GitHub from "next-auth/providers/github";
import NextAuth, { type DefaultSession } from "next-auth";
// we don't use these but we need to import something so we can override the interface
import {  DefaultJWT } from "next-auth/jwt";
import { AdapterSession } from 'next-auth/adapters';

// We need to add the role to the JWT inside `NextAuth` below, so the `middleware.ts` can have access to it.
// The problem is that it wasn't added this `role` custom field, even if we defined it in `auth.ts`.
// Apparently, the problem is with the types of `next-auth`, which we need to redefine.
// See https://stackoverflow.com/questions/74425533/property-role-does-not-exist-on-type-user-adapteruser-in-nextauth
// and see https://authjs.dev/getting-started/typescript#module-augmentation.

declare module "next-auth" {
  interface Session extends DefaultSession {
    /**
     * By default, TypeScript merges new interface properties and overwrites existing ones.
     * In this case, the default session user properties will be overwritten,
     * with the new ones defined above. To keep the default session user properties,
     * we need to add them back into the newly declared interface.
     */
    user: DefaultSession["user"] & {
      role?: string;
    };
  }

  interface User {
    // Additional properties here:
    role?: string;
  }
}

declare module "next-auth/adapters" {
  interface AdapterUser {
    // Additional properties here:
    role?: string;
  }
}

declare module "next-auth/jwt" {
  interface JWT {
    role?: string;
  }
}

And that's it! 🎉 Give yourself a pat on the back!

Now that we have the authentication and authorization set up, it's time to finally use it.

4. Generating private routes

As you may have noticed by now, Nextra statically generates the page for you following the structure and the _meta.json files that you define in your project. These changes are then reflected in your navbar and sidebar.

However, there is no way for us to define private routes and use them at build-time, like you do when generating the pages with Nextra. Unfortunately, Nextra doesn't have this feature in place, and authentication is not something they had in mind to implement for a framework that was primarily meant for public-facing documentation.

However, it is possible to do this, considering what was discussed in 3.1 A word about middleware.ts, as long as we have access to the private routes at build-time.

To accomplish this, we're using ts-morph, a package that will allow us to manipulate Typescript source files. We will create a script with ts-morph that manipulates the const privateRoutesMap inside middleware.ts. This script will populate the const with all the private routes and the needed roles to access them.

Let's crack on!

4.1 Installing ts-morph and setup

Install ts-morph, ts-node (to run the script we're implementing) and fast-glob (to traverse the file system).

pnpm add ts-morph ts-node fast-glob

After installing these dependencies, we will have to adjust our tsconfig.json file so we can run our script independently. Below the exclude property, in the last line, add the following piece of code.

  "exclude": ["node_modules"],

  // These are only used by `ts-node` to run the script that generates the private routes.
  // These options are overrides used only by ts-node, same as the --compilerOptions flag and the TS_NODE_COMPILER_OPTIONS environment variable
  "ts-node": {
    "compilerOptions": {
      "module": "commonjs"
    }
  },

Great! Let's start implementing our script now.

4.2 Creating generatePrivateRoutes.ts

In the root of the project, create a file called generatePrivateRoutes.ts. Add the following code.

import { Project } from "ts-morph";
import path from "path";
import fs from "fs";
import { globSync } from "fast-glob";

// - - - - - - - - - - - - - - - - - -  - - - - -
// `middleware.ts` is changed by executing the code below.

const CONST_VARIABLE_NAME = "privateRoutesMap"; // Name of the constant inside `middleware.ts` to be manipulated
const DIRECTORY = "pages"; // Directory to look for the routes (should be `pages`, according to Nextra's file system)

export function changeMiddleware() {

  // Get private routes
  const pagesDir = path.join(__dirname, DIRECTORY);
  const privateRoutes = getPrivateRoutes(pagesDir);
  
  // Initialize the project and source file
  const project = new Project();
  const sourceFile = project.addSourceFileAtPath(path.resolve(__dirname, "middleware.ts"));
  
  // Find the variable to replace and change it's declaration
  const variable = sourceFile.getVariableDeclaration(CONST_VARIABLE_NAME);
  if (variable) {
    variable.setInitializer(JSON.stringify(privateRoutes));
    sourceFile.saveSync();
  } else {
    console.error("Variable not found in `middleware.ts`. File wasn't changed.");
  }  
}

export default {
  changeMiddleware: () => changeMiddleware()
}

The code is fairly simple. We get the private routes by calling a function called getPrivateRoutes() (which we will implement shortly). Then, we find the middleware.ts file and find the variable we want to change. If we do find it, we change its initialization to the private routes map we retrieved earlier. Otherwise, we log an error.

Now, it's time to add the function that will retrieve the private routes!

4.3 Implementing private route retrieval function

Our function getPrivateRoutes will need to recursively iterate over a given folder directory and find a way to know which routes are private. As of now, there's no way of doing so.

That's why we're going to define a new property inside _meta.json files.

4.3.1 Defining _meta.json files with private properties

We need to tell our function which pages/routes are private. To do this, we are going to use the _meta.json files that come with Nextra.

To achieve this, we are going to allow people to declare a route as private by adding a private property to a route defined in the respective _meta.json.

// pages/_meta.json
{
  "index": {
    "title": "Homepage",
    "type": "page",
    "display": "hidden"
  },
  "reference_api": {
    "title": "API Reference",
    "type": "page",
    "private": {                // We add this property to make `reference_api` private
      "private": true,
      "roles": ["user"]
    }
  },
  "about": {
    "title": "About Us",
    "type": "page"
  },
  "contact": {
    "title": "Contact Us",
    "type": "page"
  },
  "github_link": {
    "title": "Github",
    "href": "https://github.com/shuding/nextra",
    "newWindow": true,
    "display": "hidden"
  }
}

As you can see, we've added a property called private to the reference_api route, where we define if it's private or not by boolean in the private property and where we define the roles that can access it in the roles property.

Alternatively, people can define private routes by simply passing "private": true, instead of passing an object. In these cases, the roles empty will default to an empty array,

  "reference_api": {
    "title": "API Reference",
    "type": "page",
    "private": true             // Simpler version
  },

We'll take into account both of these scenarios.

4.3.2 Implementing the function

Okay, now let's start coding our function! Inside generatePrivateRoutes.ts, add this function above the ts-morph code we've written earlier.

// Types for the `_meta.json` structure
type PrivateInfo = {
  private: boolean;
  roles?: string[];
};

type MetaJson = {
  [key: string]: string | any | PrivateInfo;
};

type PrivateRoutes = {
  [key: string]: string[];
};

/**
 * This function looks at the file system under a path and goes through each `_meta.json` looking for private routes recursively.
 * It is expecting the `_meta.json` values of keys to have a property called "private" to consider the route as private for specific roles.
 * If a parent is private, all the children are private as well. The children inherit the roles of their direct parent.
 * @param pagesDir path to recursively look for.
 * @returns map of private routes as key and array of roles that are permitted to access the route.
 */
function getPrivateRoutes(pagesDir: string): PrivateRoutes {
  let privateRoutes: PrivateRoutes = {};

  // Find all _meta.json files recursively
  const metaFiles = globSync(path.join(pagesDir, "**/_meta.json"));

  // Variable to keep track if parent is private on nested routes and its role
  const rootPrivateSettings: { [key: string]: { private: boolean; roles: string[] } } = {};

  // Iterate over the found meta files
  for (const file of metaFiles) {
    // Get the path of file and read it
    const dir = path.dirname(file);
    const metaJson: MetaJson = JSON.parse(fs.readFileSync(file, "utf-8"));

    // Iterate over the key/value pairs of the "_meta.json" file
    for (const [key, meta] of Object.entries(metaJson)) {
      const route = path.join(dir, key).replace(pagesDir, "").replace(/\\/g, "/");

      // Check if the current meta has a "private" property
      if (meta.private !== undefined) {
        if (typeof meta.private === "boolean") {
          if (meta.private) {
            privateRoutes[route] = [];
            rootPrivateSettings[dir] = { private: true, roles: [] };
          }
        }
        // If the "private" property is an object with possible roles
        else if (meta.private.private === true) {
          const roles = meta.private.roles ? meta.private.roles : [];
          privateRoutes[route] = roles;
          rootPrivateSettings[dir] = { private: true, roles: roles };
        }
      } else {
        // Check if the parent folder is private and inherit roles
        const parentDir = path.resolve(dir, "..");
        if (rootPrivateSettings[parentDir] && rootPrivateSettings[parentDir].private) {
          const parentRoles = rootPrivateSettings[parentDir].roles;
          privateRoutes[route] = parentRoles;
        }
      }
    }
  }

  // Now let's just do a second pass to clean-up possible unwanted/invalid routes
  for (const route of Object.keys(privateRoutes)) {
    const fullPath = path.join(pagesDir, route);
    const lastSegment = route.split("/").pop();

    // Remove separators or any route that doesn't correspond to an existing file/directory
    if (lastSegment === "---") {
      delete privateRoutes[route];
      continue;
    }

    // Check for the existence of .mdx file
    const mdxPath = `${fullPath}.mdx`;
    if (!fs.existsSync(fullPath) && !fs.existsSync(mdxPath)) {
      delete privateRoutes[route];
    }
  }

  return privateRoutes;
}

Whoa, that's a lot! The code is fairly documented so you can follow along easier. But the gist of it is:

  • we use fast-glob to find all the _meta.json files recursively.
  • we iterate over the _meta.json files.
    • in each _meta.json, we look over the key-value pairs to construct the route.
    • in each key-value, we check for the private property and resolve its boolean.
    • while iterating, we keep track of the routes and check if the parent is also private. If the parent is private, the child must be private too. It also inherits the direct parent's roles.
  • after iteration, we do a second pass to clean-up possible invalid routes.
  • we return a map of private route as key and roles array as value.

And that's it!

4.3.2.1 Nextra v3 version

For Nextra v3, we need to make a few changes. Because _meta.json files now become _meta.ts files, we're going to change the getPrivateRoutes function.

export function getPrivateRoutes(pagesDir: string): PrivateRoutes {
  let privateRoutes: PrivateRoutes = {};

  // Find all _meta.ts files recursively
  const metaFiles = globSync(path.join(pagesDir, "**/_meta.ts"));

  // Variable to keep track if parent is private on nested routes and its role
  const rootPrivateSettings: { 
    [key: string]: { private: boolean; roles: string[] } 
  } = {};

  // Initialize ts-morph Project for reading the _meta.ts files
  const project = new Project();

  // Iterate over the found meta files
  for (const file of metaFiles) {
    // Get the path of the file and parse it using ts-morph
    const dir = path.dirname(file);
    const sourceFile = project.addSourceFileAtPath(file);

    // Get the default export assignment from the _meta.ts file
    const defaultExport = sourceFile.getExportAssignments();
    
    if (!defaultExport) {
      console.error(`No export assignment found in ${file}`);
      continue;
    }

    const metaObject = defaultExport[0].getExpression();

    // If the expression is an object literal, we can process it
    if (metaObject?.getKind() === SyntaxKind.ObjectLiteralExpression) {
      const objectLiteral = metaObject as ObjectLiteralExpression;
      
      // Iterate over the properties of the object literal
      objectLiteral.getProperties().forEach(prop => {
        if (prop.getKind() === SyntaxKind.PropertyAssignment) {
          const propertyAssignment = prop as PropertyAssignment;
          const key = propertyAssignment.getName();
          const initializer = propertyAssignment.getInitializer();

          if (!key || !initializer) {
            return;
          }

          const route = path.join(dir, key).replace(pagesDir, "").replace(/\\/g, "/");

          // Check if the current meta has a "private" property
          if (initializer.getKind() === SyntaxKind.ObjectLiteralExpression) {
            const metaInitializer = initializer as ObjectLiteralExpression;

            // Check for "private" property inside the object
            const privateProp = metaInitializer.getProperty("private") as PropertyAssignment;
            if (privateProp) {
              const privateValue = privateProp.getInitializer();

              if (privateValue?.getKind() === SyntaxKind.TrueKeyword) {
                privateRoutes[route] = [];
                rootPrivateSettings[dir] = { private: true, roles: [] };
              }
              // If the "private" property is an object with possible roles
              else if (privateValue?.getKind() === SyntaxKind.ObjectLiteralExpression) {
                const privateObject = privateValue as ObjectLiteralExpression;
                const rolesProp = privateObject.getProperty("roles") as PropertyAssignment;
                const roles = rolesProp ? rolesProp.getInitializer()?.getText().replace(/[\[\]\s"]/g, "").split(",") as string[] : [] as string[];
                privateRoutes[route] = roles;
                rootPrivateSettings[dir] = { private: true, roles: roles };
              }
            }
          } else {
            // Check if the parent folder is private and inherit roles
            const parentDir = path.resolve(dir, "..");
            if (rootPrivateSettings[parentDir] && rootPrivateSettings[parentDir].private) {
              const parentRoles = rootPrivateSettings[parentDir].roles;
              privateRoutes[route] = parentRoles;
            }
          }
        }
      });
    }
  }

  // Second pass to clean-up possible unwanted/invalid routes
  for (const route of Object.keys(privateRoutes)) {
    const fullPath = path.join(pagesDir, route);
    const lastSegment = route.split("/").pop();

    // Remove separators or any route that doesn't match existing file/directory
    if (lastSegment === "---") {
      delete privateRoutes[route];
      continue;
    }

    // Check for the existence of .mdx file
    const mdxPath = `${fullPath}.mdx`;
    if (!fs.existsSync(fullPath) && !fs.existsSync(mdxPath)) {
      delete privateRoutes[route];
    }
  }

  return privateRoutes;
}

4.4 Running the script before building

Now that we have our handy-dandy script ready, we need to make sure it always gets executed before running our application, whether it is being built for production or compiling to be running on localhost.

To do this, we only need to change our package.json file. Head over there and change the scripts, like so:

  "scripts": {
    "private-route-gen": "ts-node -e \"import gen from './src/generatePrivateRoutes'; gen.changeMiddleware()\"",
    "dev": "npm run private-route-gen && next",
    "prebuild": "npm run private-route-gen",
    "build": "next build",
    "start": "next start"
  },

We've created a private-route-gen that uses ts-node to run our script. This newly added script is executed when running pnpm run dev (running the app locally) and pnpm run build. We've added it to the prebuild script so it executes before production builds.

And that's it! Now every time you run your application, we are sure that the private routes are correctly materialized!

Hurray! 🎉

Note

You may get an error when building the project saying:

Error validating _meta.json file for "reference_api" property.
Unrecognized key(s) in object: 'private'

This is expected, since Nextra doesn't know what the private property is. This doesn't affect the performance of the application, it's simply a warning.

5. Moving source files to src folder

Before proceeding, let's do some cleaning up 🧹. Right now, we have some source files (like auth.ts, middleware.ts, generatePrivateRoutes.ts) mixed with several configuration files at root level.

Luckily for us, Next.js supports adding a src folder so we can keep the root directory focused on configuration files and the src folder to source files.

Let's move our source code to a src folder! Start by creating it at root level. Then, move the following items into it:

  • the app folder.
  • the components folder.
  • the pages folder.
  • auth.ts, middleware.ts and generatePrivateRoutes.ts files.

Now we have to change some imports. Check the following changes in each file so everything works again!

// src/pages/index.mdx
import { auth } from "@/src/auth.ts"    // changed from `@/auth.ts`
import { useData } from 'nextra/data'
import Link from 'next/link'
import { signOut } from "next-auth/react"
import LoginOrUserInfo from "@/src/components/LoginOrUserInfo" // changed from `@/components/LoginOrUserInfo`
// src/middleware.ts
"server only";

import { auth } from "@/src/auth";    // changed from `@/auth.ts`
import { NextResponse } from "next/server";
// tests/unit/LoginOrUserInfo.test.tsx
import { render, screen } from "@testing-library/react";
import LoginOrUserInfo from "@/src/components/LoginOrUserInfo";   // changed from `@/components/LoginOrUserInfo`
import { DefaultSession } from "next-auth";
import { signOut, signIn } from "next-auth/react";
// package.json
"private-route-gen": "ts-node src/generatePrivateRoutes.ts",

And you're sorted! We now have all our source files inside src, the tests inside tests, and all the configuration files at root level.

6. Adding custom theme

Now that we've protected some routes through the middleware.ts file, we need to go a bit further. It doesn't make sense for public people to see the private routes, either be it on the sidebar or on the navbar.

For this, we can go about this with two options:

  1. we customize the default theme through theme.config.jsx (https://nextra.site/docs/docs-theme/theme-configuration#customize-the-navbar), where we check each sidebar title and hide it and override the whole navbar.

  2. use the code from the nextra-theme-docs to keep the default theme, use it as a custom-theme in theme.config.jsx and try to conditionally render each link according to the person's role.

Although you can go with Option 1 for simplicity sake, we are going with Option 2 for three main reasons:

  • we want to keep the same look n' feel of the application as it stands.
  • we don't want to re-implement and waste the time trying to do so.
  • we want to have access to the private property we've defined inside _meta.json files, which can only be made through augmenting the types of the nextra-docs-theme source code.

With this in mind, let's do this!

6.1 Copying the nextra-theme-docs

Download the directory from https://github.com/dwyl/nextra-demo/tree/34a6327e00b941b50dffdbbed99ab6bf294511a4/theme. This directory is a version of nextra-theme-docs with a few modifications (they are explained in the README.md inside the directory).

Long story short, the differences are:

  • we've imported some nextra components from the original package and placed it inside the theme.
  • moved some code from src/constants.tsx to src/contexts/config.tsx. This is because, as is, the code threw a ReferenceError: Cannot access 'DEFAULT_THEME' before initialization error.
  • removed tailwind.config.js and postcss.config.js. We'll be using these on the root of the project instead.

After downloading this directory, put all of the downloaded code inside a new directory called theme.

6.2 Installing TailwindCSS and setting up pnpm workspace

For this to work, we'll have to install TailwindCSS on our project. This is what the custom theme depends on to properly render their components.

For this, open the terminal and type pnpm install tailwindcss postcss autoprefixer.

Next up, let's create two files: tailwind.config.js and postcss.config.js. These two files are from the original theme.

// tailwind.config.js

const colors = require('tailwindcss/colors')

const makePrimaryColor =
  l =>
  ({ opacityValue }) => {
    return (
      `hsl(var(--nextra-primary-hue) var(--nextra-primary-saturation) ${l}%` +
      (opacityValue ? ` / ${opacityValue})` : ')')
    )
  }

/** @type {import('tailwindcss').Config} */
module.exports = {
  prefix: 'nx-',
  content: [
    './theme/src/**/*.tsx',
    './theme/src/nextra_icons/*.tsx',
    './theme/src/nextra_components/*.tsx'
  ],
  theme: {
    screens: {
      sm: '640px',
      md: '768px',
      lg: '1024px',
      xl: '1280px',
      '2xl': '1536px'
    },
    fontSize: {
      xs: '.75rem',
      sm: '.875rem',
      base: '1rem',
      lg: '1.125rem',
      xl: '1.25rem',
      '2xl': '1.5rem',
      '3xl': '1.875rem',
      '4xl': '2.25rem',
      '5xl': '3rem',
      '6xl': '4rem'
    },
    letterSpacing: {
      tight: '-0.015em'
    },
    colors: {
      transparent: 'transparent',
      current: 'currentColor',
      black: '#000',
      white: '#fff',
      gray: colors.gray,
      slate: colors.slate,
      neutral: colors.neutral,
      red: colors.red,
      orange: colors.orange,
      blue: colors.blue,
      yellow: colors.yellow,
      primary: {
        50: makePrimaryColor(97),
        100: makePrimaryColor(94),
        200: makePrimaryColor(86),
        300: makePrimaryColor(77),
        400: makePrimaryColor(66),
        500: makePrimaryColor(50),
        600: makePrimaryColor(45),
        700: makePrimaryColor(39),
        750: makePrimaryColor(35),
        800: makePrimaryColor(32),
        900: makePrimaryColor(24)
      }
    },
    extend: {
      colors: {
        dark: '#111'
      }
    }
  },
  darkMode: ['class', 'html[class~="dark"]']
}
// postcss.config.js
/** @type {import('postcss').Postcss} */
module.exports = {
  plugins: {
    'postcss-import': {},
    'tailwindcss/nesting': {},
    tailwindcss: {},
    'postcss-lightningcss': {
      browsers: '>= .25%'
    }
  }
}

And that's it for the TailwindCSS part of things! Now, because our theme directory has its own set of dependencies, we need to tell our root project that information! We want it to install and use its dependencies. For this, we leverage pnpm Workspaces to install and update dependencies in a single pnpm install command!

For this, we have to:

  • give our root project's workspace a name. We can do this by going to package.json and adding the line "name": "nextra" on top.
  • create a pnpm-workspace.yaml file.
packages:
  - '.'
  - 'theme'

6.3 Using TailwindCSS globally

Now that we've installed TailwindCSS, let's use it in our application!

For our theme to work, we need to create a .css file and import it in our Pages Router so it is used throughout our application pages. To do this, create a file inside src - src/globals.css.

/* Use the theme's styles CSS file */
@import "../theme/css/styles.css"

Easy enough, right? We are simply using the theme's styles in our own application's styles! Now we just need to use it in our application. To do this, we are going to be using a custom app component, which is called to initialize pages. We are going to override it to inject our styles.

Inside src/pages, create a file called _app.tsx.

// These styles apply to every route in the application
import '@/src/globals.css'
import type { AppProps } from 'next/app'
 
export default function App({ Component, pageProps }: AppProps) {
  return <Component {...pageProps} />
}

And that's it!

The very last thing we need to do, is to tell Nextra we want to use our custom theme. For this, simply head over to next.config.js and change the theme property to the directory of our newly created theme directory.

const withNextra = require("nextra")({
  theme: "./theme/src/index.tsx",      // change here
  themeConfig: "./theme.config.jsx",
  defaultShowCopyCode: true
});

module.exports = withNextra();

If you run pnpm run dev, you should be able to see the application running as before!

7. Conditional rendering on components

Now that we have our custom theme properly setup and added to our application, we now have the opportunity to do whatever we want with the components. In our case, it is extremely useful to conditionally render links of protected routes according to the logged user.

7.1 With which methods can we do this?

auth.js provides us a few ways of authenticating people and getting their session. Next.js is moving to a server-side-first approach with their app router with the introduction of Server Components. So this should be our approach.

However, Nextra does not yet support app router. This makes it so that the statically rendered pages are client components. If we take a look at auth.js's documentation, we quickly realise that we can't use the auth() call like we did in src/middleware.ts inside our theme's components. In fact, we can only use useSession() to fetch the current session of the logged in person.

Note

We did try using the auth() call, but we were always prompted with the same error:

unhandledRejection: Error: `headers` was called outside a request scope. Read more: https://nextjs.org/docs/messages/next-dynamic-api-wrong-context

Reading the link in the error, this happens because calling headers() is made within the library, which is out of the scope of the theme, making it impossible to do it "server-side".

Even after updating the handlers inside app/api/auth/[...nextauth]/route.ts and simply overriding the default theme through theme.config.jsx, the same error occurred.

While this may seem not like the ideal scenario, it's important to stress that the routes are still protected and that the issued JWT are encrypted by default.. So, while very unlikely, the person may know some links exist, but they can't access it, as they are protected server-side through middleware.ts.

7.2 Using useSession()

Now that we know we're using the useSession() hook to retrieve the person's current session, let's make some changes to our code so we can use it!

The first thing we need to do is to wrap our app with <SessionProvider>. This will ensure the session is accessible throughout the application.

Head over to src/pages/_app.tsx, and change it to the following.

// These styles apply to every route in the application
import '@/src/globals.css'
import type { AppProps } from 'next/app'
import { SessionProvider } from "next-auth/react"

export default function MyApp({
  Component,
  pageProps: { session, ...pageProps },
}: AppProps) {
  return (
    <SessionProvider session={session}>
      <Component {...pageProps} />;
    </SessionProvider>
  )
}

Awesome!

Before proceeding, let's do some housekeeping 🧹. We know we are going to need two things:

  • use the extended User interface that we augmented inside auth.
  • use the PrivateInfo type inside src/generatePrivateRoutes.ts to know whether a menu item is private or not.

Let's start with the first one. Inside src/auth.ts, locate the augmented Session interface. Change it to the following.

import { ExtendedUser } from './types';         // added this

declare module "next-auth" {
  interface Session extends DefaultSession {
    user: ExtendedUser                          // changed here


  }

  interface User {
    // Additional properties here:
    role?: string;
  }
}

Notice that we've created an ExtendedUser interface that is imported from types.ts. We haven't created this file. So let's do it! Inside src, create a file called types.ts.

import { type DefaultSession } from 'next-auth';
/**
 * Holds types for the application (on the `src` side)
 */

// Types for the `_meta.json` structure
export type PrivateInfo = {
  private: boolean;
  roles?: string[];
};

export type MetaJson = {
  [key: string]: string | any | PrivateInfo;
};

export type PrivateRoutes = {
  [key: string]: string[];
};

// Types for auth
export type ExtendedUser = {
    role?: string;
  } & DefaultSession["user"];

Notice that we simply moved the extended user code to ExtendedUser inside this file. In addition to this, we have also copied the types from src/generatePrivateRoutes.ts to here, as well. All that is left to do is update src/generatePrivateRoutes.ts to import these!

// generatePrivateRoutes.ts
import { PrivateRoutes, MetaJson } from './types';

// delete the types
// ...

And that's it! We're ready to rock and roll! 🎸

7.3 Rendering links inside navbar

Let's start by conditionally rendering the links inside the navbar.

First let's go theme/src/types.ts and add two additional types for the PageItem and MenuItem types that are used inside theme/src/components/navbar.tsx.

Add the following lines.

// `theme/src/types.ts`

import type { MenuItem, PageItem } from 'nextra/normalize-pages'
import { PrivateInfo } from '../../src/types';

// Extends the PageItem and MenuItem with the `private` property
export type ExtendedPageItem = { private?: PrivateInfo } & PageItem;
export type ExtendedMenuItem = { private?: PrivateInfo } & MenuItem;

We are simply extending the PageItem and MenuItem types with the PrivateInfo interface that we moved earlier. This way, we'll have access to the private property when writing code inside navbar.tsx!

Speaking of which, let's finally change the navbar.tsx component! Head over to theme/src/components/navbar.tsx

First, locate the NavBarProps type on top of the file and change it accordingly. We are extending our items' types with the extended types we've defined before so we can access the private property.

export type NavBarProps = {
  flatDirectories: Item[]
  items: (ExtendedPageItem | ExtendedMenuItem)[]
}

Now we're ready to make some changes to the NavBar function. Head over to this function and use the useSession() hook.

// theme/src/components/navbar.tsx

import { useSession } from "next-auth/react"
import { ExtendedPageItem, ExtendedMenuItem } from '../types';
import { ExtendedUser } from '../../../src/types';

export function Navbar({ flatDirectories, items }: NavBarProps): ReactElement {
  const config = useConfig()
  const activeRoute = useFSRoute()
  const { menu, setMenu } = useMenu()

  const {data, status: session_status} = useSession()     // add this
  const user = data?.user as ExtendedUser                 // add this

  return (
    ...
  )

Great! We are now successfully using the useSession() hook and getting the authenticated (or not) person's session data. Now we can leverage this data to conditionally render the links inside our NavBar!

Because the Session object returned by useSession also has a status (that can be either "loading", "authenticated" or "unauthenticated"), we will have to take this into account when changing how the NavBar renders the links.

Other than this, all we have to do is block the rendering of links that the user is not allowed to see. Locate the return of the NavBar function and implement the following changes.

  return (
    <div>
      <div/>
      <nav>
        {config.logoLink ? (
          <Anchor
            href={typeof config.logoLink === 'string' ? config.logoLink : '/'}
            className="nx-flex nx-items-center hover:nx-opacity-75 ltr:nx-mr-auto rtl:nx-ml-auto"
          >
            {renderComponent(config.logo)}
          </Anchor>
        ) : (
          <div className="nx-flex nx-items-center ltr:nx-mr-auto rtl:nx-ml-auto">
            {renderComponent(config.logo)}
          </div>
        )}
        {items.map(pageOrMenu => {

          // Start of changes -------------------

          // Wait until the session is fetched (be it empty or authenticated)
          if(session_status === "loading") {
            return null
          }

          // If it's a public user but the link is marked as private, hide it
          if(session_status === "unauthenticated") {
            if(pageOrMenu.private) return null
          }

          // If the user is authenticated
          // and the page menu is protected or the role of the user is not present in the array, we block it
          if(session_status === "authenticated" && user) {
            if (pageOrMenu.private?.private) {
              const neededRoles = pageOrMenu.private.roles || []
              const userRole = user.role
              if(!userRole || !neededRoles.includes(userRole)) {
                return null
              }
            }
          }

          // End of changes -------------------

          if (pageOrMenu.display === 'hidden') return null

          // ...
        })}
  )

Let's break down what we just implemented:

  • we check if the session status is "loading". If so, we render nothing.
  • if the session status is "unauthenticated" and the route is private, we don't render the title for the person to see.
  • if the session status is "authenticated", it means the person has a session and is logged in. If the route is private, we check if the person has the necessary role to see it. If they don't, we don't render it for the person to see.

And that's it! We can test this behaviour! If we run our application as is (pnpm run dev), we'll see how everything is the same. That's because our person has the "user" role, which is allowed under api_reference.

However, if we head over to src/auth.ts and change the role to something different...

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: providers,
  callbacks: {
    jwt({ token, user, account, profile }) {
      return { ...token, role: "another_role" };   // changed here
    },
    session({ session, token }) {
      session.user.role = token.role
      return session;
    },
  },
});

And run the application again, you'll see that the title is hidden!

Hurray! 🎉

Because we're going to be using this feature of conditionally hiding links, let's make it a function so we can use it in other places (namely the sidebar).

Inside theme/src/utils/render.tsx, add the following function. This function is the same code that we just wrote inside navbar.tsx.

import { ExtendedUser } from "../../../src/types";
import { ExtendedItem, ExtendedPageItem, ExtendedMenuItem } from "../types";
import { SessionContextValue } from "next-auth/react";

export function shouldLinkBeRenderedAccordingToUserRole(
  session: SessionContextValue<boolean>,
  item: ExtendedItem | ExtendedPageItem | ExtendedMenuItem
) {
  const { data, status: session_status } = session;
  const user = data?.user as ExtendedUser;

  // Wait until the session is fetched (be it empty or authenticated)
  if (session_status === "loading") return false;

  // If it's a public user but the link is marked as private, hide it
  if (session_status === "unauthenticated") {
    if (item.private) return false;
  }

  // If the user is authenticated
  // and the page menu is protected or the role of the user is not present in the array, we block it
  if (session_status === "authenticated" && user) {
    if (item.private?.private) {
      const neededRoles = item.private.roles || [];
      const userRole = user.role;
      if (!userRole || !neededRoles.includes(userRole)) {
        return false;
      }
    }
  }

  return true;
}

Great! Now we just need to use it inside our navbar.tsx file.

// theme/src/components/navbar.tsx

// ...

// Old code --
const {data, status: session_status} = useSession()
const user = data?.user as ExtendedUser

// New code --
const session = useSession()

// ...

// Old code --
if(session_status === "loading") return null

if(session_status === "unauthenticated") {
  if(pageOrMenu.private) return null
}

if(session_status === "authenticated" && user) {
  if (pageOrMenu.private?.private) {
    const neededRoles = pageOrMenu.private.roles || []
    const userRole = user.role
    if(!userRole || !neededRoles.includes(userRole)) {
      return null
    }
  }
}

// New code --
if(!shouldLinkBeRenderedAccordingToUserRole(session, pageOrMenu))
  return null

Awesome! This makes things simple for the rest of our guide!

7.4 Rendering links inside sidebar

To conditionally render inside theme/src/components/sidebar.tsx takes a bit more work. This is because this component is used to display both the structure of the document inside each page but also to showcase the menu in mobile.

7.4.1 Understanding how directory items are passed down to the sidebar

If you take a look inside the sidebar.tsx file, you will find the following piece of code.

interface SideBarProps {
  docsDirectories: PageItem[]
  flatDirectories: Item[]
  fullDirectories: Item[]
  asPopover?: boolean
  headings: Heading[]
  includePlaceholder: boolean
}

These are the props that are passed on to the Sidebar function component. What's important is what docsDirectories, flatDirectories and fullDirectories pertain to. If we look at

const {
activeType,
activeIndex,
activeThemeContext,
activePath,
topLevelNavbarItems,
docsDirectories,
flatDirectories,
flatDocsDirectories,
directories
} = useMemo(
() =>
normalizePages({
list: pageMap,
locale,
defaultLocale,
route: fsPath
}),
[pageMap, locale, defaultLocale, fsPath]
)
, we'll see that these three parameters are fetched from calling the normalizePages() function from the nextra/normalize-pages package.

  • docsDirectories are used in the sidebar menu when in desktop mode. You can find it on the left side of the screen, showing the structure of the page and of the parent directory.

Important

This array can be extended so it includes our custom private property we've added to the _meta.json files. This can be done with the types we've defined earlier in theme/src/types.ts.

  • fullDirectories are used in the sidebar menu in mobile mode. The sidebar here takes on a role of both the navbar and the page contents. So, it will show the page routes alongside its children (hence the name fullDirectories).

Important

This array can be extended so it includes our custom private property we've added to the _meta.json files. This can be done with the types we've defined earlier in theme/src/types.ts.

  • flatDirectories are used inside the search component.

Important

This array can NOT be extended with the private property we've added to the _meta.json files, at least through normal augmentation. To include the private property in each item of this array, we'll have to find another way. For this section, we won't be needing to use this array, though.

Now that we have an idea what each xxxDirectories paramater pertains to, let's redefine them with our extended types. Inside theme/src/components/sidebar.tsx, change it to the following.

// theme/src/components/sidebar.tsx
interface SideBarProps {
  docsDirectories: ExtendedPageItem[]   // they have the `private` property
  flatDirectories: ExtendedItem[]       // they DON'T have the `private` property (used for search)
  fullDirectories: ExtendedItem[]       // they have the `private` property
  asPopover?: boolean
  headings: Heading[]
  includePlaceholder: boolean
}

Great! We are using a new type ExtendedItem that we haven't defined yet. Let's do that inside theme/src/types.ts, alongside the others.

// theme/src/types.tsx

export type ExtendedItem = { private?: PrivateInfo } & Item;

Awesome! 🥳

7.4.2 Conditionally rendering links in Menu inside the sidebar

If we take a closer look inside navbar.tsx, you will see the Menu function is what renders the links (it either receives fullDirectories or docDirectories arrays).

So this is the function where we want to make changes! 😊

First, let's change the MenuProps to our extended types so we have access to the private property we've defined inside our _meta.json files.

interface MenuProps {
  directories: ExtendedPageItem[] | ExtendedItem[]    // change here
  anchors: Heading[]
  base?: string
  className?: string
  onlyCurrentDocs?: boolean
}

And now, inside the Menu function, simply do the same thing we've done in the navbar!

function Menu({
  directories,
  anchors,
  className,
  onlyCurrentDocs
}: MenuProps): ReactElement {

  const session = useSession()

  return (
    <ul className={cn(classes.list, className)}>
      {directories.map(item => {

        if(!shouldLinkBeRenderedAccordingToUserRole(session, item))
          return null

        return !onlyCurrentDocs || item.isUnderCurrentDocsTree ? (
          item.type === 'menu' ||
          (item.children && (item.children.length || !item.withIndexPage)) ? (
            <Folder key={item.name} item={item} anchors={anchors} />
          ) : (
            <File key={item.name} item={item} anchors={anchors} />
          )
        ) : null
      }

      )}
    </ul>
  )
}

Note

In Nextra v3, you will only need to add the following code to the Menu function:

if(!shouldLinkBeRenderedAccordingToUserRole(session, item))
          return null

We are calling the useSession() hoook and using the shouldLinkBeRenderedAccordingToUserRole() function to render the links according to the person role!

This function is a function that we create in /theme/src/utils/render.tsx. Change it so it has the following piece of code following:

import { ExtendedUser } from "../../../src/types";
import { ExtendedItem, ExtendedPageItem, ExtendedMenuItem } from "../types";
import { SessionContextValue } from "next-auth/react";

/**
 * This functions tells if the user can see a given `navbar` or `sidebar` link item
 * according to its role.
 * @param session session from the user.
 * @param item item (can either be a page item, menu item or normal item)
 * @returns true if the link can be shown to the user. False if the user doesn't have authorization to see the link.
 */
export function shouldLinkBeRenderedAccordingToUserRole(
  session: SessionContextValue<boolean>,
  item: ExtendedItem | ExtendedPageItem | ExtendedMenuItem
) {
  const { data, status: session_status } = session;
  const user = data?.user as ExtendedUser;

  // Wait until the session is fetched (be it empty or authenticated)
  if (session_status === "loading") return false;

  // If it's a public user but the link is marked as private, hide it
  if (session_status === "unauthenticated") {
    if (item.private) return false;
  }

  // If the user is authenticated
  // and the page menu is protected or the role of the user is not present in the array, we block it
  if (session_status === "authenticated" && user) {
    if (item.private?.private) {
      const neededRoles = item.private.roles || [];
      const userRole = user.role;
      if (!userRole || !neededRoles.includes(userRole)) {
        return false;
      }
    }
  }

  return true;
}

And that's it! Congratulations, we've just conditionally rendered all the private properties in both our sidebar and navbar!

You may notice that the mega_private directory is completely hidden from the sidebar. This is because our person has a "user" role, which is different to what this directory requires.

Great job! 🎉

8. Linting the Markdown files

8.1 Linting in Nextra v2

Maintaining a consistent style guide is critical for documentation to be easily accessible. This saves time for the person reading it by making it more predictable to navigate.

With this in mind, it is a good idea to add automatic linting to our documentation .mdx files.

In this section, we'll focus on three things:

  • adding a script to check if any external URLs are dead or alive.
  • linting the .mdx files.
  • checking if relative links in files are valid.

These are extremely useful to maintain consistency and

Maintaining a consistent style guide is crucial for ensuring that documentation is easily accessible. This approach not only enhances readability but also saves time for readers by making the content more predictable and easier to navigate.

To achieve this, incorporating automatic linting into our documentation .mdx files is a smart move.

In this section, we will focus on three key tasks:

  • adding a acript to verify external URLs: This script will check if any external links are dead or alive, ensuring that all references are up-to-date and functional.

  • linting the .mdx files: Implementing linting tools will help maintain the consistency and quality of our Markdown files by automatically detecting and fixing common formatting pitfalls.

  • validating relative links: We will also check if the relative links within the files are valid, preventing broken links and enhancing the user experience.

These steps are instrumental in maintaining a high standard of documentation quality and consistency. Are you ready to get started? Let's go! 🏃‍♂️

8.1.1 Installing dependencies

Let's start by installing some dependencies. Run the following commands.

pnpm install -w --saveDev @actions/core contentlayer2 next-contentlayer2 markdown-link-check markdownlint-cli2 markdownlint-rule-relative-links
pnpm install -w next-compose-plugins
  • next-compose-plugins makes it easier to add plugins to our Next.js application.
  • contentlayer2 converts the content of the site into type-safe JSON data. This will be used to see the contents of the .mdx files to find URLs, internal or external. It is a maintained fork of the original contentlayer, which maintenance efforts have been halted (see contentlayerdev/contentlayer#651 (comment) for more information).
  • next-contentlayer2 is an adapter of contentlayer2 to Next.js projects.
  • markdown-link-check extracts links from Markdown texts and checks whether the link is alive (HTTP Code 200) or dead. It also checks mailto: links. This will be used solely for external and e-mail links.
  • markdownlint-cli2 is the successor of the original markdownlint-cli, a statis analysis tool to enforce standards and consistency for Markdown files. You can file the linting rules used in https://github.com/DavidAnson/markdownlint#rules--aliases.
  • markdownlint-rule-relative-links is a custom markdownlint rule to validate relative links. It will be used to check for internal/relative links between Markdown files.
  • @actions/core are a collection of functions for Github Actions. Because we want to employ these steps in our Github Actions, this will be used to mark as a step as failed in case linting fails.

Now we're ready to start implementing these features!

8.1.2 Checking external links

Let's start with arguably the "hardest" feature to implement. We're going to scour the Markdown files for external links and then check if they are alive or not.

First, head over to next.config.js. We are going to be adding a contentlayer2 plugin so it can correctly get the site content.

Head over to the file and use withPlugins from next-compose-plugin to make it easy to add other plugins. We are adding the withContentLayer plugin to our app.

// next.config.js


const withPlugins = require('next-compose-plugins'); // import this
const { withContentlayer } = require('next-contentlayer2'); //import this
const withNextra = require("nextra")({
  theme: "./theme/src/index.tsx",
  themeConfig: "./theme.config.tsx",
  defaultShowCopyCode: true
});

// Use `withPlugins` to compose the plugins and add the `withContentLayer` plugin we've imported
module.exports = withPlugins([withNextra, withContentlayer]);

Next, let's change our tsconfig.json and add a baseUrl and a path to path to it. This will make it so contentlayer is accessible in our script.

// tsconfig.json

// ...
"compilerOptions": {
    // ...
    "baseUrl": ".",
    "paths": {
      "@/*": ["./*"],
      "contentlayer/generated": ["./.contentlayer/generated"]
    }
}
// ...

Now, we need to define the configuration for contentlayer2. We are going to define two documents to match:

  • the .mdx pages.
  • the _meta.json files.

contentlayer2 needs to be aware of these so they can properly construct the site contents.

For this, in the root of the project, create a file called contentlayer.config.js.

// contentlayer.config.js

import { defineDocumentType, makeSource } from 'contentlayer2/source-files'

const computedFieldsPage = {
  slug: {
    type: 'string',
    resolve: (doc) => doc._raw.sourceFileName.replace(/\.mdx$/, ''),
  },
}

export const Page = defineDocumentType(() => ({
  name: 'Page',
  filePathPattern: `**/*.mdx`,
  computedFieldsPage,
}))

export const MetaJson = defineDocumentType(() => ({
  name: 'MetaJson',
  filePathPattern: `**/_meta.json`,
}))

export default makeSource({
  contentDirPath: 'src/pages',
  documentTypes: [Page],
  onUnknownDocuments: 'skip-ignore'
})

As you can see, we are using defineDocumentType to define a type of document expected to find in the docs. In our case, we define Page for .mdx pages and MetaJson for _meta.json files. We set the contentDirPath to src/pages, which is the directory where we want contentlayer2 to run.

Now we're ready to create our script! For this, create a folder scripts in the root of the project. Inside this folder, add a file called link-check.mjs. This will be our script!

import core from "@actions/core";
import markdownLinkCheck from "markdown-link-check";
import { allDocuments } from "../.contentlayer/generated/index.mjs";

// String to tag internal links to be ignored when checking in `markdown-lint-check`
const ignoreURLTag = "#ignoreURL#";
const localhost = "http://localhost:3000";
const baseUrl = process.argv.find((arg) => arg.includes("--baseUrl"));

/**
 * This function checks external links that are present in the markdown files.
 * It checks if they're alive or dead using `markdown-lint-check`.
 * @param {string} markdownAllBody all of the markdowns text
 * @returns array of dead links
 */
export async function findDeadExternalLinksInMarkdown(markdownAllBody) {
  // Options for `markdown-lint-check`.
  // It catches internal links and external links.
  const configOpts = {
    projectBaseUrl: baseUrl ? baseUrl.replace("--baseUrl=", "https://") : localhost,
    replacementPatterns: [
      // Match links like `[example](/example) and adding tag to later ignore
      {
        pattern: "^/",
        replacement: `${ignoreURLTag}{{BASEURL}}/`,
      },
      // Match links like `[example](./example) and adding tag to later ignore
      {
        pattern: "^./",
        replacement: `${ignoreURLTag}{{BASEURL}}/`,
      },
    ],
  };

  // Runs `markdown-link-check` only on external links.
  // If this step fails, check the output. You should find "input". It shows the link that caused the error.
  // Most likely the reason it's failing is because the link doesn't start with `./` or `/` (e.g. [example](index.md), instead of [example](./index.md)).
  return new Promise((resolve, reject) => {
    markdownLinkCheck(markdownAllBody, configOpts, function (error, linkCheckresults) {

      // Filtering links for only external URLs
      const results = linkCheckresults.map((linkCheckResult) => ({ ...linkCheckResult }));
      const filteredResults = results.filter(function (item) {
        return !item.link.includes(ignoreURLTag);
      });

      // Collecting dead links
      const deadLinks = filteredResults.filter(result => result.status === "dead").map(result => result.link);

      resolve(deadLinks);
    });
  });
}

// This section only runs when the script is invoked from `package.json`.
// This "if" statement checks if the script is being run from the command line rather than being imported as module - it's the entry point of the script.
if (process.argv[1] === new URL(import.meta.url).pathname) {
  (async () => {
    // Get the map of all the markdown files after running `contentlayer2`
    const allBody = allDocuments.map(({ body }) => body.raw).join("\n--- NEXT PAGE ---\n");
    const deadLinks = await findDeadExternalLinksInMarkdown(allBody);

    if (deadLinks.length > 0) {
      console.error("Dead links found:", deadLinks);
      core.setFailed();
    } else {
      console.log("All links are valid! 🙌");
    }
  })();
}

Let's go over the changes we've made!

  • we create a function findDeadExternalLinksInMarkdown() that is responsible for finding all the external links given a markdown text.
    • we first initialize configOpts, where we define the parameters for markdownLintCheck, markdown-lint-check's main function. In this configuration, we define the base URL of the project and, most importantly, the patterns to replace internal links we find. For this, we define a tag (a string) that we'll later use to filter out these internal links, leaving only the external links inside our .mdx files.
    • we return a Promise which calls markdownLintCheck, which receives the body of all the Markdown files and the configuration we've just defined. Inside the callback function, we filter out the internal links and return an array of dead links.
  • we added an if statement that is only invoked when the script is called from the command line. We do this to make it easier to test with Jest. In here, we call the function we've just created (findDeadExternalLinksInMarkdown()) and provide the text of all the .mdx files. We get this text after contentlayer2 parses through our site. Inside this if statement, we call core.setFailed(); in case anything goes wrong, so the Github Actions workflow fails gracefully.

And that's it! All that we need to do call this script inside our package.json! Simply head over there and, inside the scripts section, add the following line.

"lint:check-external-links": "contentlayer2 build --verbose && node scripts/link-check.mjs"

In here, we call contentlayer2 to build the content of the .mdx files that is later used inside link-check.mjs.

Important

contentlayer2 generates the data inside a folder called .contentlayer. Add it to your .gitignore so it isn't pushed to version control!

And that's it! All you need to do now is run the script!

pnpm run lint:check-external-links

Depending on the links found in your documentation, the console will log the dead links or if everything is valid!

Important

If you have links in the following format [link](invalid), where the link is internal but does not have / or ./ in the beginning, this script will fail because of markdown-lint-check not being able to create an URL from invalid.

You will be able to see the culprit index under the input property in the crash log.

TypeError: Invalid URL
    at new URL (node:internal/url:797:36)
    ...
  code: 'ERR_INVALID_URL',
  input: 'invalid'

8.1.3 Linting and checking for broken internal relative links

Now let's lint our .mdx files and check if the relative links are properly set up! All we need to do is add configuration to the markdownlint-cli2 package we've installed. This package will lint the .mdx files according to rules we've defined. We are going to use the default ones, plus the markdownlint-rule-relative-links one we've installed.

For this, in the root of the project, add a file called .markdownlint-cli2.mjs.

// .markdownlint-cli2.mjs

// @ts-check

const options = {
  config: {
    default: true,
    "relative-links": true,
    MD041: false,
    MD013: false,
    "no-inline-html": {
      // Add React components we want to allow here
      allowed_elements: ["LoginOrUserInfo", "Info"],
    },
  },
  globs: ["src/pages/**/*.{md,mdx}"],
  ignores: ["**/node_modules", "theme", "scripts"],
  customRules: ["markdownlint-rule-relative-links"],
};

export default options;
  • we are using the default options, with some overrides so it works best with .mdx files. We've disabled MD041 and MD013. In addition to this, we only allow specific inline HTML components in our .mdx files, those being LoginOrUserInfo and Info components we've defined for the authentication flow.
  • inside globs, we add the paths of the .mdx files we want to lint.
  • in ignores, we define paths to ignore the linting.
  • in customRules, we add additional, third-party custom rules. In here, we use the markdownlint-rule-relative-links we've installed to check the relative internal links.

And that's the hart part done! Because markdownlint-cli2 uses this config path by default, all that's left is calling it in our package.json. Add the following scripts to it.

    "lint:fix-markdown": "markdownlint-cli2 --fix",
    "lint:check-markdown": "markdownlint-cli2",
  • lint:fix-markdown is meant to be used in development mode. It parses the .mdx files and attempts to fix any issues.
  • lint:check-markdown is meant to be used in CI (Github Actions).

And you're done! 🎉 Awesome work!

You can run the script try and fix any issues you may have in your files.

pnpm run lint:fix-markdown

The console will log any issues that arise.

8.1.4 Adding to CI

Because we've automated the linting, it's simple for us to use in our Github Actions workflow!

name: Build & Test
on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]
jobs:
  ci:
    timeout-minutes: 60
    runs-on: ubuntu-latest
    steps:

    # Setup environment (Node and pnpm)
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v4
      with:
        node-version: lts/*
    - uses: pnpm/action-setup@v4
      with:
        version: latest

    # Install dependencies
    - name: Install dependencies
      run: pnpm install --no-frozen-lockfile

    # ADD THESE LINES ----------------------------------------
    # Run `markdownlint-cli2` to lint markdown files
    - name: Running `markdownlint-cli2`
      run: pnpm run lint:check-markdown

    # Run script to check for dead links
    - name: Checking for dead links
      run: pnpm run lint:check-external-links
    # ADD THESE LINES ----------------------------------------

    # Run build
    - name: Build Next.js app
      run: pnpm run build

    # Run unit tests
    - name: Running unit tests
      run: pnpm run test

    # Run E2E tests
    - name: Install Playwright Browsers
      run: pnpm exec playwright install --with-deps
    - name: Run Playwright tests
      run: pnpm exec playwright test
      env:
        AUTH_SECRET: some_nextauth_secret
        TEST_PASSWORD: password

    - uses: actions/upload-artifact@v4
      if: always()
      with:
        name: playwright-report
        path: playwright-report/
        retention-days: 30

As you can see, we simply call the scripts from our package.json file!

Simple as! 😃

8.2 Linting in Nextra v3

In Nextra v3, we cannot use contentlayer2 because it clashes with Nextra's dependencies, making it impossible to use both in the same project.

With this in mind, we are streamlining the linting process. We're going to keep using remark and remark-lint to lint our .mdx files.

8.2.1 Install packages

Run the following command:

pnpm add --save-dev -w yargs ignore url glob remark vfile-reporter to-vfile remark-mdx remark-gfm remark-lint remark-validate-links remark-lint-no-dead-urls

We are going to be using these libraries to create a script that will use remark and run through the .mdx files and process them.

8.2.1 Adding ignore file

Let's add a file called .remarkignore file. We'll use this file to specify the directories we want to ignore when linting the .mdx files.

# `_app.mdx` and `index.mdx` files  are essentially `.js` files that don't need linting.
src/**/_app.mdx
src/**/index.mdx

# Parked/archived directories won't be linted
src/**/**/_*


# Linting rules don't need to apply to `README.md`, only for documentation
README.md

8.2.2 Create the script

Let's create a folder called lint and create a file called lint.mjs inside it.

import fs from "fs";
import path from "path";
import yargs from "yargs";
import ignore from "ignore";
import chalk from "chalk";
import { fileURLToPath } from "url";
import { glob } from "glob";
import { remark } from "remark";
import { reporter } from "vfile-reporter";
import { hideBin } from "yargs/helpers";
import {read} from 'to-vfile'

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

// Counter to keep track of total warnings
let totalWarnings = 0;

/**
 *  Load and parse the .remarkignore file.
 * @param {string} path path to the ignore file.
 * @returns {Promise<ignore>} A promise that resolves with the ignore instance.
 */
async function loadIgnoreFile(path) {
  try {
    const ignoreFileContent = fs.readFileSync(path, "utf-8");
    const ig = ignore().add(ignoreFileContent);
    return ig;
  } catch (err) {
    console.warn("No .remarkignore file found, proceeding without ignoring files.");
    return ignore();
  }
}

/**
 *  Process a single file with the given preset and formatting flag.
 * @param {string} filePath  The path to the file
 * @param {string} preset  The preset object to use for linting with array of remark plugins
 * @param {boolean} shouldFormat  Flag to indicate whether formatting (changing markdown files) should be applied
 */
async function formatSingleFile(filePath, preset, shouldFormat) {
  try {
    // Create a virtual file with metadata like `stem`.
    // This is needed for rules related to files.
    const vfile = await read(filePath);

    // Process the file with the given preset
    const file = await remark().use(preset).process(vfile);

    // Check if there are any issues
    const issues = file.messages.length;

    // Print the issues
    if (issues === 0) {
      console.log(`${chalk.green(filePath)}: no issues found`);
    } else {
      totalWarnings += file.messages.length;
      console.error(reporter(file));
    }

    // Write the file back if formatting is enabled
    if (shouldFormat) {
      fs.writeFileSync(filePath, file.value.toString());
    }
  } catch (err) {
    console.error(`Error processing file ${filePath}:`, err);
  }
}

// Main function to handle glob pattern and process files
/**
 * Process files based on the given pattern, preset, and formatting flag.
 * @param {string} pattern The glob pattern to match files.
 * @param {string} pattern The path to the ignore file.
 * @param {string} preset The path to the preset object to use for linting with array of remark plugins.
 * @param {boolean} shouldFormat  Flag to indicate whether formatting (changing markdown files) should be applied.
 * @param {boolean} failOnError Flag to indicate whether to fail command if any error is found.
 * @returns {Promise<void>} A promise that resolves when all files are processed.
 */
async function processFiles(pattern, ignoreFile, preset, shouldFormat, failOnError) {
  try {
    // Load the ignore file and get the list of files
    const ig = await loadIgnoreFile(ignoreFile);
    const files = await glob(pattern);

    if (files.length === 0) {
      console.log("No files matched the given pattern.");
      return;
    }

    // Filter out files that are ignored
    const filteredFiles = files.filter((file) => !ig.ignores(file));

    if (filteredFiles.length === 0) {
      console.log("All matched files are ignored.");
      return;
    }

    // Process each file
    for (const file of filteredFiles) {
      await formatSingleFile(file, preset, shouldFormat);
    }

    // Print total warnings and fail command if needed
    if (totalWarnings > 0) {
      console.log(`${chalk.yellow("⚠")} Total ${totalWarnings} warning(s)`);
      if (failOnError) {
        process.exit(1);
      }
    }
  } catch (err) {
    console.error("Error during file processing:", err);
  }
}

// Use yargs to handle command-line arguments
const argv = yargs(hideBin(process.argv))
  .option("pattern", {
    alias: "p",
    type: "string",
    description: "Glob pattern to match files",
    demandOption: true,
  })
  .option("preset", {
    alias: "r",
    type: "string",
    description: "Path to the preset file",
    demandOption: true,
  })
  .option("ignoreFile", {
    alias: "i",
    type: "string",
    description: "Path to the ignore file",
    demandOption: false,
  })
  .option("format", {
    alias: "f",
    type: "boolean",
    description: "Flag to indicate whether formatting should be applied",
    default: false,
  })
  .option("failOnError", {
    alias: "e",
    type: "boolean",
    description: "Flag to indicate whether to fail command if any error is found",
    default: false,
  }).argv;

// Dynamically import the preset file
const presetPath = path.resolve(argv.preset);
const preset = await import(presetPath);

// Start processing files
processFiles(argv.pattern, argv.ignoreFile, preset.default, argv.format, argv.failOnError);

Let's break down the script!

  • processFiles function:

    • Asynchronously processes files based on a given pattern.
    • Loads an ignore file and retrieves a list of files matching the pattern.
    • Filters out ignored files.
    • Processes each remaining file using a specified preset and formatting option.
    • Logs total warnings and exits with an error code if failOnError is true.
  • argv constant:

    • Uses yargs to handle command-line arguments.
    • Defines options for pattern, preset, ignoreFile, format, and failOnError.
    • Parses and stores the command-line arguments.
  • presetPath constant:

    • Resolves the path to the preset file specified by the user.
  • preset constant:

    • Dynamically imports the preset file based on the resolved path.
  • processFiles invocation:

    • Calls the processFiles function with the parsed command-line arguments and the imported preset.

With this, this script can be called as a CLI, which is what we'll do in the package.json. We'll provide all the parameters there!

8.2.3 Adding preset file

Before calling our script, we need to create a preset of remark rules that we want to apply to our .mdx files.

In the same lint folder, add remark-preset.mjs and paste the following code.

import remarkMdx from "remark-mdx";
import remarkGfm from "remark-gfm";
import remarkLint from "remark-lint";
import remarkValidateLinks from "remark-validate-links";

// This configuration file is meant to be used in `remark CLI` to check for warnings.
// This means that if any of these fail, the command still succeeds.
// See https://github.com/unifiedjs/unified-engine#options for the options.
const remarkPreset = {
  plugins: [
    // Support `mdx` and GFM
    remarkMdx, // https://mdxjs.com/packages/remark-mdx/
    remarkGfm, // https://github.com/remarkjs/remark-gfm

    // Introduce remark linting rules
    remarkLint,

    // Validating URLs
    remarkValidateLinks, // https://github.com/remarkjs/remark-validate-links
  ],
  // Override `remark-stringify` rules when serializing text.
  // See https://github.com/remarkjs/remark/tree/main/packages/remark-stringify#options for options.
  settings: {
    emphasis: "_",
    strong: "*",
    bullet: "-",
  },
};

export default remarkPreset;

We're using the default remark-lint rules, and adding support to mdx files (by using remark-mdx) and GFM (by using remark-gfm). Links are being validated by remark-validate-links.

8.2.4 Adding to package.json

Let's call the script in our package.json so we can call it in CI or locally.

  "scripts": {
    "lint:check-markdown": "node './lint/lint.mjs' --pattern 'src/pages/**/*.mdx' --ignoreFile './.remarkignore' --preset  './lint/remark-preset.mjs' --failOnError",
    "lint:fix-markdown": "node './lint/lint.mjs' --pattern 'src/pages/**/*.mdx' --ignoreFile './.remarkignore' --preset  './lint/remark-preset.mjs' --format"
  },

As you can see, we are adding the pattern, ignoreFile and preset arguments when calling the script. Now we are able to run these, similarly to what we've done in 8.1 Linting in Nextra v2!

Star the repo!

Thanks for learning with us! If you find it useful, please give the repo a star! ⭐️

HitCount