Knowledge Base
Transform your Discord forum into a structured knowledge base with categories, search, and cross-linking.
Overview
A knowledge base differs from an FAQ by providing:
- Hierarchical organization (categories → subcategories → articles)
- Internal linking between related articles
- Versioning for documentation updates
- Comprehensive search across all content
Architecture
Discord Forum Channels├── getting-started/├── tutorials/├── api-reference/└── troubleshooting/ │ ▼ Discord Forum API │ ├── GET /servers/:id/channels → Categories ├── GET /servers/:id/tags → Subcategories ├── GET /threads → Articles └── GET /search → Full-text search │ ▼ Knowledge Base WebsiteDiscord Structure
Organize your Discord server to mirror your knowledge base:
Channel = Category
📁 DOCUMENTATION├── #getting-started (forum)├── #tutorials (forum)├── #api-reference (forum)└── #troubleshooting (forum)Tags = Subcategories
Each forum channel has tags for subcategories:
#getting-started├── Tag: Installation├── Tag: Configuration├── Tag: Quick Start└── Tag: UpgradingThreads = Articles
Each thread is a documentation article:
Thread: "Installing on Windows"├── First message: Article content└── Replies: Community additions, updatesImplementation
1. Fetch Knowledge Base Structure
const API_BASE = process.env.API_URL;const SERVER_ID = process.env.DISCORD_SERVER_ID;
export async function getCategories() { const response = await fetch(`${API_BASE}/servers/${SERVER_ID}/channels`); const { channels } = await response.json();
// Filter to documentation channels only const docChannelIds = process.env.DOC_CHANNEL_IDS.split(','); return channels.filter((c) => docChannelIds.includes(c.id));}
export async function getSubcategories(channelId) { const response = await fetch(`${API_BASE}/servers/${SERVER_ID}/tags`); const { tags } = await response.json(); return tags.filter((t) => t.channelId === channelId);}
export async function getArticles(channelId, tag = null) { const params = new URLSearchParams({ channelId, sort: 'popular', limit: '100', });
if (tag) params.set('tag', tag);
const response = await fetch(`${API_BASE}/threads?${params}`); return response.json();}
export async function getArticle(articleId) { const response = await fetch(`${API_BASE}/threads/${articleId}`); return response.json();}
export async function searchKB(query) { const params = new URLSearchParams({ q: query, serverId: SERVER_ID, limit: '20', });
const response = await fetch(`${API_BASE}/search?${params}`); return response.json();}2. Knowledge Base Homepage
import { getCategories, getSubcategories, getArticles } from '@/lib/kb';
export async function getStaticProps() { const categories = await getCategories();
// Fetch subcategories and article counts for each category const categoriesWithData = await Promise.all( categories.map(async (category) => { const subcategories = await getSubcategories(category.id); const { threads } = await getArticles(category.id);
return { ...category, subcategories, articleCount: threads.length, }; }) );
return { props: { categories: categoriesWithData }, revalidate: 3600, };}
export default function KnowledgeBasePage({ categories }) { return ( <main className="kb-home"> <header> <h1>Documentation</h1> <p>Everything you need to know</p> <SearchBox /> </header>
<div className="category-grid"> {categories.map((category) => ( <CategoryCard key={category.id} category={category} /> ))} </div> </main> );}
function CategoryCard({ category }) { const icons = { 'getting-started': '🚀', 'tutorials': '📖', 'api-reference': '📚', 'troubleshooting': '🔧', };
return ( <article className="category-card"> <span className="icon">{icons[category.name] || '📄'}</span> <h2> <a href={`/docs/${category.name}`}>{formatTitle(category.name)}</a> </h2> <p>{category.topic}</p> <div className="subcategories"> {category.subcategories.slice(0, 4).map((sub) => ( <a key={sub.id} href={`/docs/${category.name}/${sub.name}`}> {sub.name} </a> ))} </div> <span className="article-count">{category.articleCount} articles</span> </article> );}3. Category Page
import { getCategories, getSubcategories, getArticles } from '@/lib/kb';
export async function getStaticPaths() { const categories = await getCategories(); return { paths: categories.map((c) => ({ params: { category: c.name } })), fallback: 'blocking', };}
export async function getStaticProps({ params }) { const categories = await getCategories(); const category = categories.find((c) => c.name === params.category);
if (!category) { return { notFound: true }; }
const subcategories = await getSubcategories(category.id); const { threads } = await getArticles(category.id);
// Group articles by subcategory const articlesBySubcategory = subcategories.map((sub) => ({ ...sub, articles: threads.filter((t) => t.tags.includes(sub.name)), }));
// Articles without a subcategory const uncategorized = threads.filter( (t) => !subcategories.some((s) => t.tags.includes(s.name)) );
return { props: { category, subcategories: articlesBySubcategory, uncategorized, }, revalidate: 3600, };}
export default function CategoryPage({ category, subcategories, uncategorized }) { return ( <main className="category-page"> <Breadcrumb items={[{ label: 'Docs', href: '/docs' }, category.name]} />
<header> <h1>{formatTitle(category.name)}</h1> <p>{category.topic}</p> </header>
<div className="category-content"> <aside className="sidebar"> <nav> {subcategories.map((sub) => ( <div key={sub.id} className="nav-section"> <h3>{sub.name}</h3> <ul> {sub.articles.map((article) => ( <li key={article.id}> <a href={`/docs/${category.name}/${article.slug}`}> {article.title} </a> </li> ))} </ul> </div> ))} </nav> </aside>
<div className="main-content"> {subcategories.map((sub) => ( <section key={sub.id}> <h2>{sub.name}</h2> <div className="article-list"> {sub.articles.map((article) => ( <ArticleCard key={article.id} article={article} /> ))} </div> </section> ))}
{uncategorized.length > 0 && ( <section> <h2>Other</h2> <div className="article-list"> {uncategorized.map((article) => ( <ArticleCard key={article.id} article={article} /> ))} </div> </section> )} </div> </div> </main> );}
function ArticleCard({ article }) { return ( <a href={`/docs/${article.channelName}/${article.slug}`} className="article-card"> <h3>{article.title}</h3> <p>{article.preview}</p> <span className="meta"> Updated {formatRelative(article.lastActivityAt)} </span> </a> );}4. Article Page
import { getArticle, getCategories, getArticles } from '@/lib/kb';
export async function getStaticProps({ params }) { const article = await getArticle(params.slug);
if (!article) { return { notFound: true }; }
// Get related articles (same tags) const { threads } = await getArticles(article.channelId); const related = threads .filter((t) => t.id !== article.id) .filter((t) => t.tags.some((tag) => article.tags.includes(tag))) .slice(0, 5);
return { props: { article, related, category: params.category, }, revalidate: 3600, };}
export default function ArticlePage({ article, related, category }) { const [mainContent, ...comments] = article.messages;
return ( <main className="article-page"> <Breadcrumb items={[ { label: 'Docs', href: '/docs' }, { label: formatTitle(category), href: `/docs/${category}` }, article.title, ]} />
<div className="article-layout"> <aside className="toc"> <h4>On this page</h4> <TableOfContents content={mainContent.contentHtml} /> </aside>
<article> <header> <h1>{article.title}</h1> <div className="meta"> <span>Last updated: {formatDate(article.lastActivityAt)}</span> {article.tags.map((tag) => ( <span key={tag} className="tag">{tag}</span> ))} </div> </header>
<div className="article-content" dangerouslySetInnerHTML={{ __html: mainContent.contentHtml }} />
{comments.length > 0 && ( <section className="updates"> <h2>Updates & Additions</h2> {comments.map((comment) => ( <Update key={comment.id} comment={comment} /> ))} </section> )}
<footer> <div className="feedback"> <p>Was this helpful?</p> <button>👍 Yes</button> <button>👎 No</button> </div> <a href={`https://discord.com/channels/${article.serverId}/${article.id}`} className="edit-link" > Edit on Discord → </a> </footer> </article>
<aside className="related"> <h4>Related Articles</h4> <ul> {related.map((r) => ( <li key={r.id}> <a href={`/docs/${category}/${r.slug}`}>{r.title}</a> </li> ))} </ul> </aside> </div> </main> );}
function TableOfContents({ content }) { // Extract headings from HTML const headings = content.match(/<h[23][^>]*>([^<]+)<\/h[23]>/g) || [];
return ( <ul> {headings.map((heading, i) => { const text = heading.replace(/<[^>]+>/g, ''); const id = text.toLowerCase().replace(/\s+/g, '-'); return ( <li key={i}> <a href={`#${id}`}>{text}</a> </li> ); })} </ul> );}
function Update({ comment }) { return ( <div className="update"> <div className="update-header"> <img src={getAvatarUrl(comment.author)} alt="" /> <span>{comment.author.username}</span> <time>{formatDate(comment.createdAt)}</time> </div> <div className="update-content" dangerouslySetInnerHTML={{ __html: comment.contentHtml }} /> </div> );}5. Search Results
import { searchKB } from '@/lib/kb';
export default function SearchPage() { const [query, setQuery] = useState(''); const [results, setResults] = useState(null); const [loading, setLoading] = useState(false);
async function handleSearch(e) { e.preventDefault(); if (!query.trim()) return;
setLoading(true); const data = await searchKB(query); setResults(data); setLoading(false); }
return ( <main className="search-page"> <header> <h1>Search Documentation</h1> <form onSubmit={handleSearch}> <input type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search for articles..." autoFocus /> <button type="submit" disabled={loading}> Search </button> </form> </header>
{results && ( <div className="search-results"> <p className="result-count"> Found {results.total} results for "{results.query}" </p>
{results.results.threads.map((thread) => ( <SearchResult key={thread.id} result={thread} type="article" /> ))}
{results.results.messages.map((message) => ( <SearchResult key={message.id} result={message} type="message" /> ))} </div> )} </main> );}Styling
/* Knowledge Base Layout */.kb-home { max-width: 1200px; margin: 0 auto; padding: 2rem;}
.category-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5rem; margin-top: 2rem;}
.category-card { background: white; border: 1px solid #e0e0e0; border-radius: 12px; padding: 1.5rem; transition: box-shadow 0.2s;}
.category-card:hover { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);}
.category-card .icon { font-size: 2rem;}
.category-card h2 { margin: 0.5rem 0;}
.category-card .subcategories { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 1rem 0;}
.category-card .subcategories a { background: #f4f4f4; padding: 0.25rem 0.75rem; border-radius: 20px; font-size: 0.875rem;}
/* Article Layout */.article-layout { display: grid; grid-template-columns: 200px 1fr 200px; gap: 2rem; max-width: 1400px; margin: 0 auto;}
@media (max-width: 1200px) { .article-layout { grid-template-columns: 1fr; }
.toc, .related { display: none; }}
.toc { position: sticky; top: 2rem; height: fit-content;}
.toc ul { list-style: none; padding: 0;}
.toc li { padding: 0.5rem 0; border-left: 2px solid #e0e0e0; padding-left: 1rem;}
.toc li.active { border-left-color: #5865f2;}
.article-content { max-width: 800px;}
.article-content h2 { scroll-margin-top: 2rem;}
/* Breadcrumb */.breadcrumb { display: flex; gap: 0.5rem; font-size: 0.875rem; color: #666; margin-bottom: 1rem;}
.breadcrumb a { color: #5865f2;}
.breadcrumb span::before { content: '/'; margin-right: 0.5rem;}Best Practices
Writing Documentation
- One topic per article - Keep articles focused
- Use clear titles - Descriptive, searchable titles
- Add tags - Use subcategory tags consistently
- Include examples - Code samples, screenshots
- Update regularly - Keep content current
Organization Tips
- Create a style guide thread in each channel
- Pin important articles in Discord
- Use the “Announcements” tag for major updates
- Encourage community contributions via replies