Skip to content

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 Website

Discord 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: Upgrading

Threads = Articles

Each thread is a documentation article:

Thread: "Installing on Windows"
├── First message: Article content
└── Replies: Community additions, updates

Implementation

1. Fetch Knowledge Base Structure

lib/kb.js
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

pages/docs/index.jsx
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

pages/docs/[category]/index.jsx
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

pages/docs/[category]/[slug].jsx
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

pages/docs/search.jsx
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

  1. One topic per article - Keep articles focused
  2. Use clear titles - Descriptive, searchable titles
  3. Add tags - Use subcategory tags consistently
  4. Include examples - Code samples, screenshots
  5. 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