Go Back

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

David Onifade
David OnifadeDecember 19, 2024

8 min read

Next.jsReactTypeScript

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

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

Install require dependencies

Bash
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

Bash
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:

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

File and folder structure:

Bash
1src/ 2β”œβ”€β”€ app/ 3β”‚ β”œβ”€β”€ blog/ 4β”‚ β”‚ └── [slug]/ 5β”‚ β”‚ └── page.tsx 6β”‚ β”œβ”€β”€ page.tsx 7β”‚ └── layout.tsx 8β”œβ”€ components/ 9β”‚ β”œβ”€β”€ markdown-formatter.tsx 10β”‚ β”œβ”€β”€ code-block.tsx 11β”‚ β”œβ”€β”€ related-posts.tsx 12└── lib/ 13 └── types/ 14 └── hashnode.ts 15 └── graphql.ts 16 └── hashnode-action.ts

GraphQL Client Configuration

Create src/lib/graphql.ts:

TYPESCRIPT
1import { GraphQLClient } from 'graphql-request'; 2 3export const HASHNODE_API_ENDPOINT = 'https://gql.hashnode.com'; 4 5export const hashNodeClient = new GraphQLClient(HASHNODE_API_ENDPOINT, { 6 headers: { 7 'Content-Type': 'application/json', 8 'Authorization': `Bearer ${process.env.HASHNODE_API_TOKEN}` 9 } 10}); 11 12// GraphQL queries definition 13export const GET_PUBLICATIONS = ` 14 query GetPublications($host: String!) { 15 publication(host: $host) { 16 id 17 title 18 about { 19 text 20 } 21 } 22 } 23`; 24 25export const GET_ALL_POSTS = ` 26 query GetAllPosts($publicationId: ObjectId!, $first: Int!, $after: String) { 27 publication(id: $publicationId) { 28 posts(first: $first, after: $after) { 29 edges { 30 node { 31 id 32 title 33 slug 34 publishedAt 35 subtitle 36 coverImage { 37 url 38 } 39 series { 40 name 41 } 42 author { 43 name 44 profilePicture 45 } 46 } 47 } 48 pageInfo { 49 hasNextPage 50 endCursor 51 } 52 } 53 } 54 } 55`; 56 57 58export const GET_SINGLE_POST = ` 59 query GetSinglePost($publicationId: ObjectId!, $slug: String!) { 60 publication(id: $publicationId) { 61 post(slug: $slug) { 62 id 63 title 64 subtitle 65 readTimeInMinutes 66 slug 67 content { 68 markdown 69 } 70 publishedAt 71 updatedAt 72 coverImage { 73 url 74 } 75 author { 76 name 77 profilePicture 78 } 79 tags { 80 name 81 } 82 } 83 } 84 } 85`; 86 87export const GET_RELATED_POSTS = ` 88 query GetRelatedPosts($host: String!, $tagSlugs: [String!]!) { 89 publication(host: $host) { 90 posts(first: 4, filter: {tagSlugs: $tagSlugs}) { 91 edges { 92 node { 93 id 94 title 95 slug 96 publishedAt 97 brief 98 coverImage { 99 url 100 } 101 tags { 102 name 103 } 104 } 105 } 106 } 107 } 108} 109`;

Create src/lib/hashnode-action.ts:

