So you write on Hashnode, and you want those posts to also show up on your own portfolio site β a unified /blog section, some SEO juice, whatever your reason. π
The obvious first move used to be Hashnode's GraphQL API. But as of 2026, that's now gated behind a Pro plan on the publication. πΈ
Good news: you don't actually need it. Every Hashnode publication ships a free, public RSS feed, and that's more than enough to build a real blog section. Let's build it. π
https://<your-subdomain>.hashnode.dev/rss.xml
That single URL gives you title, slug, publish date, tags, cover image, and full HTML content for every post. No API key. No plan upgrade. No rate limit drama.
π€ Why RSS instead of the API?
- It's free, and always has been. RSS is a publishing feature baked into every blog, not a paywalled product.
- It's boringly stable. RSS has been the same format for over 20 years. It's not getting deprecated on you next quarter.
- It's enough. A portfolio "here's what I've been writing" section doesn't need GraphQL's query flexibility β it needs a list of posts and their content, which RSS already hands you.
The one thing RSS doesn't give you: a computed "read time." You'll estimate that yourself in a few lines (see below). β±οΈ
π§© Step 1 β Fetch and parse the feed
Grab any XML parser β fast-xml-parser is a solid, dependency-light pick for JS/TS. The one gotcha: RSS wraps text fields in <![CDATA[...]]>, so you'll need to unwrap that to get plain strings back.
import { XMLParser } from "fast-xml-parser";
const RSS_URL = "https://your-subdomain.hashnode.dev/rss.xml";
async function fetchPosts() {
const res = await fetch(RSS_URL, { cache: "no-store" }); // always fresh π
const xml = await res.text();
const parser = new XMLParser({
ignoreAttributes: false,
cdataPropName: "__cdata",
isArray: (tag) => tag === "item" || tag === "category",
});
const items = parser.parse(xml)?.rss?.channel?.item ?? [];
return items.map((item: any) => ({
title: unwrap(item.title),
slug: new URL(item.link).pathname.replace(/^\/+|\/+$/g, ""),
publishedAt: new Date(item.pubDate).toISOString(),
brief: unwrap(item.description),
coverImage: item.enclosure?.["@_url"] ?? null,
tags: (item.category ?? []).map(unwrap),
html: unwrap(item["content:encoded"]),
}));
}
function unwrap(value: any): string {
if (value == null) return "";
if (typeof value === "string") return value;
return value.__cdata ?? String(value);
}
That's it β you now have an array of fully-typed post objects. β
β±οΈ Step 2 β Estimate read time yourself
RSS doesn't include it, so derive it from word count in the HTML body:
function estimateReadTime(html: string, wpm = 200): number {
const words = html
.replace(/<[^>]*>/g, " ")
.trim()
.split(/\s+/)
.filter(Boolean).length;
return Math.max(1, Math.round(words / wpm));
}
Simple, good enough, nobody's going to fact-check your read-time estimate. π
π§± Step 3 β Hide the plumbing behind a clean function
Don't let "we're scraping RSS" leak into your components. Wrap the fetch behind a domain-sounding function so the rest of your app just thinks in terms of "blog posts," not "XML feed":
// blog.service.ts
export async function getBlogPosts(limit = 10) {
const posts = await fetchPosts();
return posts.slice(0, limit);
}
If you ever switch platforms β dev.to, a headless CMS, self-hosted MDX β this is the only layer that changes. Everything downstream (hooks, components, pages) stays untouched. π
π Step 4 β Render it server-side (this is the part that matters for SEO)
Here's the step people skip, and it's the one that actually moves the needle.
If you fetch posts in a useEffect and render client-side only, crawlers may see an empty shell on first load β no title, no content, nothing to index. Fetch on the server instead, and put the real HTML in the initial response.
In Next.js (App Router), that means fetching inside a Server Component and rendering the markup server-side. If you're using React Query for client-side interactivity too, prefetch server-side and hydrate on the client β same principle, just with a cache layer on top.
The property you're after: "View Source" shows your actual post titles and content, not a loading spinner. π·οΈβ
That's the real SEO win here β not the RSS trick itself, but making sure fetched content lands in HTML a crawler can actually read.
π Bonus: cross-posting to dev.to
dev.to makes this even easier β a free, public REST API, no auth required for reads:
GET https://dev.to/api/articles?username=your_username
Straight JSON, zero XML parsing. One catch: this list endpoint only returns summaries (title, tags, dates, etc.) β no body_html. For full content you need a second call per post:
GET https://dev.to/api/articles/{id}
That one includes body_html and body_markdown. So unlike Hashnode's RSS feed (which hands you everything, list + full content, in a single request), dev.to needs one list call plus one detail call per article. Fine for a portfolio-sized post count, just something to budget for if you're fetching a lot of posts.
If you publish to both platforms, merge both sources inside the same getBlogPosts() β just normalize each shape into one common { title, slug, publishedAt, html, ... } type before returning it.
β οΈ SEO gotcha: if you cross-post the same article to multiple platforms, set a canonical URL on the copies pointing back to the original. Otherwise search engines see duplicate content and split ranking between them instead of boosting one clear winner.
π Recap
- π Every Hashnode publication has a free
/rss.xmlβ no plan required. - π§΅ Parse it, unwrap CDATA, derive read time yourself.
- π§± Isolate fetching/parsing behind one function so your app doesn't care about the source.
- π₯οΈ Render server-side (or prefetch + hydrate) so crawlers see real content β that's the actual SEO lever.
- β dev.to's API is even more open if you want a second source, or cross-post with a canonical tag pointing home.
If you write on Hashnode and have a portfolio sitting there with a dead /blog link β this is a weekend-sized fix. Go ship it. π’
Got a different setup (Gatsby, Astro, plain HTML)? The same three-layer idea β fetch/parse β normalize β render server-side β works regardless of framework. Drop a comment if you get stuck! π¬
Top comments (0)