This Banner is For Sale !!
Get your ad here for a week in 20$ only and get upto 15k traffic Daily!!!

How to build a docs site with Next.js and Contentlayer


This submit was initially printed on my blog

Have you ever ever wished to construct a docs website in your open-source library or aspect mission?

Docusaurus is a well-liked framework for producing docs from Markdown/MDX information and it does a fantastic job! Nonetheless, maybe you could have already an present Subsequent.js web site so don’t need to create one other one from a complete new codebase or on one other area.

On this submit, we’ll construct a static docs generator powered by Subsequent.js and Contentlayer. Contentlayer is one thing like Prisma for native Markdown content material — you outline the schema of paperwork to make Contentlayer generate validated (so type-safe) JSON knowledge you possibly can import from wherever.



What we’ll construct on this submit




Create a Subsequent.js app and set up dependencies

Now, let’s get began by creating a brand new Subsequent app. Be sure that to opt-in to TypeScript.

pnpm create next-app
# And reply the prompts
pnpm add contentlayer next-contentlayer sass clsx @heroicons/react remark-gfm rehype-prism-plus
Enter fullscreen mode

Exit fullscreen mode



Join Contentlayer with Subsequent.js

Comply with the Getting Began to combine Contentlayer into Subsequent.js.

// subsequent.config.mjs
import { withContentlayer } from "next-contentlayer";

