Skip to content

Community FAQ

Turn your Discord help forum into a beautiful, searchable FAQ website. Community questions become documentation.

Overview

Your Discord community is already asking and answering questions. This guide shows you how to:

  • Display resolved threads as FAQ entries
  • Add search functionality for finding answers
  • Show status badges (open, resolved, locked)
  • Credit helpful community members

Architecture

Discord Help Forum
Discord Forum API
├── GET /threads?status=resolved → FAQ entries
├── GET /threads/:id → Full Q&A thread
└── GET /search?q=... → Search results
Your FAQ Website

Implementation

1. Fetch FAQ Entries

Get resolved threads to display as FAQ entries:

lib/api.js
const API_BASE = process.env.API_URL || 'http://localhost:3000/api';
export async function getFAQEntries({ limit = 20, cursor } = {}) {
const params = new URLSearchParams({
serverId: process.env.DISCORD_SERVER_ID,
status: 'resolved',
sort: 'popular',
limit: limit.toString(),
});
if (cursor) params.set('cursor', cursor);
const response = await fetch(`${API_BASE}/threads?${params}`);
return response.json();
}
export async function getFAQEntry(threadId) {
const response = await fetch(`${API_BASE}/threads/${threadId}`);
return response.json();
}
export async function searchFAQ(query) {
const params = new URLSearchParams({
q: query,
serverId: process.env.DISCORD_SERVER_ID,
type: 'threads',
limit: '20',
});
const response = await fetch(`${API_BASE}/search?${params}`);
return response.json();
}

2. FAQ List Page

Display FAQ entries with search:

// pages/faq/index.jsx (Next.js example)
import { useState } from 'react';
import { getFAQEntries, searchFAQ } from '@/lib/api';
export async function getStaticProps() {
const { threads } = await getFAQEntries({ limit: 50 });
return {
props: { initialEntries: threads },
revalidate: 3600, // Rebuild every hour
};
}
export default function FAQPage({ initialEntries }) {
const [entries, setEntries] = useState(initialEntries);
const [query, setQuery] = useState('');
const [searching, setSearching] = useState(false);
async function handleSearch(e) {
e.preventDefault();
if (!query.trim()) {
setEntries(initialEntries);
return;
}
setSearching(true);
const results = await searchFAQ(query);
setEntries(results.results.threads);
setSearching(false);
}
return (
<main className="faq-page">
<header>
<h1>Frequently Asked Questions</h1>
<p>Answers from our community</p>
</header>
<form onSubmit={handleSearch} className="search-form">
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search for answers..."
/>
<button type="submit" disabled={searching}>
{searching ? 'Searching...' : 'Search'}
</button>
</form>
<div className="faq-list">
{entries.map((entry) => (
<FAQCard key={entry.id} entry={entry} />
))}
</div>
{entries.length === 0 && (
<p className="no-results">
No results found. Try a different search term.
</p>
)}
</main>
);
}
function FAQCard({ entry }) {
return (
<article className="faq-card">
<a href={`/faq/${entry.slug}`}>
<h2>{entry.title}</h2>
<p>{entry.preview}</p>
<footer>
<span className="author">Asked by {entry.author.username}</span>
<span className="replies">{entry.messageCount - 1} answers</span>
<span className="date">{formatDate(entry.createdAt)}</span>
</footer>
</a>
</article>
);
}

3. FAQ Detail Page

Show the full thread with all answers:

pages/faq/[slug].jsx
import { getFAQEntry, getFAQEntries } from '@/lib/api';
export async function getStaticPaths() {
const { threads } = await getFAQEntries({ limit: 100 });
return {
paths: threads.map((t) => ({ params: { slug: t.slug } })),
fallback: 'blocking',
};
}
export async function getStaticProps({ params }) {
// Note: You'll need an endpoint or lookup to get thread ID from slug
const thread = await getFAQEntry(params.slug);
if (!thread) {
return { notFound: true };
}
return {
props: { thread },
revalidate: 3600,
};
}
export default function FAQEntryPage({ thread }) {
const [question, ...answers] = thread.messages;
return (
<main className="faq-entry">
<header>
<h1>{thread.title}</h1>
<div className="tags">
{thread.tags.map((tag) => (
<span key={tag} className="tag">{tag}</span>
))}
</div>
</header>
<section className="question">
<h2>Question</h2>
<Message message={question} />
</section>
<section className="answers">
<h2>{answers.length} Answer{answers.length !== 1 ? 's' : ''}</h2>
{answers.map((answer) => (
<Message key={answer.id} message={answer} />
))}
</section>
<footer>
<a href="/faq">← Back to FAQ</a>
<a
href={`https://discord.com/channels/${thread.serverId}/${thread.id}`}
target="_blank"
rel="noopener"
>
View on Discord →
</a>
</footer>
</main>
);
}
function Message({ message }) {
return (
<div className="message">
<div className="author">
<img
src={getAvatarUrl(message.author)}
alt=""
className="avatar"
/>
<span className="username">{message.author.username}</span>
<time>{formatDate(message.createdAt)}</time>
</div>
<div
className="content"
dangerouslySetInnerHTML={{ __html: message.contentHtml }}
/>
{message.reactions.length > 0 && (
<div className="reactions">
{message.reactions.map((r) => (
<span key={r.emoji} className="reaction">
{getEmoji(r.emoji)} {r.count}
</span>
))}
</div>
)}
</div>
);
}

