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.