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 WebsiteImplementation
1. Fetch FAQ Entries
Get resolved threads to display as FAQ entries:
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:
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/apiDISCORD_SERVER_ID=123456789Build Command
npm run buildRevalidation
Configure incremental static regeneration to keep content fresh:
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> );}Related Questions
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 serviceasync function generateSummary(messages) { const response = await fetch('/api/summarize', { method: 'POST', body: JSON.stringify({ messages }), }); return response.json();}