Skip to content

Developer Blog

Turn your Discord announcements channel into a polished developer blog. Write updates where your community already is.

Overview

Instead of maintaining a separate blog platform, write developer updates in Discord:

  • Write posts in a dedicated forum channel
  • Posts sync automatically to your blog website
  • Community can discuss each post in the thread
  • Built-in RSS feed for subscribers

Architecture

Discord Announcements Forum
Discord Forum API
├── GET /threads → Blog posts
├── GET /threads/:id → Full post with comments
└── RSS feed generation
Developer Blog Website

Setup

1. Create a Blog Forum Channel

In your Discord server:

  1. Create a new forum channel named “dev-blog” or “announcements”
  2. Add tags for categories:
    • release - Version releases
    • feature - New features
    • tutorial - How-to guides
    • update - General updates
  3. Set permissions so only team members can create threads
  4. Enable moderation to control comments

2. Fetch Blog Posts

lib/blog.js
const API_BASE = process.env.API_URL;
const BLOG_CHANNEL_ID = process.env.BLOG_CHANNEL_ID;
export async function getBlogPosts({ limit = 10, cursor } = {}) {
const params = new URLSearchParams({
channelId: BLOG_CHANNEL_ID,
sort: 'latest',
limit: limit.toString(),
});
if (cursor) params.set('cursor', cursor);
const response = await fetch(`${API_BASE}/threads?${params}`);
return response.json();
}
export async function getBlogPost(slug) {
const response = await fetch(`${API_BASE}/threads/${slug}`);
return response.json();
}
export async function getBlogPostsByTag(tag, limit = 10) {
const params = new URLSearchParams({
channelId: BLOG_CHANNEL_ID,
tag,
sort: 'latest',
limit: limit.toString(),
});
const response = await fetch(`${API_BASE}/threads?${params}`);
return response.json();
}

Implementation

Blog Homepage

