Skip to content

⏭️ A comprehensive demo of using nextra for a documentation site with Auth (private pages), Search and Analytics!

License

Notifications You must be signed in to change notification settings

dwyl/nextra-demo

Folders and files

NameName
Last commit message
Last commit date

Latest commit

ce13b7a · Jul 4, 2024

History

54 Commits
Jun 27, 2024
Jun 25, 2024
Jul 4, 2024
Jul 2, 2024
Jul 4, 2024
Jun 21, 2024
Jun 25, 2024
Jun 17, 2024
Jul 4, 2024
Jun 27, 2024
Jun 18, 2024
Jul 3, 2024
Jul 1, 2024
Jun 25, 2024
Jun 21, 2024
Jun 21, 2024
Jul 1, 2024
Jul 1, 2024
Jul 3, 2024
Jul 2, 2024
Jul 3, 2024
Jun 25, 2024

Repository files navigation

Learn Nextra

HitCount contributions welcome

A comprehensive demo of using Nextra for documentation. Learn how to create a site with authentication (private pages), search and analytics!

Why?

If you work building software, you must have come across technical documentation at some point in your career. Either be it an API reference or internal manuals, technical documentation is vital for the development process. Everyone expects it, but without it, knowledge of the application becomes siloed within individuals which lead to inefficiencies in the whole development process.

To ensure long-term sustainability of your software projects, having a clear and detailed record of the why, how and what helps maintain consistency and increases the quality of your work!

Whether it is meant as an internal reference or you're creating documentation for public-facing clients that are using your project, having documentation benefits both the developers and users alike.

What?

Nextra is a framework that makes it easy for you to create a documentation static website fully optimized and powered by Next.js.

It simpifies the process of creating and maintaining documentation by offering a myriad of features, such as:

  • themable design system.
  • markdown support.
  • full-text search out-of-the-box.
  • automatic a11y.
  • filesystem-based organization.

Its integration with Next.js means that it's fully customizable, making it ideal for your team to quickly generate a website that is useful for both internal and external documentation.

Who?

This walkthrough is meant for beginners of the framework but also seasoned software developers that want to create internal and external documentation for their teams.

This will not be an introduction to technical writing, it will solely focus on the Nextra framework. We recommend visiting https://developers.google.com/tech-writing/overview if you are interested in learning more about technical writing.

If you find it useful, give the repo a star! ⭐️

If you get stuck, have questions/suggestions or just want to discuss this further, do open an issue!

How?

Are we ready to start? Let's go!

Tip

Some of the information found in this document can also be found in the Nextra's offical docs. We recommend going through their documentation (it's not long) to better have a feeling over the framework.

When using Nextra, you first have to make a choice:

  • you either use a default theme.
  • you customize your own.

The vast majority of people will go for the former. However, if you are looking to have a more customized look, you may have to create your own theme. It's easier to start with a custom theme than using a default one and change it afterwards.

In our case, we'll start with the default theme and change it if needed.

0. Start a new project

Let's create our project. We first need to install some dependencies. We're going to use pnpm throughout the project to manage dependencies.

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

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

Now, let's add some scripts to package.json to be able to run our application. Add the following:

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

Your package.json will look something like this.

{
  "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, we need to add a Nextra config file. Because we're starting a project from scratch, it has no idea it's a Next.js project. Create a file called next.config.js and add the following.

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

Here we add global configurations to our project. For example, we've added defaultShowCopyCode: true, which will make it so code snippets will have a copy button.

Lastly, we need to create a corresponding theme.config.jsx file in your project’s root directory. It will be used to configure our Nextra's site theme. We'll just add the basic ones.

Tip

You can check the full theme configurations in their documentation.

Now we're ready to bounce! Because Nextra is a file-based system framework (much like Next.js), you will have to create all the documentation under the pages folder. Let's start create our first one under pages/index.mdx!

# Welcome to our docs!
 
Hello, world!

Note

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

Let's see our handiwork. Run pnpm run dev and visit http://localhost:3000. You should see your page!

Congratulations! You've just set up your documentation website! Give yourself a pat on the back. 👏

1. Organizing 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 in the top-level _meta.json, like so.

{
  "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"
}

As you can see, you can group pages together in directories to create an hierarchy of pages, thus organizing them neatly.

If you want a folder to have their own page, you can simply add a .mdx file a level above, with the name of the folder. Let's say we want api_reference to have an introductory page when we click on it on the sidebar. Simply create the file above the folder's level!

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

Alternatively, you can create an index.mdx file inside api_reference to achieve a similar effect.

Now fill each .mdx with any content you want. Run pnpm run dev and see your pages organized!

1.1 External links and hidden routes

You can further 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.

You can also hide it. Simply 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 further organize your content. You can show special pages on the navigation bar instead of the sidebar. To do this, you need to use the "type": "page" property in the _meta.json file at top level.

Let's add the API Reference to the navbar. To do this, edit the top-level _meta.json to look like so:

{
  "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, because it's 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 users.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"
  },
  "users": "users"
}

Notice that we've added "---". This will add 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! 🎉

There are the basics on how to organize your content inside your website!

2. Adding authentication

Now that our website is in working order, let's work in adding authentication to it! This is important because you may want to use this website for internal purposes, meaning that it can't be public-facing.

To address this issue, we are going to make the users have to sign in through a given provider to be able to visit private pages that we will define ourselves.

Let's rock! 🎸

We are going to be using Auth.js to streamline our 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

Great! Now let's create our secret. We are going to be using this secret as an environment variable, which is used by the library to encrypt token and e-mail verification hashes. Simply run:

npx auth secret

Your terminal will show this:

Need to install the following packages:
auth@1.0.2
Ok to proceed? (y) y

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

AUTH_SECRET=<your_secret>

Copy the secret and create a file called .env.local.

AUTH_SECRET=secret

Great! Now let's create the configuration files needed for Auth.js to work! Start by creating a file called auth.ts at the root.

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

And 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

Your imports may be complaining about not being able to find the @auth module. To fix this, 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"]
}

And that's it! We're all 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, a 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 to build 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.

Let's authenticate our users through a GitHub provider!

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 inside our GitHub account. Navigate to https://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. However, you'll have to chance this to your product's domain in production.

// Local
http://localhost:3000/api/auth/callback/github
 
// Prod
https://app.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 a file called index.ts (components/LoginOrUserInfo/index.tsx).

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()}>SIGN IN</button>;
  }
}

We are exporting 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.

We are 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.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 two main reasons: we want to keep the same look n' feel of the application as it stands and we don't want to re-implement and waste the time trying to do so.

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! 🎉

Change theme

zones (basicamente ter uma home page para ir aos docs e depois ter o signin noutro url)

shuding/nextra#93

About

⏭️ A comprehensive demo of using nextra for a documentation site with Auth (private pages), Search and Analytics!

Resources

License

Stars

Watchers

Forks