TYPESCRIPT
1'use server'; 2 3import { hashNodeClient, GET_PUBLICATIONS, GET_ALL_POSTS, GET_SINGLE_POST, GET_RELATED_POSTS} from './graphql'; 4import { SUBSCRIBE_TO_NEWSLETTER } from './mutation'; 5import { GetPostResponse, GetPostsInSeriesResponse, GetPostsResponse, GetPublicationsResponse, GraphQLError, NewsletterSubscriptionResponse } from './types/hashnode'; 6 7export async function fetchPublications(host: string): Promise<GetPublicationsResponse> { 8 try { 9 const data = await hashNodeClient.request<GetPublicationsResponse>(GET_PUBLICATIONS, { host }); 10 return data; 11 } catch (error: any) { 12 console.error('GraphQL Error:', error.response || error.message); 13 throw new Error('Failed to fetch publications'); 14 } 15} 16export async function fetchAllPosts(publicationId: string, first: number, after?: string): Promise<GetPostsResponse> { 17 try { 18 const data = await hashNodeClient.request<GetPostsResponse>(GET_ALL_POSTS, { 19 publicationId, 20 first, 21 after, 22 }); 23 return data; 24 } catch (error) { 25 console.error('Error fetching posts:', error); 26 throw error; 27 } 28} 29 30export async function fetchPost(publicationId: string, slug: string): Promise<GetPostResponse> { 31 try { 32 const data = await hashNodeClient.request<GetPostResponse>(GET_SINGLE_POST, { 33 publicationId, 34 slug 35 }); 36 return data; 37 } catch (error) { 38 console.error('Error fetching post:', error); 39 throw error; 40 } 41} 42 43export async function fetchRelatedPosts(host: string, tagSlugs: string[]): Promise<GetPostsResponse | null > { 44 try { 45 const data = await hashNodeClient.request<GetPostsResponse>(GET_RELATED_POSTS, { host, tagSlugs }); 46 return data; 47 } catch (error) { 48 console.error('Error fetching related posts:', error); 49 return null; 50 } 51}

Creating the Blog Components

Create src/components/markdown-formatter.tsx

