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-react
Copy 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 text-sm 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?