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
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.