TYPESCRIPT
1'use client' 2import React from 'react'; 3import Markdown from 'react-markdown'; 4import remarkGfm from 'remark-gfm'; 5import CodeBlock from './code-block'; 6 7interface MarkdownFormatterProps { 8 markdown: string; 9} 10 11const MarkdownFormatter: React.FC<MarkdownFormatterProps> = ({ markdown }) => { 12 return ( 13 <article className="prose lg:prose-xl w-full max-w-7xl text-gray-300 text-base md:text-lg leading-loose"> 14 <Markdown 15 components={{ 16 // @ts-ignore 17 code: CodeBlock, 18 h1: ({node, ...props}) => <h1 className="text-3xl leading-9 font-bold mb-4" {...props} />, 19 h2: ({node, ...props}) => <h2 className="text-2xl leading-8 font-semibold mb-3" {...props} />, 20 h3: ({node, ...props}) => <h3 className="text-xl leading-7 font-semibold mb-2" {...props} />, 21 a: ({node, ...props}) => <a className="text-primary hover:underline" {...props} />, 22 ul: ({node, ...props}) => <ul className="list-disc pl-6 mb-4" {...props} />, 23 ol: ({node, ...props}) => <ol className="list-decimal pl-6 mb-4" {...props} />, 24 blockquote: ({node, ...props}) => ( 25 <blockquote className="border-l-4 border-gray-300 pl-4 italic" {...props} /> 26 ), 27 mark: ({node, ...props}) => ( 28 <mark className="bg-yellow-200 text-black px-1 py-0.5 rounded" {...props} /> 29 ) 30 }} 31 remarkPlugins={[remarkGfm]} 32 > 33 {markdown} 34 </Markdown> 35 </article> 36 ); 37}; 38 39export default MarkdownFormatter;

Create src/components/code-block.tsx

TYPESCRIPT
1"use client" 2 3import React, { useState } from "react"; 4import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; 5import { atomDark } from "react-syntax-highlighter/dist/cjs/styles/prism"; 6import { FaCopy, FaCheck } from "react-icons/fa6"; 7import ReactMarkdownProps from "react-markdown"; 8 9const CodeBlock: React.FC<{ 10 node?: any; 11 inline?: boolean; 12 className?: string; 13 children?: React.ReactNode; 14} & typeof ReactMarkdownProps> = ({ node, inline, className, children, ...props }) => { 15 const [copiedCodeBlocks, setCopiedCodeBlocks] = useState< 16 Record<string, boolean> 17 >({}); 18 19 const handleCodeCopy = (code: string) => { 20 navigator.clipboard.writeText(code); 21 const blockId = code.slice(0, 10).replace(/\W/g, ""); 22 setCopiedCodeBlocks((prev) => ({ 23 ...prev, 24 [blockId]: true, 25 })); 26 27 setTimeout(() => { 28 setCopiedCodeBlocks((prev) => ({ 29 ...prev, 30 [blockId]: false, 31 })); 32 }, 2000); 33 }; 34 35 const match = /language-(\w+)/.exec(className || ""); 36 const code = String(children).replace(/\n$/, ""); 37 const blockId = code.slice(0, 10).replace(/\W/g, ""); 38 39 return !inline && match ? ( 40 <div className="relative group"> 41 <SyntaxHighlighter 42 className="rounded-xl text-base" 43 style={atomDark} 44 language={match[1]} 45 PreTag="div" 46 {...props} 47 > 48 {code} 49 </SyntaxHighlighter> 50 <button 51 onClick={() => handleCodeCopy(code)} 52 className="absolute top-2 right-2 p-1 bg-gray-700 text-white rounded opacity-0 group-hover:opacity-100 transition-opacity ease" 53 > 54 {copiedCodeBlocks[blockId] ? ( 55 <FaCheck size={16} className="text-green-400" /> 56 ) : ( 57 <FaCopy size={16} /> 58 )} 59 </button> 60 </div> 61 ) : ( 62 <code className={className} {...props}> 63 {children} 64 </code> 65 ); 66}; 67 68export default CodeBlock;

Create src/lib/types/hashnode.ts

TYPESCRIPT
1export interface Author { 2 name: string; 3 profilePicture: string; 4} 5 6export interface CoverImage { 7 url: string; 8} 9 10export type Tags = { 11 name: string 12}; 13 14export interface PostNode { 15 id: string; 16 title: string; 17 subtitle: string; 18 slug: string; 19 readTimeInMinutes: number; 20 brief: string; 21 series: { 22 name: string; 23 } 24 coverImage: CoverImage; 25 author: Author; 26 tags: Tags[]; 27 content: { 28 markdown: string; 29 } 30 publishedAt: string; 31 updatedAt: string; 32} 33 34export interface PageInfo { 35 hasNextPage: boolean; 36 endCursor: string | null; 37} 38 39export interface PostEdge { 40 node: PostNode; 41} 42 43export interface Posts { 44 edges: PostEdge[]; 45 pageInfo: PageInfo; 46} 47 48export interface Publication { 49 id: string; 50 title: string; 51 about: { 52 text: string; 53 }; 54} 55 56export interface GetPublicationsResponse { 57 publication: Publication; 58} 59export interface GetPostsResponse { 60 publication: { 61 posts: Posts; 62 }; 63} 64 65export interface GetPostResponse { 66 publication: { 67 post: PostNode; 68 }; 69} 70 71export interface HashnodeAPIResponse { 72 publication: Publication; 73}

Create src/app/blog/page.tsx:

TYPESCRIPT
1import Link from "next/link"; 2import Image from "next/image"; 3import { fetchAllPosts, fetchPublications } from "@/lib/hashnode-action"; 4import { Suspense } from "react"; 5import { PostsLoading } from "@/components/posts-loading"; 6 7const HASHNODE_PUBLICATION_ID = 8 process.env.NEXT_HASHNODE_PUBLICATION_ID || ""; 9 10export default async function BlogHome() { 11 const postsData = await fetchAllPosts(NEXT_HASHNODE_PUBLICATION_ID, 10); 12 13 const posts = postsData.publication.posts.edges; 14 15return ( 16 <div> 17 <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6"> 18 {posts.map(({ node: post }) => ( 19 <Link 20 key={post.id} 21 href={`/blog/${post.slug}`} 22 className="block hover:shadow-lg transition-all" 23 > 24 {post.coverImage && ( 25 <Image 26 src={post.coverImage.url} 27 alt={post.title} 28 width={400} 29 height={200} 30 className="w-full h-48 object-cover" 31 /> 32 )} 33 <div className="p-4"> 34 <h2 className="text-xl font-semibold">{post.title}</h2> 35 <p className="text-gray-600">{post.brief}</p> 36 <div className="mt-2 text-sm text-gray-500"> 37 {new Date(post.publishedAt).toLocaleDateString()} 38 {' β€’ '} 39 {post.author.name} 40 </div> 41 </div> 42 </Link> 43 ))} 44 </div> 45 </div> 46 ); 47}

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

TYPESCRIPT
1import { fetchPost, fetchRelatedPosts } from "@/lib/hashnode-action"; 2import { IoBookOutline } from "react-icons/io5"; 3import Image from "next/image"; 4import RelatedPosts from "@/components/related-posts"; 5import { PostNode } from "@/lib/types/hashnode"; 6import MarkdownFormatter from "@/components/blog/markdown-formatter"; 7import Link from "next/link"; 8 9const HASHNODE_PUBLICATION_ID = 10 process.env.NEXT_HASHNODE_PUBLICATION_ID || ""; 11const HASHNODE_HOST = 12 process.env.NEXT_HASHNODE_PUBLICATION_HOST || ""; 13 14export default async function BlogPost({ 15 params, 16}: { 17 params: { slug: string }; 18}) { 19 const postData = await fetchPost(HASHNODE_PUBLICATION_ID, params.slug); 20 const post = postData.publication.post; 21 22 const currentPostId = post.id; 23 24 const tagSlugs = post.tags.map((tag) => tag.name); 25 26 const relatedPostData = await fetchRelatedPosts(HASHNODE_HOST, tagSlugs); 27 28 let relatedPosts: PostNode[] = []; 29 30 if (relatedPostData && relatedPostData.publication) { 31 relatedPosts = relatedPostData.publication.posts.edges 32 .filter((edge) => edge.node.id !== currentPostId) 33 .map((edge) => edge.node); 34 } 35 36return ( 37 <div className="max-w-4xl mx-auto"> 38 {post.coverImage && ( 39 <Image 40 src={post.coverImage.url} 41 alt={post.title} 42 width={1200} 43 height={600} 44 className="w-full h-96 object-cover mb-8" 45 /> 46 )} 47 <h1 className="text-4xl font-bold mb-4">{post.title}</h1> 48 <div className="flex items-center mb-6"> 49 {post.author.profilePicture && ( 50 <Image 51 src={post.author.profilePicture} 52 alt={post.author.name} 53 width={50} 54 height={50} 55 className="rounded-full mr-4" 56 /> 57 )} 58 <div> 59 <p className="font-semibold">{post.author.name}</p> 60 <p className="text-gray-600"> 61 {new Date(post.publishedAt).toLocaleDateString()} 62 </p> 63 </div> 64 </div> 65 66 <MarkdownFormatter markdown={post.content.markdown} /> 67 <p className="text-right mt-6 text-gray-400"> 68 Last updated: {new Date(post.updatedAt).toLocaleDateString()} 69 </p> 70 {relatedPosts.length > 0 && ( 71 <div className="my-12"> 72 <h2 className="text-2xl font-bold mb-6">Related Posts</h2> 73 <RelatedPosts posts={relatedPosts} /> 74 </div> 75 )} 76 </div> 77 ); 78}

Create src/components/related-posts.tsx

TYPESCRIPT
1import Link from 'next/link' 2import Image from 'next/image' 3import { PostNode } from '@/lib/types/hashnode' 4 5interface RelatedPostsProps { 6 posts: PostNode[] 7} 8 9export default function RelatedPosts({ posts }: RelatedPostsProps) { 10 return ( 11 <div className="mt-12"> 12 <h3 className="text-2xl font-bold mb-6">Related Posts</h3> 13 <div className="grid md:grid-cols-2 lg:grid-cols-4 gap-4"> 14 {posts.map((post) => ( 15 <Link 16 key={post.id} 17 href={`/blog/${post.slug}`} 18 className="block hover:shadow-lg transition-all" 19 > 20 {post.coverImage && ( 21 <Image 22 src={post.coverImage.url} 23 alt={post.title} 24 width={300} 25 height={150} 26 loading='lazy' 27 className="w-full h-36 object-contain" 28 /> 29 )} 30 <div className="p-3"> 31 <h4 className="font-semibold">{post.title}</h4> 32 <p className="text-sm text-gray-600"> 33 {new Date(post.publishedAt).toLocaleDateString()} 34 </p> 35 </div> 36 </Link> 37 ))} 38 </div> 39 </div> 40 ) 41}

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

Testing:

Bash
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: December 20, 2024

Subscribe to my newsletter

no spam, good stuff only

Built by David, Designed by Kolawole Olubummo