pages/blog/index.jsx
import { getBlogPosts } from '@/lib/blog';
export async function getStaticProps() {
const { threads } = await getBlogPosts({ limit: 20 });
return {
props: { posts: threads },
revalidate: 300, // 5 minutes
};
}
export default function BlogPage({ posts }) {
const [featured, ...rest] = posts;
return (
<main className="blog-page">
<header>
<h1>Developer Blog</h1>
<p>Updates, releases, and insights from the team</p>
</header>
{featured && <FeaturedPost post={featured} />}
<div className="post-grid">
{rest.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
</main>
);
}
function FeaturedPost({ post }) {
return (
<article className="featured-post">
<div className="featured-content">
<div className="tags">
{post.tags.map((tag) => (
<span key={tag} className={`tag tag-${tag}`}>{tag}</span>
))}
</div>
<h2>
<a href={`/blog/${post.slug}`}>{post.title}</a>
</h2>
<p className="excerpt">{post.preview}</p>
<footer>
<AuthorBadge author={post.author} />
<time>{formatDate(post.createdAt)}</time>
</footer>
</div>
</article>
);
}
function PostCard({ post }) {
return (
<article className="post-card">
<div className="tags">
{post.tags.map((tag) => (
<span key={tag} className={`tag tag-${tag}`}>{tag}</span>
))}
</div>
<h3>
<a href={`/blog/${post.slug}`}>{post.title}</a>
</h3>
<p className="excerpt">{post.preview}</p>
<footer>
<AuthorBadge author={post.author} size="small" />
<time>{formatDate(post.createdAt)}</time>
</footer>
</article>
);
}
function AuthorBadge({ author, size = 'normal' }) {
return (
<div className={`author-badge ${size}`}>
<img src={getAvatarUrl(author)} alt="" />
<span>{author.globalName || author.username}</span>
</div>
);
}

Blog Post Page

pages/blog/[slug].jsx
import { getBlogPost, getBlogPosts } from '@/lib/blog';
export async function getStaticPaths() {
const { threads } = await getBlogPosts({ limit: 100 });
return {
paths: threads.map((t) => ({ params: { slug: t.slug } })),
fallback: 'blocking',
};
}
export async function getStaticProps({ params }) {
const post = await getBlogPost(params.slug);
if (!post) {
return { notFound: true };
}
return {
props: { post },
revalidate: 300,
};
}
export default function BlogPostPage({ post }) {
// First message is the blog post content
const [content, ...comments] = post.messages;
return (
<article className="blog-post">
<header>
<div className="tags">
{post.tags.map((tag) => (
<span key={tag} className={`tag tag-${tag}`}>{tag}</span>
))}
</div>
<h1>{post.title}</h1>
<div className="meta">
<AuthorBadge author={post.author} />
<time dateTime={post.createdAt}>{formatDate(post.createdAt)}</time>
<span className="reading-time">
{estimateReadingTime(content.content)} min read
</span>
</div>
</header>
<div
className="post-content"
dangerouslySetInnerHTML={{ __html: content.contentHtml }}
/>
{content.attachments.length > 0 && (
<div className="attachments">
{content.attachments.map((att) => (
<PostImage key={att.id} attachment={att} />
))}
</div>
)}
<footer className="post-footer">
<ShareButtons post={post} />
<a
href={`https://discord.com/channels/${post.serverId}/${post.id}`}
className="discord-link"
>
Discuss on Discord →
</a>
</footer>
{comments.length > 0 && (
<section className="comments">
<h2>Discussion ({comments.length})</h2>
{comments.map((comment) => (
<Comment key={comment.id} comment={comment} />
))}
</section>
)}
</article>
);
}
function PostImage({ attachment }) {
if (!attachment.contentType?.startsWith('image/')) {
return null;
}
return (
<figure>
<img
src={attachment.url}
alt={attachment.description || ''}
width={attachment.width}
height={attachment.height}
loading="lazy"
/>
{attachment.description && (
<figcaption>{attachment.description}</figcaption>
)}
</figure>
);
}
function Comment({ comment }) {
return (
<div className="comment">
<AuthorBadge author={comment.author} size="small" />
<div
className="comment-content"
dangerouslySetInnerHTML={{ __html: comment.contentHtml }}
/>
<time>{formatRelative(comment.createdAt)}</time>
</div>
);
}
function estimateReadingTime(text) {
const words = text.split(/\s+/).length;
return Math.ceil(words / 200);
}

Category Pages

pages/blog/category/[tag].jsx
import { getBlogPostsByTag } from '@/lib/blog';
export async function getStaticPaths() {
return {
paths: [
{ params: { tag: 'release' } },
{ params: { tag: 'feature' } },
{ params: { tag: 'tutorial' } },
{ params: { tag: 'update' } },
],
fallback: false,
};
}
export async function getStaticProps({ params }) {
const { threads } = await getBlogPostsByTag(params.tag, 50);
return {
props: {
tag: params.tag,
posts: threads,
},
revalidate: 300,
};
}
export default function CategoryPage({ tag, posts }) {
const labels = {
release: 'Releases',
feature: 'Features',
tutorial: 'Tutorials',
update: 'Updates',
};
return (
<main className="category-page">
<header>
<span className={`tag tag-${tag}`}>{labels[tag]}</span>
<h1>{labels[tag]}</h1>
<p>{posts.length} posts</p>
</header>
<div className="post-list">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
</main>
);
}

RSS Feed

// pages/api/feed.xml.js (Next.js API route)
import { getBlogPosts } from '@/lib/blog';
export default async function handler(req, res) {
const { threads } = await getBlogPosts({ limit: 50 });
const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Developer Blog</title>
<description>Updates, releases, and insights from the team</description>
<link>https://yourdomain.com/blog</link>
<atom:link href="https://yourdomain.com/api/feed.xml" rel="self" type="application/rss+xml"/>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
${threads.map((post) => `
<item>
<title><![CDATA[${post.title}]]></title>
<description><![CDATA[${post.preview}]]></description>
<link>https://yourdomain.com/blog/${post.slug}</link>
<guid>https://yourdomain.com/blog/${post.slug}</guid>
<pubDate>${new Date(post.createdAt).toUTCString()}</pubDate>
<author>${post.author.username}</author>
${post.tags.map((tag) => `<category>${tag}</category>`).join('')}
</item>
`).join('')}
</channel>
</rss>`;
res.setHeader('Content-Type', 'application/xml');
res.setHeader('Cache-Control', 's-maxage=3600, stale-while-revalidate');
res.status(200).send(rss);
}

Styling

/* Blog Layout */
.blog-page {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.blog-page header {
text-align: center;
margin-bottom: 3rem;
}
.blog-page h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
}
/* Featured Post */
.featured-post {
background: linear-gradient(135deg, #5865f2, #7983f5);
border-radius: 16px;
padding: 3rem;
margin-bottom: 3rem;
color: white;
}
.featured-post h2 {
font-size: 2rem;
margin-bottom: 1rem;
}
.featured-post h2 a {
color: white;
text-decoration: none;
}
.featured-post .excerpt {
font-size: 1.125rem;
opacity: 0.9;
}
/* Post Grid */
.post-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 2rem;
}
.post-card {
background: white;
border: 1px solid #e0e0e0;
border-radius: 12px;
padding: 1.5rem;
transition: transform 0.2s, box-shadow 0.2s;
}
.post-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
}
.post-card h3 {
margin: 0.75rem 0;
font-size: 1.25rem;
}
.post-card h3 a {
color: inherit;
text-decoration: none;
}
.post-card .excerpt {
color: #666;
line-height: 1.5;
}
/* Tags */
.tags {
display: flex;
gap: 0.5rem;
}
.tag {
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.tag-release { background: #dcfce7; color: #166534; }
.tag-feature { background: #dbeafe; color: #1e40af; }
.tag-tutorial { background: #fef3c7; color: #92400e; }
.tag-update { background: #f3e8ff; color: #7c3aed; }
/* Blog Post */
.blog-post {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.blog-post header {
margin-bottom: 2rem;
}
.blog-post h1 {
font-size: 2.5rem;
line-height: 1.2;
margin: 1rem 0;
}
.blog-post .meta {
display: flex;
align-items: center;
gap: 1rem;
color: #666;
}
.post-content {
font-size: 1.125rem;
line-height: 1.8;
}
.post-content h2 {
margin-top: 2rem;
}
.post-content img {
max-width: 100%;
border-radius: 8px;
}
.post-content code {
background: #f4f4f4;
padding: 0.2em 0.4em;
border-radius: 3px;
}
.post-content pre {
background: #1e1e1e;
color: #d4d4d4;
padding: 1.5rem;
border-radius: 8px;
overflow-x: auto;
}
/* Author Badge */
.author-badge {
display: flex;
align-items: center;
gap: 0.5rem;
}
.author-badge img {
width: 36px;
height: 36px;
border-radius: 50%;
}
.author-badge.small img {
width: 24px;
height: 24px;
}
/* Comments */
.comments {
margin-top: 3rem;
padding-top: 2rem;
border-top: 1px solid #e0e0e0;
}
.comment {
padding: 1rem 0;
border-bottom: 1px solid #f0f0f0;
}
.comment-content {
margin: 0.5rem 0;
}

Writing Tips

Good Blog Post Format

# Title: Clear and Descriptive
First paragraph: Hook the reader with what this post is about.
## What's New
- Bullet points for quick scanning
- Use code blocks for examples
## How to Use
Step-by-step instructions with code examples.
## What's Next
Tease upcoming features or link to related resources.
---
Questions? Reply below or join our Discord!

Using Discord Features

  • Bold and italic for emphasis
  • Code blocks with syntax highlighting
  • Images and GIFs
  • Embeds for links (auto-previewed)