Go Back

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

David Onifade
David Onifade12/19/2024

8 min

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 can write my articles once and it’s 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, GET_POSTS_IN_SERIES } 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; } } export async function fetchPostsInSeries(publicationId: string, slug: string): Promise<GetPostsInSeriesResponse> { try { const data = await hashNodeClient.request<GetPostsInSeriesResponse>(GET_POSTS_IN_SERIES, { publicationId, slug }); return data; } catch (error) { console.error('Error fetching post:', error); throw error; } }

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 GetPostsInSeriesResponse { publication: { series: { 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.

Read on hashnode

Last updated: 12/19/2024

Subscribe to my newsletter

no spam, good stuff only

Built by David, Designed by Kolawole Olubummo