4. Category Filtering

Group FAQs by forum tags:

function FAQCategories({ entries, tags }) {
const [activeTag, setActiveTag] = useState(null);
const filtered = activeTag
? entries.filter((e) => e.tags.includes(activeTag))
: entries;
return (
<>
<div className="category-filter">
<button
className={!activeTag ? 'active' : ''}
onClick={() => setActiveTag(null)}
>
All
</button>
{tags.map((tag) => (
<button
key={tag.id}
className={activeTag === tag.name ? 'active' : ''}
onClick={() => setActiveTag(tag.name)}
>
{tag.name}
</button>
))}
</div>
<div className="faq-list">
{filtered.map((entry) => (
<FAQCard key={entry.id} entry={entry} />
))}
</div>
</>
);
}

Styling

/* FAQ List */
.faq-page {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.search-form {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
}
.search-form input {
flex: 1;
padding: 0.75rem 1rem;
font-size: 1rem;
border: 2px solid #e0e0e0;
border-radius: 8px;
}
.search-form input:focus {
outline: none;
border-color: #5865f2;
}
.search-form button {
padding: 0.75rem 1.5rem;
background: #5865f2;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
}
.faq-card {
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1rem;
transition: box-shadow 0.2s;
}
.faq-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.faq-card h2 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
}
.faq-card p {
color: #666;
margin: 0 0 1rem;
}
.faq-card footer {
display: flex;
gap: 1rem;
font-size: 0.875rem;
color: #888;
}
/* FAQ Entry */
.faq-entry .question {
background: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.message {
border-bottom: 1px solid #e0e0e0;
padding: 1.5rem 0;
}
.message:last-child {
border-bottom: none;
}
.message .author {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.message .avatar {
width: 32px;
height: 32px;
border-radius: 50%;
}
.message .username {
font-weight: 600;
}
.message time {
color: #888;
font-size: 0.875rem;
}
.message .content {
line-height: 1.6;
}
.message .content code {
background: #f4f4f4;
padding: 0.2em 0.4em;
border-radius: 3px;
}
.message .content pre {
background: #2d2d2d;
color: #f8f8f2;
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
}
/* Category Filter */
.category-filter {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.category-filter button {
padding: 0.5rem 1rem;
border: 1px solid #e0e0e0;
border-radius: 20px;
background: white;
cursor: pointer;
}
.category-filter button.active {
background: #5865f2;
color: white;
border-color: #5865f2;
}

SEO Optimization

Meta Tags

import Head from 'next/head';
function FAQEntryPage({ thread }) {
return (
<>
<Head>
<title>{thread.title} - FAQ</title>
<meta name="description" content={thread.preview} />
<meta property="og:title" content={thread.title} />
<meta property="og:description" content={thread.preview} />
<meta property="og:type" content="article" />
</Head>
{/* ... */}
</>
);
}

Structured Data

function FAQStructuredData({ entries }) {
const structuredData = {
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": entries.map((entry) => ({
"@type": "Question",
"name": entry.title,
"acceptedAnswer": {
"@type": "Answer",
"text": entry.preview,
},
})),
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
);
}

Deployment

Environment Variables

API_URL=https://your-api.example.com/api
DISCORD_SERVER_ID=123456789

Build Command

Terminal window
npm run build

Revalidation

Configure incremental static regeneration to keep content fresh:

pages/faq/index.jsx
export async function getStaticProps() {
const { threads } = await getFAQEntries();
return {
props: { initialEntries: threads },
revalidate: 3600, // Rebuild every hour
};
}

Enhancements

Upvote System

Track which answers are most helpful:

function AnswerWithVotes({ answer }) {
const votes = answer.reactions.find(r => r.emoji === 'thumbsup')?.count || 0;
return (
<div className="answer">
<div className="vote-count">{votes}</div>
<Message message={answer} />
</div>
);
}

Show similar FAQs:

async function getRelatedFAQs(threadId, tags) {
// Fetch threads with same tags
const { threads } = await getFAQEntries({ tag: tags[0], limit: 5 });
return threads.filter(t => t.id !== threadId);
}

AI Summary

Use an AI to summarize long threads:

// This requires an additional AI service
async function generateSummary(messages) {
const response = await fetch('/api/summarize', {
method: 'POST',
body: JSON.stringify({ messages }),
});
return response.json();
}