Make an Internal Docs System

Milecia

Many companies face the issue of how to maintain internal documentation that connects all of the departments or products. Notion or other SaaS tools are the common answer to try and address this problem, but they usually miss some of the things you might want to customize internally.

That's why we're going to make a Next.js app that will let us build our own internal docs system. We'll be able to upload screenshots or other images to Cloudinary and render Markdown on the page. You'll be able to expand this system to meet your own needs.

Setting up a few things

Before we jump into writing code, there are a few tools we need to have in place. First, we'll be using Cloudinary as the host for any images we need in our docs. So go make a free account here.

We'll be working with a PostgreSQL database to save the Markdown and any other data associated with a document. If you don't have a local instance, you can download it for free here. Make sure you note your username and password. You'll need that for the connection string to let your app interact with the database.

Generating the Next app

Now that we have the tools ready to use, let's create a new Next app by running the following command in your terminal.

1$ yarn create next-app --typescript

You will be prompted for a project name in the terminal. I've creatively called this app internal-docs, but feel free to name it anything. This will generate a fully functional project that we can modify.

There's one file we can go ahead and edit now. We know that we'll be working with Cloudinary for images and the Next app needs to know that it's ok to reference Cloudinary in the image sources. In the root of your project, open the next.config.js file and add the following code.

1// next.config.js
2...
3 reactStrictMode: true,
4 images: {
5 domains: ['res.cloudinary.com']
6 }
7...

Now let's go ahead and install the packages that we'll be using with the following command.

1$ yarn add autoprefixer postcss tailwindcss axios prisma @prisma/client @heroicons/react

We'll be using Tailwind CSS to handle our styles so there's a little bit of setup we need to do for this package.

Initializing Tailwind CSS

With the packages installed, we can run the following CLI command to intialize Tailwind in our project.

1$ npx tailwindcss init -p

This will generate a couple of files that let our project use Tailwind. We need to edit the tailwind.config.js file so that it applies the styles to the files in our project. Open this file and add the following code.

