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
Bashnpx create-next-app@latest hashnode-blog cd hashnode-blog
Install require dependencies
Bashnpm install graphql-request react-syntax-highlighter react-markdown remark-gfm # Install types npm install @types/react-syntax-highlighter
Create environment variables file .env.local
BashNEXT_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:
Bashquery { publication(host: "your_username.hashnode.dev") { id } }
File and folder structure:
Bash1src/ 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
:
TYPESCRIPT1import { 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
:
TYPESCRIPT1'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
TYPESCRIPT1'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
TYPESCRIPT1"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
TYPESCRIPT1export 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
:
TYPESCRIPT1import 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
TYPESCRIPT1import { 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
TYPESCRIPT1import 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:
Bashnpm 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.