Integrations
Feedback
Receive feedback from your users
Overview
Feedback is crucial for knowing what your reader thinks, and help you to further improve documentation content.
Installation
Add dependencies:
npm install class-variance-authority lucide-reactCopy the component:
'use client';
import { cn } from '@/lib/cn';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { ThumbsDown, ThumbsUp } from 'lucide-react';
import { type SyntheticEvent, useEffect, useState } from 'react';
import {
Collapsible,
CollapsibleContent,
} from 'fumadocs-ui/components/ui/collapsible';
import { cva } from 'class-variance-authority';
import { usePathname } from 'next/navigation';
const rateButtonVariants = cva(
'inline-flex items-center gap-2 px-3 py-2 rounded-full font-medium border text-sm [&_svg]:size-4 disabled:cursor-not-allowed',
{
variants: {
active: {
true: 'bg-fd-accent text-fd-accent-foreground [&_svg]:fill-current',
false: 'text-fd-muted-foreground',
},
},
},
);
export interface Feedback {
opinion: 'good' | 'bad';
message: string;
}
function get(url: string): Feedback | null {
const item = localStorage.getItem(`docs-feedback-${url}`);
if (item === null) return null;
return JSON.parse(item) as Feedback;
}
function set(url: string, feedback: Feedback | null) {
const key = `docs-feedback-${url}`;
if (feedback) localStorage.setItem(key, JSON.stringify(feedback));
else localStorage.removeItem(key);
}
export function Rate({
onRateAction,
}: {
onRateAction: (url: string, feedback: Feedback) => Promise<void>;
}) {
const url = usePathname();
const [previous, setPrevious] = useState<Feedback | null>(null);
const [opinion, setOpinion] = useState<'good' | 'bad' | null>(null);
const [message, setMessage] = useState('');
useEffect(() => {
setPrevious(get(url));
}, [url]);
function submit(e?: SyntheticEvent) {
e?.preventDefault();
if (opinion == null) return;
const feedback: Feedback = {
opinion,
message,
};
void onRateAction(url, feedback);
set(url, feedback);
setPrevious(feedback);
setMessage('');
setOpinion(null);
}
return (
<Collapsible
open={opinion !== null || previous !== null}
onOpenChange={(v) => {
if (!v) setOpinion(null);
}}
className="border-y py-3"
>
<div className="flex flex-row items-center gap-2">
<p className="text-sm font-medium pe-2">How is this guide?</p>
<button
disabled={previous !== null}
className={cn(
rateButtonVariants({
active: (previous?.opinion ?? opinion) === 'good',
}),
)}
onClick={() => {
setOpinion('good');
}}
>
<ThumbsUp />
Good
</button>
<button
disabled={previous !== null}
className={cn(
rateButtonVariants({
active: (previous?.opinion ?? opinion) === 'bad',
}),
)}
onClick={() => {
setOpinion('bad');
}}
>
<ThumbsDown />
Bad
</button>
</div>
<CollapsibleContent className="mt-3">
{previous ? (
<div className="px-3 py-6 flex flex-col items-center gap-3 bg-fd-card text-fd-card-foreground text-sm text-center rounded-xl text-fd-muted-foreground">
<p>Thank you for your feedback!</p>
<button
className={cn(
buttonVariants({
color: 'secondary',
}),
'text-xs',
)}
onClick={() => {
setOpinion(previous?.opinion);
set(url, null);
setPrevious(null);
}}
>
Submit Again?
</button>
</div>
) : (
<form className="flex flex-col gap-3" onSubmit={submit}>
<textarea
autoFocus
value={message}
onChange={(e) => setMessage(e.target.value)}
className="border rounded-lg bg-fd-secondary text-fd-secondary-foreground p-3 resize-none focus-visible:outline-none placeholder:text-fd-muted-foreground"
placeholder="Leave your feedback..."
onKeyDown={(e) => {
if (!e.shiftKey && e.key === 'Enter') {
submit(e);
}
}}
/>
<button
type="submit"
className={cn(buttonVariants({ color: 'outline' }), 'w-fit px-3')}
>
Submit
</button>
</form>
)}
</CollapsibleContent>
</Collapsible>
);
}The @/lib/cn import specifier may be different for your project, change it to import your cn() function if needed. (e.g. like @/lib/utils)
How to Use
Now add the <Rate /> component to your docs page:
import defaultMdxComponents from 'fumadocs-ui/mdx';
import {
DocsPage,
DocsTitle,
DocsDescription,
DocsBody,
} from 'fumadocs-ui/page';
import { Rate } from '@/components/rate';
import posthog from 'posthog-js';
export default async function Page() {
// other logic
return (
<DocsPage toc={toc} full={page.data.full}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
<Mdx components={{ ...defaultMdxComponents }} />
</DocsBody>
<Rate
onRateAction={async (url, feedback) => {
'use server';
await posthog.capture('on_rate_docs', feedback);
}}
/>
</DocsPage>
);
}On above example, it reports user feedback by capturing a on_rate_docs event on PostHog.
You can specify your own server action to onRateAction, and report the feedback to different destinations like database, or GitHub Discussions via their API.
Linking to GitHub Discussion
To report your feedback to GitHub Discussion, make a custom onRateAction.
You can copy this example as a starting point:
import { App } from 'octokit';
import type { Feedback } from '@/components/rate';
export const repo = 'fumadocs';
export const owner = 'fuma-nama';
export const DocsCategory = 'Docs Feedback';
const octokit = await getOctokit();
const destination = await getFeedbackDestination();
async function getOctokit() {
const appId = process.env.GITHUB_APP_ID;
const privateKey = process.env.GITHUB_APP_PRIVATE_KEY;
if (!appId || !privateKey) {
console.warn(
'No GitHub keys provided for Github app, docs feedback feature will not work.',
);
return;
}
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',
},
},
);
return app.getInstallationOctokit(data.id);
}
async function getFeedbackDestination() {
if (!octokit) return;
const {
repository,
}: {
repository: {
id: string;
discussionCategories: {
nodes: {
id: string;
name: string;
}[];
};
};
} = await octokit.graphql(`
query {
repository(owner: "${owner}", name: "${repo}") {
id
discussionCategories(first: 25) {
nodes { id name }
}
}
}
`);
return repository;
}
export async function onRateAction(url: string, feedback: Feedback) {
'use server';
if (!octokit || !destination) return;
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 ${url}`;
const body = `[${feedback.opinion}] ${feedback.message}\n\n> Forwarded from user feedback.`;
const {
search: { nodes: discussions },
}: {
search: {
nodes: { id: string }[];
};
} = await octokit.graphql(`
query {
search(type: DISCUSSION, query: ${JSON.stringify(`${title} in:title repo:fuma-nama/fumadocs author:@me`)}, first: 1) {
nodes {
... on Discussion { id }
}
}
}`);
if (discussions.length > 0) {
await octokit.graphql(`
mutation {
addDiscussionComment(input: { body: ${JSON.stringify(body)}, discussionId: "${discussions[0].id}" }) {
comment { id }
}
}`);
} else {
await octokit.graphql(`
mutation {
createDiscussion(input: { repositoryId: "${destination.id}", categoryId: "${category!.id}", body: ${JSON.stringify(body)}, title: ${JSON.stringify(title)} }) {
discussion { id }
}
}`);
}
}- Create your own GitHub App and obtain its app ID and private key.
- Fill required environment variables.
- Replace constants like
owner,repo, andDocsCategory.
How is this guide?