1// tailwind.config.js
2
3module.exports = {
4 content: [
5 "./pages/**/*.{js,ts,jsx,tsx}",
6 "./components/**/*.{js,ts,jsx,tsx}",
7 ],
8...

Now we need to update the stylesheet in the project to use the Tailwind directives. Go to the styles directory and open the global.css file. You can delete all of the code out of this file and add the following.

1@tailwind base;
2@tailwind components;
3@tailwind utilities;

These directives let us use all of the Tailwind functionality throughout the project because it's imported in the _app.tsx file, which is the top-level component in the project. That's all for our styles. Now we can set up the database schema.

Using Prisma for database operations

Most apps use some type of ORM that make it easier to interact with the database and we're going to use Prisma. This package was part of the ones we installed towards the beginning. Now we can initialize it in the project with this command:

1$ npx prisma init

This will generate a .env file at the root of your project and a new prisma directory that has a schema.prisma file. The schema file connects the whole app to Postgres and is also where we will define the database schema. Let's start by editing the .env file and update the connection string with your local Postgres username, password, and database name. So that file should look similar to this.

1# .env
2
3DATABASE_URL="postgresql://username:password@localhost:5432/product_inventory"

Now we can dig into the schema.prisma file and start defining the schema that we'll eventually migrate to the database. So open that file and add the following code.

1// schema.prisma
2
3generator client {
4 provider = "prisma-client-js"
5}
6
7datasource db {
8 provider = "postgresql"
9 url = env("DATABASE_URL")
10}
11
12model Document {
13 id String @id @default(uuid())
14 title String
15 content String
16}

The Document model includes all of the fields we need in the database. The content field is going to store all of our Markdown which will contain any images that are uploaded to Cloudinary. This is the only model we'll have to get this started, but feel free to add to this as you see fit.

Running the database migration

Now that the model is in place, we can run a migration to get this table schema into our database. In your terminal, run the following command.

1$ npx prisma migrate dev --name init

This will create a new database for you if you don't have it already and it will connect to the database and create a table called Document with the fields we defined. Now we can dive into the core functionality of the app and finally write some code.

Building CRUD functionality for documents

The base functionality we need for this system is the ability to look at all of the documents we have available, some way to add and edit docs, and a way to delete them. We'll start by creating the API routes that will handle all of this. Go to the pages > api directory and delete the placeholder hello.ts file and add a new file called new-doc.ts.

Creating a new document

This will be the route we call to create a new document. Open this new file and add the following code.

1// new-doc.ts
2
3import type { NextApiRequest, NextApiResponse } from "next";
4import { PrismaClient } from "@prisma/client";
5
6export type Document = {
7 title: string;
8 content: string;
9};
10
11const prisma = new PrismaClient();
12
13export default async function handler(
14 req: NextApiRequest,
15 res: NextApiResponse<Document>
16) {
17 const docData = req.body;
18 const newDoc = await prisma.document.create({
19 data: {
20 title: docData.title,
21 content: docData.content,
22 },
23 });
24
25 res.status(200).json(newDoc);
26
27 await prisma.$disconnect();
28}

This code allows us to connect to Postgres using Prisma and then it creates a new record for the document. It takes the body from the request and uses that data to define the document. If you were working on a production-level app, you'd need to include some validation around these user inputs to avoid potential security vulnerabilities.

Looking at existing documents

Let's add a new file called docs.ts in the pages > api directory. This is how we will fetch all of the existing documents or a specific document by its ID. Now open your docs.ts file and add the following code.

1// docs.ts
2
3import type { NextApiRequest, NextApiResponse } from "next";
4import { PrismaClient } from "@prisma/client";
5import { Document } from "./new-doc";
6
7const prisma = new PrismaClient();
8
9export default async function handler(
10 req: NextApiRequest,
11 res: NextApiResponse<Document | Document[] | null>
12) {
13 const { id } = req.query;
14
15 if (id) {
16 const document = await prisma.document.findUnique({
17 where: {
18 id: id as string,
19 },
20 });
21
22 res.status(200).json(document);
23
24 await prisma.$disconnect();
25 } else {
26 const documents = await prisma.document.findMany();
27
28 res.status(200).json(documents);
29
30 await prisma.$disconnect();
31 }
32}

This code checks if there is an id query in the request URL. If there is one, this will query the database for a specific document. Without the id query, we return all of the documents from the database. Another thing for production to note is that you still need validation for the id value and possibly some pagination for returning all the docs.

Editing documents

Sometimes internal processes will change or big features will need a lot of research before work starts, so being able to update docs with new information is crucial. In the pages > api directory, add a new file called edit-doc.ts and add the following code.

1// edit-doc.ts
2
3import type { NextApiRequest, NextApiResponse } from "next";
4import { PrismaClient } from "@prisma/client";
5import { Document } from "./new-doc";
6
7const prisma = new PrismaClient();
8
9export default async function handler(
10 req: NextApiRequest,
11 res: NextApiResponse<Document>
12) {
13 const docData = req.body;
14
15 const updatedDoc = await prisma.document.update({
16 where: { id: docData.id },
17 data: {
18 title: docData.title,
19 content: docData.content,
20 },
21 });
22
23 res.status(200).json(updatedDoc);
24
25 await prisma.$disconnect();
26}

Here, the document ID is sent as part of the request body and we use it along with the updated data to the database to change the record. More production notes, you'd want to record when these changes happened and who did them as well and of course, you can't forget the validation. You'd also want some error handling just in case you run into issues updating a document.

Deleting a document

This can be a fun part of documentation maintenance. When you can go through and delete documentation, it usually means that things are moving forward and changing. So let's add one more file to the pages > api called delete-doc.ts and add the following code to it.

1// delete-doc.ts
2
3import type { NextApiRequest, NextApiResponse } from "next";
4import { PrismaClient } from "@prisma/client";
5import { Document } from "./new-doc";
6
7const prisma = new PrismaClient();
8
9export default async function handler(
10 req: NextApiRequest,
11 res: NextApiResponse<Document>
12) {
13 const docId = req.body;
14
15 const deleteProduct = await prisma.document.delete({
16 where: { id: docId },
17 });
18
19 res.status(200).json(deleteProduct);
20
21 await prisma.$disconnect();
22}

This gets the document ID from the request body and runs the delete query in our database. An important thing to note is how we keep disconnecting from Prisma at the end of all of the responses. This makes sure we don't accidentally end up with 10 open Prisma clients.

We just finished making all of the back-end CRUD functionality for this app, so it's time to build out the front-end that employees can interact with.

Displaying the documents

All of the docs will be displayed in a table and employees will be able to click on buttons in the rows to view, edit, or update an individual document. There will also be a button to allow employees to add new docs. Let's start by building the functionality for that button.

The new document button

Go to the pages directory and open the index.tsx file. Delete everything inside the <main> element. The first element we're going to add is the button to create a new document. Update your file to the following code.

1// index.tsx
2
3import type { NextPage } from "next";
4import Head from "next/head";
5import { PlusCircleIcon } from "@heroicons/react/solid";
6import Link from "next/link";
7
8const Home: NextPage = () => {
9 return (
10 <div>
11 <Head>
12 <title>Internal Docs</title>
13 <meta name="description" content="This is our internal docs system" />
14 <link rel="icon" href="/favicon.ico" />
15 </Head>
16
17 <main>
18 <Link href="/doc" passHref>
19 <div className="flex gap-2 bg-gray-700 hover:bg-gray-300 text-white font-bold py-2 px-4 rounded-full m-6">
20 <PlusCircleIcon className="h-6 w-6 text-green-500" />
21 Add new document
22 </div>
23 </Link>
24 </main>
25 </div>
26 );
27};
28
29export default Home;

This button links to the new document page that will have the form you add your content to. It'll look like this in the browser if you run the app with yarn dev.

New document page

Since we have the button linking to a page called doc, let's add some new things to our project directory. Create a new folder in the pages directory called docs. Inside this folder, add a new file called index.tsx and add the following code.

1// docs > index.tsx
2
3import axios from "axios";
4import Link from "next/link";
5
6export default function NewDoc() {
7 const submitDoc = async (e) => {
8 e.preventDefault();
9
10 const document = {
11 title: e.target.title.value,
12 content: e.target.content.value,
13 };
14
15 await axios.post("/api/new-doc", document);
16 };
17
18 return (
19 <div className="mt-4 ml-4">
20 <h2 className="text-3xl mb-12">New Document</h2>
21 <form className="w-full mb-12" onSubmit={submitDoc}>
22 <div className="mb-6">
23 <label
24 className="block text-gray-500 font-bold mb-1 md:mb-0 pr-4"
25 htmlFor="title"
26 >
27 Title
28 </label>
29 <div className="md:w-2/3">
30 <input
31 className="bg-gray-200 appearance-none border-2 border-gray-200 rounded w-full py-2 px-4 text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-blue-500"
32 id="product-name"
33 name="title"
34 type="text"
35 />
36 </div>
37 </div>
38 <div className="mb-6">
39 <div>
40 <label
41 className="block text-gray-500 font-bold mb-1 md:mb-0 pr-4"
42 htmlFor="content"
43 >
44 Content
45 </label>
46 </div>
47 <div className="md:w-2/3">
48 <textarea
49 className="bg-gray-200 appearance-none border-2 border-gray-200 rounded block w-full py-2 px-4 text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-blue-500"
50 rows={50}
51 id="content"
52 name="content"
53 ></textarea>
54 </div>
55 </div>
56 <div className="md:flex md:items-center gap-6">
57 <div className="md:w-1/3"></div>
58 <div>
59 <div className="shadow bg-blue-400 hover:bg-blue-500 focus:shadow-outline focus:outline-none text-white font-bold py-2 px-4 rounded">
60 <Link href="/" passHref>
61 Back
62 </Link>
63 </div>
64 </div>
65 <div className="md:w-2/3">
66 <button
67 className="shadow bg-blue-400 hover:bg-blue-500 focus:shadow-outline focus:outline-none text-white font-bold py-2 px-4 rounded"
68 type="submit"
69 >
70 Save Document
71 </button>
72 </div>
73 </div>
74 </form>
75 </div>
76 );
77}

On this page, we created a form that takes the title and content values for the new document and it calls the new-doc endpoint when an employee submits the form and it goes back to the main page when they cancel. A few more notes on production improvements, it would be great to have some kind of message show when an employee submits the form and have form validation on this page. Here's what the new document page looks like.

Now that we've added a document, let's make the table for the main page to display all of the documents we have.

Displaying all the documents

To get all of the documents we have available, we're going to add some new functionality to the pages > index.tsx file. Open this file and update the existing code to match the following code.

1// pages > index.tsx
2
3import Head from "next/head";
4import {
5 PencilAltIcon,
6 PlusCircleIcon,
7 TrashIcon,
8} from "@heroicons/react/solid";
9import Link from "next/link";
10import { Document } from "./api/new-doc";
11import axios from "axios";
12
13const Home = ({ documents }: { documents: Document[] }) => {
14 return (
15 <div>
16 <Head>
17 <title>Internal Docs</title>
18 <meta name="description" content="This is our internal docs system" />
19 <link rel="icon" href="/favicon.ico" />
20 </Head>
21
22 <main>
23 <Link href="/doc" passHref>
24 <div className="flex gap-2 bg-gray-700 hover:bg-gray-300 text-white font-bold py-2 px-4 rounded-full m-6">
25 <PlusCircleIcon className="h-6 w-6 text-green-500" />
26 Add new document
27 </div>
28 </Link>
29 <div className="flex flex-col gap-2">
30 {documents.length > 0 ? (
31 documents.map((doc: Document) => (
32 <div key={doc.id} className="flex gap-6 border-b-2 w-full p-4">
33 <div>{doc.title}</div>
34 <Link passHref href={`/doc/${doc.id}`}>
35 <PencilAltIcon className="h-6 w-6 text-teal-500" />
36 </Link>
37 <TrashIcon className="h-6 w-6 text-rose-400" />
38 </div>
39 ))
40 ) : (
41 <div>Add some new products</div>
42 )}
43 </div>
44 </main>
45 </div>
46 );
47};
48
49export async function getServerSideProps() {
50 const docsRes = await axios.get("http://localhost:3000/api/docs");
51
52 const documents = await docsRes.data;
53
54 return {
55 props: {
56 documents,
57 },
58 };
59}
60
61export default Home;

We've added a few more imports to get new icons and the axios package. There's also a new call to the getServerSideProps method and that's where we call the back-end to get all of the documents we have in our database table. It returns the documents array as a prop to the Home component.

After the new document button, we check the length of the array and show a list of documents or a message inviting users to make a new document. This is what the app will look like with the document we added earlier.

Now we can add the deletion functionality since we have that button on the document record and it'll be fast to implement.

Deleting a document

We need to add a function that calls the back-end with the correct document ID to delete. Add the deleteDoc function at the beginning of the Home component like below.

1// pages > index.tsx
2
3...
4const Home = ({ documents }: { documents: Document[] }) => {
5 const deleteDoc = async (docId: string) => {
6 await axios.delete("/api/delete-doc", { data: docId });
7 };
8
9 return (
10 <div>
11 <Head>
12 <title>Internal Docs</title>
13 <meta name="description" content="This is our internal docs system" />
14 <link rel="icon" href="/favicon.ico" />
15 </Head>
16...

Then we'll call this function when someone clicks the trash icon on the row with the following code change.

1// pages > index.tsx
2
3...
4<Link passHref href={`/doc/${doc.id}`}>
5 <PencilAltIcon className="h-6 w-6 text-teal-500" />
6</Link>
7<TrashIcon
8 className="h-6 w-6 text-rose-400"
9 onClick={() => deleteDoc(doc.id)}
10/>
11...

That's all we needed for the delete functionality! Let's finish up with the edit functionality.

Edit document page

We'll need to add a new page to the pages > doc directory called [id].tsx. This page will show a form with the current document title and content and let employees edit the fields and save the changes. Add the following code to this new file.

1// pages > doc > [id].tsx
2
3import axios from "axios";
4import Link from "next/link";
5import { Document } from "../api/new-doc";
6
7export default function EditDoc({ doc }: { doc: Document }) {
8 const submitDoc = async (e) => {
9 e.preventDefault();
10
11 const modifiedDoc = {
12 id: doc.id,
13 title: e.target.title.value,
14 content: e.target.content.value,
15 };
16
17 await axios.patch("/api/edit-doc", modifiedDoc);
18 };
19
20 return (
21 <div className="mt-4 ml-4">
22 <h2 className="text-3xl mb-12">Edit {doc?.title || "Document"}</h2>
23 <form className="w-full mb-12" onSubmit={submitDoc}>
24 <div className="mb-6">
25 <label
26 className="block text-gray-500 font-bold mb-1 md:mb-0 pr-4"
27 htmlFor="title"
28 >
29 Title
30 </label>
31 <div className="md:w-2/3">
32 <input
33 className="bg-gray-200 appearance-none border-2 border-gray-200 rounded w-full py-2 px-4 text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-blue-500"
34 id="product-name"
35 name="title"
36 type="text"
37 defaultValue={doc ? doc.title : ""}
38 />
39 </div>
40 </div>
41 <div className="mb-6">
42 <div>
43 <label
44 className="block text-gray-500 font-bold mb-1 md:mb-0 pr-4"
45 htmlFor="content"
46 >
47 Content
48 </label>
49 </div>
50 <div className="md:w-2/3">
51 <textarea
52 className="bg-gray-200 appearance-none border-2 border-gray-200 rounded block w-full py-2 px-4 text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-blue-500"
53 rows={50}
54 id="content"
55 name="content"
56 defaultValue={doc ? doc.content : ""}
57 ></textarea>
58 </div>
59 </div>
60 <div className="md:flex md:items-center gap-6">
61 <div className="md:w-1/3"></div>
62 <div>
63 <div className="shadow bg-blue-400 hover:bg-blue-500 focus:shadow-outline focus:outline-none text-white font-bold py-2 px-4 rounded">
64 <Link href="/" passHref>
65 Back
66 </Link>
67 </div>
68 </div>
69 <div className="md:w-2/3">
70 <button
71 className="shadow bg-blue-400 hover:bg-blue-500 focus:shadow-outline focus:outline-none text-white font-bold py-2 px-4 rounded"
72 type="submit"
73 >
74 Save Document
75 </button>
76 </div>
77 </div>
78 </form>
79 </div>
80 );
81}
82
83export async function getServerSideProps(context) {
84 const docId = context.params.id;
85
86 const docRes = await axios.get("http://localhost:3000/api/docs", {
87 params: {
88 id: docId,
89 },
90 });
91
92 const doc = await docRes.data;
93
94 return {
95 props: {
96 doc,
97 },
98 };
99}

This page starts off by calling the getServerSideProps method to fetch the data for the selected document based on the document ID passed in the URL and returns this as a prop to the EditDoc component.

Then we render a form that uses the document data as the default values for the fields so that an employee can start making direct edits. There's also a function that will save any changes to the fields via a call to the back-end once the form is submitted.

Just to keep up with the other sections, there are a few things you can do to improve this for production. Having some kind of authorization around who can make edits to certain documents could be useful. Adding a way to handle real-time team editing or locking a document until someone is finished editing can help prevent conflicts. Of course, form validation will always help with some security vulnerabilities.

That's all! You now have an internal docs system template that you can expand in a number of directions.

Finished code

You can check out the complete code in the internal-docs folder of this repo. You can also check it out in this Code Sandbox.

Conclusion

There are a lot of different needs that your company or team might need for documentation and this gives you a simple starting point. You can make this as complex as you want or you can keep it as simple as you like. Either way, it'll be a fun way to start looking at documentation management.

Milecia

Software Team Lead

Milecia is a senior software engineer, international tech speaker, and mad scientist that works with hardware and software. She will try to make anything with JavaScript first.