Skip to Content
Fumadocs

Feedback

Receive feedback from your users

Overview

Feedback tells you what readers think of your docs and which pages need improvement. Fumadocs provides components to collect feedback on pages and on blocks of content.

Installation

Install it with Fumadocs CLI. It also adds the component to your docs page when it finds one:

npx @fumadocs/cli feature feedback

Or install the component only:

npx @fumadocs/cli@latest add feedback

Page Feedback

Add <Feedback /> to the bottom of your docs page. The onSendAction callback runs when a reader submits feedback.

Send the feedback to an endpoint in your app:

import { DocsPage } from 'fumadocs-ui/layouts/docs/page';
import { Feedback } from '@/components/feedback/client';

function Content() {
  return (
    <DocsPage>
      {/* at the bottom of page */}
      <Feedback
        onSendAction={async (feedback) => {
          const res = await fetch('/api/feedback', {
            method: 'POST',
            body: JSON.stringify(feedback),
          });

          return res.json();
        }}
      />
    </DocsPage>
  );
}

Handle the feedback in an endpoint. For example, report it as an on_rate_docs event to PostHog. The endpoint renders on request, so your project needs an adapter.

src/pages/api/feedback.ts
import type { APIRoute } from 'astro';
import { pageFeedback } from '@/components/feedback/schema';

export const prerender = false;

export const POST: APIRoute = async ({ request }) => {
  const feedback = pageFeedback.parse(await request.json());

  console.log(feedback);
  return Response.json({});
};

Feedback Block

Block feedback lets readers comment on a specific block of content through a popover that opens when they select text.

Add remarkBlockId, a remark plugin that gives each block an ID:

astro.config.mjs
import { defineConfig } from 'astro/config';
import fumadocs from '@fumadocs/astro';
import { remarkBlockId } from '@fumadocs/satteri/remark-block-id';

export default defineConfig({
  integrations: [
    fumadocs({
      mdastPlugins: [remarkBlockId({ addDataAttribute: 'feedback' })],
    }),
  ],
});

Then wrap your page content in <FeedbackText />. The onSendAction callback runs when a reader submits feedback on a block.

import { DocsPage } from 'fumadocs-ui/layouts/docs/page';
import { FeedbackText } from '@/components/feedback/client';

function Content() {
  return (
    <DocsPage>
      <FeedbackText
        onSendAction={async (feedback) => {
          const res = await fetch('/api/feedback/block', {
            method: 'POST',
            body: JSON.stringify(feedback),
          });

          return res.json();
        }}
      >
        {/* the content of page */}
      </FeedbackText>
    </DocsPage>
  );
}

Handle it in another endpoint like the one of page feedback, and parse the request body with blockFeedback instead.

Good to know

remark-block-id generates a block ID from its content and order in the page, so 3rd party services can track the blocks too.

Integrating with GitHub Discussion

To report your feedback to GitHub Discussion, you can copy this file as a starting point:

lib/github.ts
import { App, Octokit } from 'octokit';
import {
  blockFeedback,
  BlockFeedback,
  pageFeedback,
  type ActionResponse,
  type PageFeedback,
} from '@/components/feedback/schema';

export const repo = 'fumadocs';
export const owner = 'fuma-nama';
export const DocsCategory = 'Docs Feedback';

let instance: Octokit | undefined;

async function getOctokit(): Promise<Octokit> {
  if (instance) return instance;
  const appId = process.env.GITHUB_APP_ID;
  const privateKey = process.env.GITHUB_APP_PRIVATE_KEY;

  if (!appId || !privateKey) {
    throw new Error('No GitHub keys provided for Github app, docs feedback feature will not work.');
  }

  const app = new App({
    appId,
    privateKey,
  });

  const { data } = await app.octokit.request('GET /repos/{owner}/{repo}/installation', {
    owner,
    repo,
    headers: {
      'X-GitHub-Api-Version': '2022-11-28',
    },
  });

  instance = await app.getInstallationOctokit(data.id);
  return instance;
}

interface RepositoryInfo {
  id: string;
  discussionCategories: {
    nodes: {
      id: string;
      name: string;
    }[];
  };
}

let cachedDestination: RepositoryInfo | undefined;
async function getFeedbackDestination() {
  if (cachedDestination) return cachedDestination;
  const octokit = await getOctokit();

  const {
    repository,
  }: {
    repository: RepositoryInfo;
  } = await octokit.graphql(`
  query {
    repository(owner: "${owner}", name: "${repo}") {
      id
      discussionCategories(first: 25) {
        nodes { id name }
      }
    }
  }
`);

  return (cachedDestination = repository);
}

export async function onPageFeedbackAction(feedback: PageFeedback): Promise<ActionResponse> {
  'use server';
  feedback = pageFeedback.parse(feedback);
  const url = new URL(feedback.url);

  return createDiscussionThread(
    url.pathname,
    `[${feedback.opinion}] ${feedback.message}\n\n> Forwarded from user feedback.`,
  );
}

export async function onBlockFeedbackAction(feedback: BlockFeedback): Promise<ActionResponse> {
  'use server';
  feedback = blockFeedback.parse(feedback);
  const url = new URL(feedback.url);
  url.hash = feedback.blockId;

  return createDiscussionThread(
    url.pathname,
    `> ${feedback.blockBody}\n\n${feedback.message}\n\n> [Forwarded from user feedback](${url.href}).`,
  );
}

async function createDiscussionThread(pageId: string, body: string) {
  const octokit = await getOctokit();
  const destination = await getFeedbackDestination();
  const category = destination.discussionCategories.nodes.find(
    (category) => category.name === DocsCategory,
  );

  if (!category) throw new Error(`Please create a "${DocsCategory}" category in GitHub Discussion`);

  const title = `Feedback for ${pageId}`;
  const queryResult: {
    search: {
      nodes: { id: string; title: string; url: string }[];
    };
  } = await octokit.graphql(`
          query {
            search(type: DISCUSSION, query: ${JSON.stringify(`"${title}" in:title repo:${owner}/${repo} author:@me`)}, first: 10) {
              nodes {
                ... on Discussion { id, title, url }
              }
            }
          }`);

  const discussion = queryResult.search.nodes.find((item) => item.title === title);

  if (discussion) {
    const result: {
      addDiscussionComment: {
        comment: { id: string; url: string };
      };
    } = await octokit.graphql(`
            mutation {
              addDiscussionComment(input: { body: ${JSON.stringify(body)}, discussionId: "${discussion.id}" }) {
                comment { id, url }
              }
            }`);

    return {
      githubUrl: result.addDiscussionComment.comment.url,
    };
  } else {
    const result: {
      createDiscussion: {
        discussion: { id: string; url: string };
      };
    } = await octokit.graphql(`
            mutation {
              createDiscussion(input: { repositoryId: "${destination.id}", categoryId: "${category.id}", body: ${JSON.stringify(body)}, title: ${JSON.stringify(title)} }) {
                discussion { id, url }
              }
            }`);

    return {
      githubUrl: result.createDiscussion.discussion.url,
    };
  }
}
  1. Create your own GitHub App and obtain its app ID and private key.
  2. Fill required environment variables.
  3. Replace constants like owner, repo, and DocsCategory.
  4. Remove the 'use server' directives, and call onPageFeedbackAction and onBlockFeedbackAction from your endpoints.

How is this guide?

Last updated on