A comprehensive demo
of using Nextra
for documentation.
Learn how to create a site with
authentication
(private pages),
search and analytics!
- Learn Nextra
- Why?
- What?
- Who?
- How?
- Change theme
- zones (basicamente ter uma home page para ir aos docs e depois ter o signin noutro url)
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.
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.
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!
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.
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. 👏
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"
}
}
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!
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!
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!
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!
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.
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.
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 askey
and the array of roles needed to access the path asvalue
. This variable will be changed on build-time, so do not change its name!- we export
auth
as default frommiddleware.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 amatcher
property. We useRegEx
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.
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 aprofile()
callback. In this callback, we receive aProfile
returned by theOAuth
provider (in our case, it'sGitHub
). We can return a subset using the profile's information to define user's information in our application. By default, it returns theid
,email
,name
,image
, but we can add more. That's what we did, by adding therole
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 ourJWT
tokens andsession
cookies. In order, thejwt
callback receives the user information we've defined earlier. We append therole
to the token. Afterwards, thesession
callback is invoked, where we use therole
property we've just appended to thetoken
and add it to thesession
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:
- nextauthjs/next-auth#9836
- nextauthjs/next-auth#9609
- https://stackoverflow.com/questions/72073321/why-did-user-object-is-undefined-in-nextauth-session-callback
- nextauthjs/next-auth#8456
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.
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!
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.
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!
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.
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.
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.
- in each
- after iteration, we do a second pass to clean-up possible invalid routes.
- we return a map of
private route
as key androles array
as value.
And that's it!
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.
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
andgeneratePrivateRoutes.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.
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:
-
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. -
use the code from the
nextra-theme-docs
to keep the default theme, use it as acustom-theme
intheme.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!
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
tosrc/contexts/config.tsx
. This is because, as is, the code threw aReferenceError: Cannot access 'DEFAULT_THEME' before initialization
error. - removed
tailwind.config.js
andpostcss.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
.
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'
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!
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.
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
.
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 insideauth
. - use the
PrivateInfo
type insidesrc/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! 🎸
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! 🎉
- custom theme is the only option
- although yuyou can change some aspects of the sidebar (https://nextra.site/docs/docs-theme/theme-configuration#customize-sidebar-content), you can't do it on the navbar (shuding/nextra#2799). Either way, the way Nextra does it doesn't allow us to have access to the authorization and change the display accordingly. We need to go deeper.