/** @sort {import('subsequent').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  swcMinify: true,
};

module.exports = withContentlayer(nextConfig);
Enter fullscreen mode

Exit fullscreen mode

A cool factor is that Contentlayer triggers Quick Refresh as you edit Markdown content material, when you want handbook reloads with a naive fs.readFile method.



Outline Contentlayer doc

Then, we’ll outline a Docs schema. You might really feel accustomed to this (and code era) when you have ever used Prisma.

We included remark-gfm to assist GitHub Flavored Markdown and rehype-prism-plus for syntax spotlight of code blocks.

// contentlayer.config.ts
import { defineDocumentType, makeSource } from "contentlayer/source-files";
import rehypePrismPlus from "rehype-prism-plus";
import remarkGfm from "remark-gfm";

export const Docs = defineDocumentType(() => ({
  title: "Docs",
  filePathPattern: `docs/**/*.mdx`,
  contentType: "mdx",
  fields: {
    id: {
      sort: "string",
    },
    title: {
      sort: "string",
      required: true,
    },
  },
  computedFields: {
    id:  doc._raw.flattenedPath.substitute("docs/", ""),
    ,
    slug: {
      sort: "string",
      resolve: (doc) => doc._raw.flattenedPath.substitute("docs/", ""),
    },
  },
}));

export default makeSource({
  contentDirPath: "content material",
  documentTypes: [Docs],
  mdx: {
    remarkPlugins: [remarkGfm],
    rehypePlugins: [[rehypePrismPlus, { ignoreMissing: true }]],
  },
});

Enter fullscreen mode

Exit fullscreen mode



Add MDX content material

Let’s write MDX content material and add it beneath content material/docs listing. Be sure that to place a picture named automotive.jpg in /public listing.

/content material/docs/pattern.mdx
---
id: "pattern"
title: "That is the title"
---

## Heading degree 2

### Heading degree 3

Ullamco et `nostrud magna` commodo nostrud occaecat quis pariatur id ipsum. Ipsum
consequat enim id excepteur consequat nostrud esse esse fugiat dolore.
Reprehenderit occaecat exercitation non cupidatat in eiusmod laborum ex eu
fugiat aute culpa pariatur. Irure elit proident consequat veniam minim ipsum ex
pariatur.

- record merchandise 1
- record merchandise 2
- record merchandise 3

### Heading degree 3

![image alt](/automotive.jpg)

``ts
perform sum(a: quantity, b: quantity) {
  return a + b
}
``
Enter fullscreen mode

Exit fullscreen mode

Line on the high is the file title. Don’t embody it!



Create a dynamic route for docs pages

Now, you possibly can simply import all of the docs from contentlayer/generated with validated and typed metadata. No have to dig into the file construction or deal with invalid content material. Clear!

// /pages/docs/[...slug].tsx
import React from "react";
import { GetStaticPathsResult, GetStaticPropsContext } from "subsequent";
import { useMDXComponent } from "next-contentlayer/hooks";
import { allDocs, sort Docs } from "contentlayer/generated";

sort Props = {
  doc: Docs;
};

export default perform DocsPage({ doc }: Props) {
  const MDXContent = useMDXComponent(doc.physique.code);
  return (
    <div>
      <h1>{doc.title}</h1>
      <MDXContent />
    </div>
  );
}

export async perform getStaticProps({ params }: GetStaticPropsContext) {
  const slug = params?.slug;
  if (!Array.isArray(slug)) {
    return {
      notFound: true,
    };
  }
  const doc = allDocs.discover((submit) => submit.slug === slug.be part of("/"));

  if (!doc) {
    return { notFound: true };
  }

  const props: Props = {
    doc,
  };

  return {
    props,
  };
}

export async perform getStaticPaths(): Promise<GetStaticPathsResult> {
  const paths = allDocs.map((doc) => ({
    params: { slug: doc.slug.break up("/") },
  }));

  return {
    paths,
    fallback: false,
  };
}
Enter fullscreen mode

Exit fullscreen mode

Rendered HTML

Add CSS information
Earlier than writing types, we’d like so as to add world CSS information.

  • reset.css — obtain here
  • tokens.css — obtain here
  • prism.css — obtain any here
// /pages/_app.tsx
import "../types/normalize.css"; // Reset browser defaults
import "../types/tokens.css"; // Contains CSS variables
import "../types/prism.css"; // Syntax spotlight
Enter fullscreen mode

Exit fullscreen mode

If Prism is working appropriately, code blocks (surrounded by triple backticks) look fairly just like the under:

Highlighted JavaScript function that sums



Type MDX content material

We’d wish to type the article by wrapping <MDXContent /> with <Markup /> part. This isn’t the one method however the easiest resolution.

/// parts/Markup/index.tsx
import React from "react";
import types from "./types.module.scss";

sort Props = {
  kids?: React.ReactNode;
};

export default perform Markup({ kids }: Props) {
  return <div className={types.container}>{kids}</div>;
}
Enter fullscreen mode

Exit fullscreen mode

// /parts/Markup/types.module.scss
.container {
  :the place(h2, h3, h4) {
    margin-top: 2.25rem;
    margin-bottom: 1.2rem;
    line-height: 1.3;
  }

  :the place(desk, img, blockquote) {
    margin-top: 1.5rem;
    margin-bottom: 1.5rem;
  }

  :the place(p) {
    margin-top: 1rem;
  }

  :the place(ul, ol):not(:first-child) {
    margin: 1.5rem 0;
  }

  :the place(ul, ol) :the place(ul, ol) {
    // margin-top must be the identical because the hole of record gadgets.
    margin: 0.5rem 0 0 0;
  }

  > *:first-child {
    margin-top: 0;
  }

  :the place(h2) {
    font-weight: var(--font-weight-bold);
    font-size: var(--font-size-2xl);
    border-bottom: 1px strong var(--color-gray-200);
    padding-bottom: 0.3rem;
  }

  :the place(h3) {
    font-weight: var(--font-weight-bold);
    font-size: var(--font-size-xl);
  }

  :the place(h4) {
    font-weight: var(--font-weight-bold);
    font-size: var(--font-size-lg);
  }

  :the place(a) {
    colour: var(--color-primary-600);
    font-weight: var(--font-weight-medium);

    &:hover {
      text-decoration: underline;
    }
  }

  :the place(code):not([class*="language-"]) {
    background-color: var(--color-gray-100);
    padding: 0.1em 0.3em;
    border: 1px strong var(--color-gray-200);
    border-radius: var(--rounded-md);
    font-family: var(--font-family-code);
    font-weight: var(--font-weight-medium);
    font-size: 0.9em;
    overflow: auto;
  }

  :the place(pre):not([class*="language-"]) {
    // Firefox would not assist :has() but, however OK as not so vital.
    &:has(code) {
      margin-top: 2rem;
      margin-bottom: 2rem;
    }
  }

  :the place(blockquote) {
    border-left: 0.2rem strong var(--color-gray-300);
    padding-left: 1em;
  }

  :the place(ul, ol) {
    padding-left: 1.5rem;
    show: flex;
    flex-direction: column;
    hole: 0.5rem;
  }

  :the place(ul) {
    list-style: disc;
  }

  :the place(ul[class~="contains-task-list"]) {
    list-style: none;
  }

  :the place(ol) {
    list-style: decimal;
  }

  :the place(p) {
    line-height: 1.75;
  }
}
Enter fullscreen mode

Exit fullscreen mode

// /pages/docs/[...slug].tsx
export default perform DocsPage({ doc }: Props) {
  return (
      {...}
      <Markup>
        <MDXContent />
      </Markup>
      {...}
  );
}
Enter fullscreen mode

Exit fullscreen mode

Styled HTML



Structure for the sidebar navigation

Then, let’s create a format for the sidebar navigation.

// /parts/DocsTemplate/index.tsx
import { useMDXComponent } from "next-contentlayer/hooks";
import { sort Docs } from "contentlayer/generated";
import Markup from "../Markup";
import types from "./types.module.scss";

sort Props = {
  doc: Docs;
};

export default perform DocsTemplate({ doc: { title, physique } }: Props) {
  const MDXContent = useMDXComponent(physique.code);
  return (
    <div className={types.container}>
      <div />
      <article className={types.article}>
        <header className={types.header}>
          <h1 className={types.title}>{title}</h1>
        </header>
        <Markup>
          <MDXContent />
        </Markup>
      </article>
    </div>
  );
}
Enter fullscreen mode

Exit fullscreen mode

// /parts/DocsTemplate/types.module.scss
.container {
  show: grid;
  grid-template-columns: 18rem 1fr;
  column-gap: 1rem;
}

.article {
  padding: 1rem;
}

.header {
  padding-bottom: 2rem;

  .title {
    font-weight: var(--font-weight-extrabold);
    font-size: var(--font-size-5xl);
    line-height: 1.2;
    margin: 0;
  }
}
Enter fullscreen mode

Exit fullscreen mode

// /pages/docs/[...slug].tsx
export default perform DocsPage({ doc }: Props) {
  return <DocsTemplate doc={doc} />;
}
Enter fullscreen mode

Exit fullscreen mode

Docs layout with blank space for the sidebar



Create Sidebar part

We need to make navigation settle for arbitrary ranges of nested classes like /class/article.mdx and /cateogry/another-category/article.mdx.

To assist this, we outline a recursive sort (not an official title) as follows:

// /sorts.ts
export sort NavItemCategory = {
  id: string;
  label: string;
  open?: boolean;
  gadgets: NavItem[]; // Self reference
};

export sort NavItemLink = {
  id: string;
  label: string;
  href: string;
};

export sort NavItem = NavItemCategory | NavItemLink;
Enter fullscreen mode

Exit fullscreen mode

And Sidebar part handles the handed knowledge in a recursive method:

// /parts/Sidebar/index.tsx
import { ChevronRightIcon } from "@heroicons/react/20/strong";
import clsx from "clsx";
import Hyperlink from "subsequent/hyperlink";
import { useRouter } from "subsequent/router";
import React from "react";

import { NavItem } from "../../sorts";

import types from "./types.module.scss";

sort Props = {
  gadgets: NavItem[];
};

export default perform Sidebar({ gadgets }: Props) {
  return (
    <nav className={types.container}>
      <ul className={types.record}>
        {gadgets.map((merchandise) => (
          <Merchandise key={merchandise.id} merchandise={merchandise} />
        ))}
      </ul>
    </nav>
  );
}


sort ItemProps = {
  merchandise: NavItem;
};

perform Merchandise({ merchandise }: ItemProps) {
  const router = useRouter();
  const isActive = React.useCallback(
    (href: string) => href === router.asPath,
    [router.asPath]
  );

  if ("gadgets" in merchandise) {
    // Class
    return (
      <li className={types.class}>
        <particulars>
          <abstract className={types.button}>
            {merchandise.label}
            <ChevronRightIcon />
          </abstract>
          <ul className={types.record}>
            {merchandise.gadgets.map((merchandise) => (
              <Merchandise key={merchandise.id} merchandise={merchandise} /> {/* Recursion */}
            ))}
          </ul>
        </particulars>
      </li>
    );
  } else {
    // Doc hyperlink
    return (
      <li key={merchandise.href}>
        <Hyperlink
          href={merchandise.href}
          className={clsx(
            types.button,
            isActive(merchandise.href) && types.isActive
          )}
          aria-current={isActive(merchandise.href) ? "web page" : undefined}
        >
          {merchandise.label}
        </Hyperlink>
      </li>
    );
  }
}
Enter fullscreen mode

Exit fullscreen mode

// /parts/Sidebar/types.module.scss
$rowGap: 0.25rem;

.container {
  padding: 1rem;
  border-right: 1px strong var(--color-gray-200);
  top: 100vh;
  place: sticky;
  high: 0;
}

.record {
  show: grid;
  row-gap: $rowGap;
  colour: var(--color-gray-600);
}

.class {
  particulars[open] {
    > abstract svg {
      rotate: 90deg;
    }
  }

  abstract {
    show: flex;
    align-items: heart;
    justify-content: space-between;
    user-select: none;

    svg {
      width: 1.6rem;
      top: 1.6rem;
      colour: var(--color-gray-500);
      translate: 0.25rem;
    }
  }

  abstract::-webkit-details-marker {
    show: none;
  }

  ul {
    margin-left: 1rem;
    margin-top: $rowGap;
  }
}

.button {
  show: flex;
  padding: 0.3rem 0.75rem;
  border-radius: var(--rounded-md);
  font-weight: var(--font-weight-medium);
  transition: var(--transition-bg);
  border: 1px strong clear;
  cursor: pointer;
  width: 100%;

  &.isActive {
    font-weight: var(--font-weight-semibold);
    colour: var(--color-primary-500);
    background-color: var(--color-gray-50);
    border-color: var(--color-gray-200);
  }

  &:hover {
    background-color: var(--color-gray-100);
  }
}
Enter fullscreen mode

Exit fullscreen mode



Check Sidebar with dummy knowledge

// /parts/DocsTemplate/index.tsx
export default perform DocsTemplate({ doc: { title, physique } }: Props) {
  const MDXContent = useMDXComponent(physique.code);
  return (
    <div className={types.container}>
      <Sidebar
        gadgets={[
          {
            id: "item1",
            href: "#",
            label: "Item 1",
          },
          {
            id: "category1",
            label: "Category 1",
            items: [
              {
                id: "category1-1",
                label: "Category 1-1",
                href: "#",
              },
              {
                id: "category1-2",
                label: "Category 1-2",
                href: "#",
              },
            ],
          },
        ]}
      />
      <article className={types.article}>
       {...}
      </article>
    </div>
  );
}
Enter fullscreen mode

Exit fullscreen mode

Docs with sidebar

Looks as if it’s working! The class is dealt with appropriately.



Sidebar knowledge from actual content material



Add categorized docs

/content material/docs/frameworks/react.mdx
---
id: "react"
title: "React"
---

Article about React.
Enter fullscreen mode

Exit fullscreen mode

/content material/docs/frameworks/vue.mdx
---
id: "vue"
title: "Vue"
---

Article about Vue.
Enter fullscreen mode

Exit fullscreen mode



Outline sidebar config

We would wish to specify the order and classes in sidebar.js. For the config file, we outline sorts to take away href and make label non-compulsory (and default to the title) as follows:

// /sorts.ts
sort Non-obligatory<T, Okay extends keyof T> = Choose<Partial<T>, Okay> & Omit<T, Okay>;

export sort DocsSidebarItemConfig =
  | NavItemCategory
  | Non-obligatory<Omit<NavItemLink, "href">, "label">;

export sort DocsSidebarConfig = {
  gadgets: DocsSidebarItemConfig[];
};
Enter fullscreen mode

Exit fullscreen mode

// /sidebar.js
/** @sort {import('./sorts').DocsSidebarConfig} */
const sidebar = {
  gadgets: [
    {
      id: "sample",
    },
    {
      id: "frameworks",
      label: "Frameworks",
      items: [
        {
          id: "react",
        },
        {
          id: "vue",
        },
      ],
    },
  ],
};

export default sidebar;
Enter fullscreen mode

Exit fullscreen mode

// /lib/docs.ts
import { allDocs } from "contentlayer/generated";
import sidebar from "../sidebar";
import { NavItem, DocsSidebarItemConfig, NavItemLink } from "../sorts";

export perform getSidebarItems(
  gadgets: DocsSidebarItemConfig[] = sidebar.gadgets
) {
  const consequence: NavItem[] = [];
  for (const merchandise of gadgets) {
    if ("gadgets" in merchandise) {
      // Class
      consequence.push({
        ...merchandise,
        gadgets: getSidebarItems(merchandise.gadgets),
      });
    } else {
      // Doc hyperlink
      const doc = allDocs.discover((d) => d.id === merchandise.id);
      if (!doc) proceed;
      consequence.push({
        ...merchandise,
        href: "/docs/" + doc.slug,
        label: merchandise.label ?? doc.title,
      });
    }
  }
  return consequence;
}
Enter fullscreen mode

Exit fullscreen mode

// /parts/DocsTemplate/index.tsx
sort Props = {
  doc: Docs;
  sidebarItems: NavItem[];
};

export default perform DocsTemplate({
  doc: { title, physique },
  sidebarItems,
}: Props) {
  const MDXContent = useMDXComponent(physique.code);
  return (
    <div className={types.container}>
      <Sidebar gadgets={sidebarItems} />
      <article className={types.article}>
       {...}
      </article>
    </div>
  );
}
Enter fullscreen mode

Exit fullscreen mode

Name getSidebarItems() to get navigation gadgets for the sidebar.

// /pages/docs/[...slug].tsx
sort Props = {
  doc: Docs;
  sidebarItems: NavItem[];
};

export default perform DocsPage({ doc, sidebarItems }: Props) {
  return <DocsTemplate doc={doc} sidebarItems={sidebarItems} />;
}

export async perform getStaticProps({ params }: GetStaticPropsContext) {
  //  {...}
  const doc = allDocs.discover((submit) => submit.slug === slug.be part of("/"));
  const sidebarItems = getSidebarItems();
  //  {...}
  const props: Props = {
    doc,
    sidebarItems,
  };
  //  {...}
}
Enter fullscreen mode

Exit fullscreen mode

It really works similar to anticipated!

Docs with nested sidebar



Nonetheless rather more to do…

We might construct a easy docs generator however there are such a lot of lacking issues:

  • Picture optimization by subsequent/picture
  • Desk of contents
  • Subsequent/earlier navigation
  • File title for code blocks
  • Anchor hyperlink for every heading
  • Utilizing React parts inside .mdx information
  • On cellular units, the sidebar must be hidden and expanded as a modal menu



Saazy Template helps all of the lacking options!

Logo of Saazy is shown over the grid of pages

Full featured docs of Saazy

I constructed Saazy Template, a Subsequent.js starter for advertising and marketing that features:

  • Touchdown web page
  • Pricing web page
  • Check in web page
  • And different 10+ pages
  • Docs/weblog
  • Built-in kinds
  • 16+ reusable parts

Visit the live preview or get it now

The Article was Inspired from tech community site.
Contact us if this is inspired from your article and we will give you credit for it for serving the community.

This Banner is For Sale !!
Get your ad here for a week in 20$ only and get upto 10k Tech related traffic daily !!!

Leave a Reply

Your email address will not be published. Required fields are marked *

Want to Contribute to us or want to have 15k+ Audience read your Article ? Or Just want to make a strong Backlink?