Hashnode Integration
Blog content integration with Hashnode GraphQL API
Hashnode Integration
Portfolio OS integrates with Hashnode to source blog content via their GraphQL API.
Overview
Hashnode provides a GraphQL API to fetch blog posts, allowing Portfolio OS to use Hashnode as a headless CMS while maintaining full control over the frontend.
Configuration
Environment Variables
# apps/site/.env.local
NEXT_PUBLIC_HASHNODE_PUBLICATION_HOST=yourblog.hashnode.dev
Client Setup
// packages/hashnode/src/client.ts
const HASHNODE_API_URL = 'https://gql.hashnode.com'
export async function hashnodeQuery(query: string, variables: any = {}) {
const response = await fetch(HASHNODE_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query,
variables,
}),
})
if (!response.ok) {
throw new Error(`Hashnode API error: ${response.statusText}`)
}
return response.json()
}
GraphQL Queries
Get All Posts
query GetAllPosts($host: String!) {
publication(host: $host) {
posts(first: 20) {
edges {
node {
id
title
slug
brief
content {
html
markdown
}
coverImage {
url
}
publishedAt
author {
name
profilePicture
}
tags {
name
slug
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
Usage:
import { hashnodeQuery } from '@mindware-blog/hashnode'
const GET_ALL_POSTS = `...` // Query above
export async function getAllPosts() {
const data = await hashnodeQuery(GET_ALL_POSTS, {
host: process.env.NEXT_PUBLIC_HASHNODE_PUBLICATION_HOST
})
return data.data.publication.posts.edges.map(edge => edge.node)
}
Get Single Post
query GetPost($host: String!, $slug: String!) {
publication(host: $host) {
post(slug: $slug) {
id
title
slug
brief
content {
html
markdown
}
coverImage {
url
}
publishedAt
readTimeInMinutes
author {
name
username
profilePicture
bio {
text
}
}
tags {
name
slug
}
seo {
title
description
}
}
}
}
Usage:
export async function getPostBySlug(slug: string) {
const data = await hashnodeQuery(GET_POST, {
host: process.env.NEXT_PUBLIC_HASHNODE_PUBLICATION_HOST,
slug
})
return data.data.publication.post
}
Caching Strategy
Cache Hashnode responses to reduce API calls:
import { getCached } from '@mindware-blog/lib/cache'
export async function getAllPostsCached() {
return getCached(
'hashnode:posts:all',
() => getAllPosts(),
3600 // 1 hour TTL
)
}
export async function getPostBySlugCached(slug: string) {
return getCached(
`hashnode:post:${slug}`,
() => getPostBySlug(slug),
7200 // 2 hour TTL
)
}
Type Definitions
// packages/hashnode/src/types.ts
export interface HashnodePost {
id: string
title: string
slug: string
brief: string
content: {
html: string
markdown: string
}
coverImage?: {
url: string
}
publishedAt: string
readTimeInMinutes: number
author: {
name: string
username: string
profilePicture?: string
bio?: {
text: string
}
}
tags: Array<{
name: string
slug: string
}>
seo?: {
title: string
description: string
}
}
export interface HashnodePublication {
posts: {
edges: Array<{
node: HashnodePost
}>
pageInfo: {
hasNextPage: boolean
endCursor: string
}
}
}
Usage in Pages
Blog Listing
// apps/site/app/blog/page.tsx
import { getAllPostsCached } from '@mindware-blog/hashnode'
export default async function BlogPage() {
const posts = await getAllPostsCached()
return (
<div>
<h1>Blog</h1>
<div className="grid gap-6">
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
</div>
)
}
Single Post
// apps/site/app/blog/[slug]/page.tsx
import { getPostBySlugCached } from '@mindware-blog/hashnode'
import { notFound } from 'next/navigation'
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await getPostBySlugCached(params.slug)
if (!post) {
notFound()
}
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content.html }} />
</article>
)
}
Webhooks
Set up Hashnode webhooks to invalidate cache on new posts:
// apps/site/app/api/webhooks/hashnode/route.ts
import { revalidatePath } from 'next/cache'
import { redis } from '@mindware-blog/lib/cache'
export async function POST(request: Request) {
const signature = request.headers.get('x-hashnode-signature')
// Verify webhook signature
if (!verifySignature(signature)) {
return new Response('Invalid signature', { status: 401 })
}
const body = await request.json()
// Invalidate cache
await redis.del('hashnode:posts:all')
revalidatePath('/blog')
return new Response('OK')
}
Error Handling
export async function getAllPosts() {
try {
const data = await hashnodeQuery(GET_ALL_POSTS, {
host: process.env.NEXT_PUBLIC_HASHNODE_PUBLICATION_HOST
})
if (!data.data?.publication) {
throw new Error('Publication not found')
}
return data.data.publication.posts.edges.map(edge => edge.node)
} catch (error) {
console.error('Failed to fetch posts from Hashnode:', error)
return [] // Return empty array as fallback
}
}
Pagination
export async function getPaginatedPosts(cursor?: string, limit: number = 10) {
const PAGINATED_QUERY = `
query GetPosts($host: String!, $first: Int!, $after: String) {
publication(host: $host) {
posts(first: $first, after: $after) {
edges {
node { ... }
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`
const data = await hashnodeQuery(PAGINATED_QUERY, {
host: process.env.NEXT_PUBLIC_HASHNODE_PUBLICATION_HOST,
first: limit,
after: cursor
})
return {
posts: data.data.publication.posts.edges.map(edge => edge.node),
pageInfo: data.data.publication.posts.pageInfo
}
}
Testing
// __tests__/hashnode/client.test.ts
import { getAllPosts, getPostBySlug } from '@mindware-blog/hashnode'
describe('Hashnode Client', () => {
it('fetches all posts', async () => {
const posts = await getAllPosts()
expect(Array.isArray(posts)).toBe(true)
expect(posts[0]).toHaveProperty('title')
})
it('fetches post by slug', async () => {
const post = await getPostBySlug('example-post')
expect(post).toHaveProperty('title')
expect(post).toHaveProperty('content')
})
})
Next Steps
- REST API - Internal API routes
- GraphQL - GraphQL implementation
- Setup Guide - Cache configuration