Building a Next.js Blog with Hashnode GraphQL API

Building a Next.js Blog with Hashnode GraphQL API

A Step-by-Step Guide to Creating a Dynamic Blog with Next.js and Hashnode for Content Management

Β·

8 min read

Introduction

I started using hashnode sometime in 2023, shortly after I built my portfolio, I needed a blog on my portfolio, but I did not want to use any of these content managements systems like strapi, I needed a solution where I could write my articles once and it becomes available on my website, then I came across hashnode’s graphql api.

TLDR; In this guide I will walk you through how I created a full-featured blog using Next.js 15 (App Router) and the Hashnode GraphQL API. We'll build a blog that supports single posts, multiple posts, and related posts.

Prerequisites

Before starting, ensure you have:

  • Node.js 18.17 or later installed

  • A Hashnode account and blog

  • Basic knowledge of React and TypeScript

  • Familiarity with GraphQL concepts (If you're not familiar with GraphQL, be sure to check out this beginner-friendly guide on freeCodeCamp)

Project Setup

Create a new Next.js project

npx create-next-app@latest hashnode-blog
cd hashnode-blog

Install require dependencies

npm install graphql-request react-syntax-highlighter react-markdown remark-gfm

# Install types
npm install @types/react-syntax-highlighter

Create environment variables file .env.local

NEXT_HASHNODE_API_TOKEN=your_personal_access_token
NEXT_HASHNODE_PUBLICATION_ID=your-publication_id
NEXT_HASHNODE_PUBLICATION_HOST=your_username.hashnode.dev

To get your publicationId, got to gql.hashnode.com and run:

query {
  publication(host: "your_username.hashnode.dev") {
    id
  }
}

File and folder structure:

src/
β”œβ”€β”€ app/
β”‚   β”œβ”€β”€ blog/
β”‚   β”‚   └── [slug]/
β”‚   β”‚       └── page.tsx
β”‚   β”œβ”€β”€ page.tsx
β”‚   └── layout.tsx
β”œβ”€ components/
β”‚   β”œβ”€β”€ markdown-formatter.tsx
β”‚   β”œβ”€β”€ code-block.tsx
β”‚   β”œβ”€β”€ related-posts.tsx
└── lib/
    └── types/
        └── hashnode.ts
    └── graphql.ts
    └── hashnode-action.ts

GraphQL Client Configuration

Create src/lib/graphql.ts:

import { GraphQLClient } from 'graphql-request';

export const HASHNODE_API_ENDPOINT = 'https://gql.hashnode.com';

export const hashNodeClient = new GraphQLClient(HASHNODE_API_ENDPOINT, {
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${process.env.HASHNODE_API_TOKEN}`
  }
});

// GraphQL queries definition
export const GET_PUBLICATIONS = `
  query GetPublications($host: String!) {
    publication(host: $host) {
      id
      title
      about {
        text
      }
    }
  }
`;

export const GET_ALL_POSTS = `
  query GetAllPosts($publicationId: ObjectId!, $first: Int!, $after: String) {
    publication(id: $publicationId) {
      posts(first: $first, after: $after) {
        edges {
          node {
            id
            title
            slug
            publishedAt
            subtitle
            coverImage {
              url
            }
            series {
              name
            }
            author {
              name
              profilePicture
            }
          }
        }
        pageInfo {
          hasNextPage
          endCursor
        }
      }
    }
  }
`;


export const GET_SINGLE_POST = `
  query GetSinglePost($publicationId: ObjectId!, $slug: String!) {
    publication(id: $publicationId) {
      post(slug: $slug) {
        id
        title
        subtitle
        readTimeInMinutes
        slug
        content {
          markdown
        }
        publishedAt
        updatedAt
        coverImage {
          url
        }
        author {
          name
          profilePicture
        }
        tags {
          name
        }
      }
    }
  }
`;

export const GET_RELATED_POSTS = `
  query GetRelatedPosts($host: String!, $tagSlugs: [String!]!) {
  publication(host: $host) {
    posts(first: 4, filter: {tagSlugs: $tagSlugs}) {
      edges {
        node {
          id
          title
          slug
          publishedAt
          brief
          coverImage {
            url
          }
          tags {
            name
          }
        }
      }
    }
  }
}
`;

Create src/lib/hashnode-action.ts:

'use server';

import { hashNodeClient, GET_PUBLICATIONS, GET_ALL_POSTS, GET_SINGLE_POST, GET_RELATED_POSTS} from './graphql';
import { SUBSCRIBE_TO_NEWSLETTER } from './mutation';
import { GetPostResponse, GetPostsInSeriesResponse, GetPostsResponse, GetPublicationsResponse, GraphQLError, NewsletterSubscriptionResponse } from './types/hashnode';

export async function fetchPublications(host: string): Promise<GetPublicationsResponse> {
  try {
    const data = await hashNodeClient.request<GetPublicationsResponse>(GET_PUBLICATIONS, { host });
    return data;
  } catch (error: any) {
    console.error('GraphQL Error:', error.response || error.message);
    throw new Error('Failed to fetch publications');
  }
}
export async function fetchAllPosts(publicationId: string, first: number, after?: string): Promise<GetPostsResponse> {
  try {
    const data = await hashNodeClient.request<GetPostsResponse>(GET_ALL_POSTS, {
      publicationId,
      first,
      after,
    });
    return data;
  } catch (error) {
    console.error('Error fetching posts:', error);
    throw error;
  }
}

export async function fetchPost(publicationId: string, slug: string): Promise<GetPostResponse> {
  try {
    const data = await hashNodeClient.request<GetPostResponse>(GET_SINGLE_POST, {
      publicationId,
      slug
    });
    return data;
  } catch (error) {
    console.error('Error fetching post:', error);
    throw error;
  }
}

export async function fetchRelatedPosts(host: string, tagSlugs: string[]): Promise<GetPostsResponse | null > {
  try {
    const data = await hashNodeClient.request<GetPostsResponse>(GET_RELATED_POSTS, { host, tagSlugs });
    return data;
  } catch (error) {
    console.error('Error fetching related posts:', error);
    return null;
  }
}

Creating the Blog Components

Create src/components/markdown-formatter.tsx

'use client'
import React from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import CodeBlock from './code-block';

interface MarkdownFormatterProps {
  markdown: string;
}

const MarkdownFormatter: React.FC<MarkdownFormatterProps> = ({ markdown }) => {
  return (
    <article className="prose lg:prose-xl w-full max-w-7xl text-gray-300 text-base md:text-lg leading-loose">
      <Markdown
        components={{
          // @ts-ignore
          code: CodeBlock,
          h1: ({node, ...props}) => <h1 className="text-3xl leading-9 font-bold mb-4" {...props} />,
          h2: ({node, ...props}) => <h2 className="text-2xl leading-8 font-semibold mb-3" {...props} />,
          h3: ({node, ...props}) => <h3 className="text-xl leading-7 font-semibold mb-2" {...props} />,
          a: ({node, ...props}) => <a className="text-primary hover:underline" {...props} />,
          ul: ({node, ...props}) => <ul className="list-disc pl-6 mb-4" {...props} />,
          ol: ({node, ...props}) => <ol className="list-decimal pl-6 mb-4" {...props} />,
          blockquote: ({node, ...props}) => (
            <blockquote className="border-l-4 border-gray-300 pl-4 italic" {...props} />
          ),
          mark: ({node, ...props}) => (
            <mark className="bg-yellow-200 text-black px-1 py-0.5 rounded" {...props} />
          )
        }}
        remarkPlugins={[remarkGfm]}
      >
        {markdown}
      </Markdown>
    </article>
  );
};

export default MarkdownFormatter;

Create src/components/code-block.tsx

"use client"

import React, { useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { atomDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
import { FaCopy, FaCheck } from "react-icons/fa6";
import ReactMarkdownProps from "react-markdown";

const CodeBlock: React.FC<{
  node?: any;
  inline?: boolean;
  className?: string;
  children?: React.ReactNode;
} & typeof ReactMarkdownProps> = ({ node, inline, className, children, ...props }) => {
  const [copiedCodeBlocks, setCopiedCodeBlocks] = useState<
    Record<string, boolean>
  >({});

  const handleCodeCopy = (code: string) => {
    navigator.clipboard.writeText(code);
    const blockId = code.slice(0, 10).replace(/\W/g, "");
    setCopiedCodeBlocks((prev) => ({
      ...prev,
      [blockId]: true,
    }));

    setTimeout(() => {
      setCopiedCodeBlocks((prev) => ({
        ...prev,
        [blockId]: false,
      }));
    }, 2000);
  };

  const match = /language-(\w+)/.exec(className || "");
  const code = String(children).replace(/\n$/, "");
  const blockId = code.slice(0, 10).replace(/\W/g, "");

  return !inline && match ? (
    <div className="relative group">
      <SyntaxHighlighter
        className="rounded-xl text-base"
        style={atomDark}
        language={match[1]}
        PreTag="div"
        {...props}
      >
        {code}
      </SyntaxHighlighter>
      <button
        onClick={() => handleCodeCopy(code)}
        className="absolute top-2 right-2 p-1 bg-gray-700 text-white rounded opacity-0 group-hover:opacity-100 transition-opacity ease"
      >
        {copiedCodeBlocks[blockId] ? (
          <FaCheck size={16} className="text-green-400" />
        ) : (
          <FaCopy size={16} />
        )}
      </button>
    </div>
  ) : (
    <code className={className} {...props}>
      {children}
    </code>
  );
};

export default CodeBlock;

Create src/lib/types/hashnode.ts

export interface Author {
    name: string;
    profilePicture: string;
}

export interface CoverImage {
    url: string;
}

export type Tags = {
    name: string
};

export interface PostNode {
    id: string;
    title: string;
    subtitle: string;
    slug: string;
    readTimeInMinutes: number;
    brief: string;
    series: {
        name: string;
    }
    coverImage: CoverImage;
    author: Author;
    tags: Tags[];
    content: {
        markdown: string;
    }
    publishedAt: string;
    updatedAt: string;
}

export interface PageInfo {
    hasNextPage: boolean;
    endCursor: string | null;
}

export interface PostEdge {
    node: PostNode;
}

export interface Posts {
    edges: PostEdge[];
    pageInfo: PageInfo;
}

export interface Publication {
    id: string;
    title: string;
    about: {
        text: string;
    };
}

export interface GetPublicationsResponse {
    publication: Publication;
}
export interface GetPostsResponse {
    publication: {
        posts: Posts;
    };
}

export interface GetPostResponse {
    publication: {
        post: PostNode;
    };
}

export interface HashnodeAPIResponse {
    publication: Publication;
}

Create src/app/blog/page.tsx:

import Link from "next/link";
import Image from "next/image";
import { fetchAllPosts, fetchPublications } from "@/lib/hashnode-action";
import { Suspense } from "react";
import { PostsLoading } from "@/components/posts-loading";

const HASHNODE_PUBLICATION_ID =
  process.env.NEXT_HASHNODE_PUBLICATION_ID || "";

export default async function BlogHome() {
  const postsData = await fetchAllPosts(NEXT_HASHNODE_PUBLICATION_ID, 10);

  const posts = postsData.publication.posts.edges;

return (
    <div>
      <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
        {posts.map(({ node: post }) => (
          <Link 
            key={post.id} 
            href={`/blog/${post.slug}`} 
            className="block hover:shadow-lg transition-all"
          >
            {post.coverImage && (
              <Image 
                src={post.coverImage.url} 
                alt={post.title} 
                width={400} 
                height={200} 
                className="w-full h-48 object-cover"
              />
            )}
            <div className="p-4">
              <h2 className="text-xl font-semibold">{post.title}</h2>
              <p className="text-gray-600">{post.brief}</p>
              <div className="mt-2 text-sm text-gray-500">
                {new Date(post.publishedAt).toLocaleDateString()}
                {' β€’ '}
                {post.author.name}
              </div>
            </div>
          </Link>
        ))}
      </div>
    </div>
  );
}

Create src/app/blog/[slug]/page.tsx

import { fetchPost, fetchRelatedPosts } from "@/lib/hashnode-action";
import { IoBookOutline } from "react-icons/io5";
import Image from "next/image";
import RelatedPosts from "@/components/related-posts";
import { PostNode } from "@/lib/types/hashnode";
import MarkdownFormatter from "@/components/blog/markdown-formatter";
import Link from "next/link";

const HASHNODE_PUBLICATION_ID =
  process.env.NEXT_HASHNODE_PUBLICATION_ID || "";
const HASHNODE_HOST =
  process.env.NEXT_HASHNODE_PUBLICATION_HOST || "";

export default async function BlogPost({
  params,
}: {
  params: { slug: string };
}) {
  const postData = await fetchPost(HASHNODE_PUBLICATION_ID, params.slug);
  const post = postData.publication.post;

  const currentPostId = post.id;

  const tagSlugs = post.tags.map((tag) => tag.name);

  const relatedPostData = await fetchRelatedPosts(HASHNODE_HOST, tagSlugs);

  let relatedPosts: PostNode[] = [];

  if (relatedPostData && relatedPostData.publication) {
    relatedPosts = relatedPostData.publication.posts.edges
      .filter((edge) => edge.node.id !== currentPostId)
      .map((edge) => edge.node);
  }

return (
    <div className="max-w-4xl mx-auto">
      {post.coverImage && (
        <Image 
          src={post.coverImage.url} 
          alt={post.title} 
          width={1200} 
          height={600} 
          className="w-full h-96 object-cover mb-8"
        />
      )}
      <h1 className="text-4xl font-bold mb-4">{post.title}</h1>
      <div className="flex items-center mb-6">
        {post.author.profilePicture && (
          <Image 
            src={post.author.profilePicture} 
            alt={post.author.name} 
            width={50} 
            height={50} 
            className="rounded-full mr-4"
          />
        )}
        <div>
          <p className="font-semibold">{post.author.name}</p>
          <p className="text-gray-600">
            {new Date(post.publishedAt).toLocaleDateString()}
          </p>
        </div>
      </div>

       <MarkdownFormatter markdown={post.content.markdown} />
        <p className="text-right mt-6 text-gray-400">
          Last updated: {new Date(post.updatedAt).toLocaleDateString()}
        </p>
        {relatedPosts.length > 0 && (
          <div className="my-12">
            <h2 className="text-2xl font-bold mb-6">Related Posts</h2>
            <RelatedPosts posts={relatedPosts} />
          </div>
        )}
    </div>
  );
}

Create src/components/related-posts.tsx

import Link from 'next/link'
import Image from 'next/image'
import { PostNode } from '@/lib/types/hashnode'

interface RelatedPostsProps {
  posts: PostNode[]
}

export default function RelatedPosts({ posts }: RelatedPostsProps) {
  return (
    <div className="mt-12">
      <h3 className="text-2xl font-bold mb-6">Related Posts</h3>
      <div className="grid md:grid-cols-2 lg:grid-cols-4 gap-4">
        {posts.map((post) => (
          <Link 
            key={post.id} 
            href={`/blog/${post.slug}`} 
            className="block hover:shadow-lg transition-all"
          >
            {post.coverImage && (
              <Image 
                src={post.coverImage.url} 
                alt={post.title} 
                width={300} 
                height={150}
                loading='lazy'
                className="w-full h-36 object-contain"
              />
            )}
            <div className="p-3">
              <h4 className="font-semibold">{post.title}</h4>
              <p className="text-sm text-gray-600">
                {new Date(post.publishedAt).toLocaleDateString()}
              </p>
            </div>
          </Link>
        ))}
      </div>
    </div>
  )
}

That’s pretty much everything we need to get the app running

Testing:

npm run dev

Head on to http://localhost:3000/blog to view you blog posts

Conclusion

Thank you for reading to this point, I hope you were able to successfully setup your blog. Until next time, keep on building and deploying. ✌🏼
If you have questions, please feel free to drop them in the comments, I’ll do my best to send a response ASAP.

Β