Building a Fully Customizable Headless Blog with Hashnode's Blog Starter Kit

Building a Fully Customizable Headless Blog with Hashnode's Blog Starter Kit

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:

  1. Push your code to GitHub

  2. Import the project in Vercel

  3. Set the root directory to your theme folder (e.g., packages/blog-starter-kit/themes/personal)

  4. Add environment variables

  5. Deploy!

# Or deploy from CLI
vercel --prod

Step 5: Enable Headless Mode

In your Hashnode dashboard:

  1. Go to Blog Settings → Advanced

  2. Enable "Headless Mode"

  3. Set your custom domain to point to your Vercel deployment

  4. 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:

  1. A production-ready foundation with Next.js and TailwindCSS

  2. Three professional themes to choose from

  3. Full customization capabilities for your unique brand

  4. Powerful GraphQL API for custom integrations

  5. Best-in-class writing experience with AI assistance

  6. Automatic SEO and performance optimization

Getting Started Today

  1. Fork the repository: github.com/Hashnode/starter-kit

  2. Choose your theme: Personal, Enterprise, or Hashnode

  3. Deploy to Vercel: One-click deployment

  4. Enable headless mode: In your Hashnode dashboard

  5. 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.