Building a Fully Customizable Headless Blog with Hashnode's Blog Starter Kit
Table of contents
As developers, we've all been there: spending hours setting up a blog infrastructure instead of writing content. We wrestle with deployment pipelines, SEO configurations, and content management systems when all we really want is to share our knowledge with the community.
Hashnode solves this problem elegantly. It's a next-generation blogging and documentation platform designed specifically for developers and teams. With over 1 million developers worldwide and 3.5 million monthly unique readers, Hashnode has become the de facto standard for technical blogs.
What makes Hashnode particularly compelling is its headless mode combined with the open-source Blog Starter Kit. This powerful combination gives you:
Complete design freedom with Next.js and TailwindCSS
A best-in-class writing experience with AI assistance
Automatic SEO optimization and performance
Full content ownership on your own domain
Zero cost (no ads, no paywalls)
Companies like FreeCodeCamp, Vercel, and MindsDB have adopted Hashnode for their technical content—and for good reason.
Core Features That Matter
1. Block-Based Editor
Hashnode's editor is Notion-like, with drag-and-drop support for code blocks, images, and embeds. As developers, we appreciate Markdown support, but the block-based approach makes it effortless to structure complex technical articles.
2. AI-Powered Writing Assistant
The built-in AI can:
Generate article summaries
Suggest relevant tags
Optimize SEO metadata
Rephrase content for clarity
Research topics within the editor
This dramatically improves productivity, especially when you're dealing with writer's block or need to optimize existing content.
3. Real-Time Collaboration
For team blogs, real-time collaboration is essential. Hashnode supports:
Co-editing articles with team members
Inline comments and discussions
Version history tracking
Multi-author management
4. Public GraphQL API
This is where things get interesting for developers. Hashnode provides a fully public GraphQL API at https://gql.hashnode.com that lets you:
query Publication {
publication(host: "yourblog.hashnode.dev") {
posts(first: 10) {
edges {
node {
title
brief
slug
publishedAt
content {
markdown
}
}
}
}
}
}
You can fetch articles, user profiles, comments, and more—all programmatically. This opens up endless possibilities for custom integrations.
The Blog Starter Kit: Your Foundation
The Hashnode Blog Starter Kit is an open-source starter kit with over 620 stars and 914 forks on GitHub. It's built with modern technologies:
Next.js 14+ with App Router
TailwindCSS for styling
TypeScript for type safety
GraphQL for data fetching
Monorepo Structure
The starter kit uses a monorepo structure with three pre-built themes:
packages/blog-starter-kit/
├── themes/
│ ├── enterprise/ # Corporate blogs
│ ├── hashnode/ # Official Hashnode style
│ └── personal/ # Individual developers
Each theme is independently deployable to Vercel, Netlify, or any platform supporting Next.js.
Getting Started: From Zero to Production
Step 1: Fork the Repository
Start by forking the official repository:
git clone https://github.com/YOUR-USERNAME/starter-kit.git
cd starter-kit
Step 2: Choose Your Theme
Navigate to your preferred theme:
cd packages/blog-starter-kit/themes/personal
pnpm install
Step 3: Configure Environment Variables
Create a .env.local file:
NEXT_PUBLIC_HASHNODE_PUBLICATION_HOST=yourblog.hashnode.dev
NEXT_PUBLIC_BASE_URL=https://yourdomain.com
The HASHNODE_PUBLICATION_HOST is your Hashnode blog URL, and BASE_URL is where you'll deploy your custom frontend.
Step 4: Deploy to Vercel
The easiest deployment path is Vercel:
Push your code to GitHub
Import the project in Vercel
Set the root directory to your theme folder (e.g.,
packages/blog-starter-kit/themes/personal)Add environment variables
Deploy!
# Or deploy from CLI
vercel --prod
Step 5: Enable Headless Mode
In your Hashnode dashboard:
Go to Blog Settings → Advanced
Enable "Headless Mode"
Set your custom domain to point to your Vercel deployment
Configure your DNS records
That's it! Your blog is now live with full customization capabilities.
Deep Dive: The Personal Theme
The Personal theme is optimized for individual developer branding. Let's examine its key features:
Header Component
// components/Header.tsx
import Link from "next/link";
import Image from "next/image";
const navigation = [
{ href: "/", label: "Home" },
{ href: "/blog", label: "Blog" },
{ href: "https://hashnode.com/@yourname", label: "Hashnode" }
];
export default function Header() {
return (
<header className="border-b border-gray-200 bg-white/70 backdrop-blur-lg sticky top-0 z-50">
<div className="max-w-5xl mx-auto px-4 py-4 flex items-center justify-between">
<Link href="/" className="flex items-center gap-2">
<Image
src="/avatar.png"
alt="Profile"
width={48}
height={48}
className="rounded-full"
/>
<span className="font-bold text-xl">Your Name</span>
</Link>
<nav className="flex gap-6">
{navigation.map(item => (
<Link
key={item.href}
href={item.href}
className="text-gray-600 hover:text-gray-900 transition-colors"
>
{item.label}
</Link>
))}
</nav>
</div>
</header>
);
}
This creates a clean, professional header with:
Profile image
Navigation menu
Semi-transparent backdrop blur effect
Sticky positioning
Article List Layout
// app/page.tsx
import { getRecentPosts } from '@/lib/hashnode';
export default async function HomePage() {
const posts = await getRecentPosts();
return (
<div className="max-w-3xl mx-auto px-4 py-12">
<h1 className="text-4xl font-bold mb-8">Blog</h1>
<div className="space-y-8">
{posts.map(post => (
<article key={post.slug} className="border-b border-gray-200 pb-8">
<Link href={`/blog/${post.slug}`}>
<h2 className="text-2xl font-bold mb-2 hover:text-blue-600 transition-colors">
{post.title}
</h2>
</Link>
<p className="text-gray-600 mb-4">{post.brief}</p>
<div className="flex items-center gap-4 text-sm text-gray-500">
<time>{new Date(post.publishedAt).toLocaleDateString()}</time>
<span>•</span>
<span>{post.views} views</span>
<span>•</span>
<span>{post.comments} comments</span>
</div>
</article>
))}
</div>
</div>
);
}
Customization: Making It Yours
Color Scheme with TailwindCSS
The beauty of TailwindCSS is how easy it is to customize your entire color scheme. Edit tailwind.config.js:
module.exports = {
content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}'],
darkMode: 'class',
theme: {
extend: {
colors: {
brand: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9', // Primary brand color
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['Fira Code', 'monospace'],
},
},
},
plugins: [],
};
Now you can use brand colors throughout your components:
<button className="bg-brand-500 hover:bg-brand-600 text-white px-6 py-2 rounded-lg">
Subscribe
</button>
Typography with Google Fonts
Add custom fonts in app/layout.tsx:
import { Inter, Fira_Code } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
});
const firaCode = Fira_Code({
subsets: ['latin'],
variable: '--font-fira-code',
});
export default function RootLayout({ children }) {
return (
<html lang="en" className={`${inter.variable} ${firaCode.variable}`}>
<body className="font-sans">{children}</body>
</html>
);
}
Dark Mode Implementation
Install next-themes:
pnpm add next-themes
Set up the ThemeProvider:
// app/providers.tsx
'use client';
import { ThemeProvider } from 'next-themes';
export function Providers({ children }) {
return (
<ThemeProvider attribute="class" defaultTheme="system">
{children}
</ThemeProvider>
);
}
Create a theme switcher component:
// components/ThemeToggle.tsx
'use client';
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';
export function ThemeToggle() {
const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme();
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return (
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="p-2 rounded-lg bg-gray-200 dark:bg-gray-800"
aria-label="Toggle theme"
>
{theme === 'dark' ? '🌞' : '🌙'}
</button>
);
}
Working with the GraphQL API
Fetching Blog Posts
Create a utility file for Hashnode API calls:
// lib/hashnode.ts
const HASHNODE_ENDPOINT = 'https://gql.hashnode.com';
interface PostNode {
title: string;
brief: string;
slug: string;
publishedAt: string;
coverImage?: {
url: string;
};
content: {
markdown: string;
};
}
export async function getRecentPosts(first: number = 10) {
const query = `
query Publication($host: String!, $first: Int!) {
publication(host: $host) {
posts(first: $first) {
edges {
node {
title
brief
slug
publishedAt
coverImage {
url
}
}
}
}
}
}
`;
const response = await fetch(HASHNODE_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query,
variables: {
host: process.env.NEXT_PUBLIC_HASHNODE_PUBLICATION_HOST,
first,
},
}),
next: { revalidate: 3600 }, // Cache for 1 hour
});
const { data } = await response.json();
return data.publication.posts.edges.map(edge => edge.node);
}
Fetching a Single Post
export async function getPost(slug: string) {
const query = `
query Post($host: String!, $slug: String!) {
publication(host: $host) {
post(slug: $slug) {
title
brief
slug
publishedAt
content {
markdown
html
}
coverImage {
url
}
author {
name
profilePicture
}
tags {
name
slug
}
}
}
}
`;
const response = await fetch(HASHNODE_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query,
variables: {
host: process.env.NEXT_PUBLIC_HASHNODE_PUBLICATION_HOST,
slug,
},
}),
});
const { data } = await response.json();
return data.publication.post;
}
Server Components in Next.js 14
With Next.js 14, you can fetch data directly in server components:
// app/blog/[slug]/page.tsx
import { getPost } from '@/lib/hashnode';
import { notFound } from 'next/navigation';
export default async function PostPage({ params }) {
const post = await getPost(params.slug);
if (!post) {
notFound();
}
return (
<article className="max-w-3xl mx-auto px-4 py-12">
{post.coverImage && (
<img
src={post.coverImage.url}
alt={post.title}
className="w-full h-96 object-cover rounded-lg mb-8"
/>
)}
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<div className="flex items-center gap-4 mb-8 text-gray-600">
<img
src={post.author.profilePicture}
alt={post.author.name}
className="w-10 h-10 rounded-full"
/>
<span>{post.author.name}</span>
<span>•</span>
<time>{new Date(post.publishedAt).toLocaleDateString()}</time>
</div>
<div
className="prose prose-lg max-w-none"
dangerouslySetInnerHTML={{ __html: post.content.html }}
/>
<div className="mt-8 flex gap-2">
{post.tags.map(tag => (
<span
key={tag.slug}
className="px-3 py-1 bg-gray-100 rounded-full text-sm"
>
#{tag.name}
</span>
))}
</div>
</article>
);
}
// Generate static paths for all posts
export async function generateStaticParams() {
const posts = await getRecentPosts(100);
return posts.map(post => ({
slug: post.slug,
}));
}
Advanced: Subpath Configuration
Sometimes you want your blog at yourdomain.com/blog instead of the root. Here's how:
Method 1: Vercel Rewrites
// next.config.js
module.exports = {
async rewrites() {
return [
{
source: '/blog',
destination: 'https://your-blog.vercel.app/blog',
},
{
source: '/blog/:path*',
destination: 'https://your-blog.vercel.app/blog/:path*',
},
];
},
basePath: '/blog',
};
Method 2: Cloudflare Workers
const BLOG_URL = 'https://your-blog.vercel.app';
const BLOG_PATH = '/blog';
addEventListener('fetch', event => {
const url = new URL(event.request.url);
if (url.pathname.startsWith(BLOG_PATH)) {
event.respondWith(handleBlogRequest(event.request));
}
});
async function handleBlogRequest(request) {
const url = new URL(request.url);
const blogUrl = `${BLOG_URL}${url.pathname}${url.search}`;
const response = await fetch(blogUrl, {
headers: request.headers,
method: request.method,
});
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
Deployment Best Practices
Environment Variables
Always use environment variables for sensitive data:
# .env.local
NEXT_PUBLIC_HASHNODE_PUBLICATION_HOST=yourblog.hashnode.dev
NEXT_PUBLIC_BASE_URL=https://yourdomain.com
NEXT_PUBLIC_GA_TRACKING_ID=G-XXXXXXXXXX
# Never commit this file to Git!
Performance Optimization
Enable Next.js image optimization:
// next.config.js
module.exports = {
images: {
domains: ['cdn.hashnode.com', 'hashnode.com'],
formats: ['image/avif', 'image/webp'],
},
};
Use the Image component:
import Image from 'next/image';
<Image
src={post.coverImage.url}
alt={post.title}
width={1200}
height={630}
priority={index === 0} // Priority for first image
className="rounded-lg"
/>
SEO Metadata
// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }) {
const post = await getPost(params.slug);
return {
title: post.title,
description: post.brief,
openGraph: {
title: post.title,
description: post.brief,
images: [post.coverImage?.url],
type: 'article',
publishedTime: post.publishedAt,
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.brief,
images: [post.coverImage?.url],
},
};
}
Real-World Examples
FreeCodeCamp
FreeCodeCamp uses Hashnode for their massive tutorial library. They've customized their blog to match their brand identity while leveraging Hashnode's collaboration features for their team of writers.
Key customizations:
Custom color scheme matching their brand
Integrated newsletter subscription
Custom article templates for tutorials
Vercel
Vercel's CEO, Guillermo Rauch, praised Hashnode: "Developers are amazed by the speed of launching a blog on a custom domain."
Vercel uses Hashnode's headless mode to maintain full control over their blog's frontend while benefiting from Hashnode's content management and SEO capabilities.
MindsDB
MindsDB adopted Hashnode for their API documentation and product guides. They report improved quality of developer documentation and easier collaboration among team members.
Conclusion: Your Next Steps
Building a technical blog doesn't have to be complicated. With Hashnode's Blog Starter Kit, you get:
A production-ready foundation with Next.js and TailwindCSS
Three professional themes to choose from
Full customization capabilities for your unique brand
Powerful GraphQL API for custom integrations
Best-in-class writing experience with AI assistance
Automatic SEO and performance optimization
Getting Started Today
Fork the repository: github.com/Hashnode/starter-kit
Choose your theme: Personal, Enterprise, or Hashnode
Deploy to Vercel: One-click deployment
Enable headless mode: In your Hashnode dashboard
Start customizing: Colors, fonts, components
Resources
The beauty of this approach is that you're not locked in. You own your content, your domain, and your design. Hashnode simply provides the infrastructure and tools to make your blogging experience exceptional.
Start building your technical blog today and join over 1 million developers who have already made Hashnode their platform of choice.
