DEV Community

Cover image for How to Pull Your Hashnode Blog Into Your Portfolio (No Paid API Needed) πŸš€
Hosein Mahmoudi
Hosein Mahmoudi

Posted on

How to Pull Your Hashnode Blog Into Your Portfolio (No Paid API Needed) πŸš€

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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

  1. πŸ†“ Every Hashnode publication has a free /rss.xml β€” no plan required.
  2. 🧡 Parse it, unwrap CDATA, derive read time yourself.
  3. 🧱 Isolate fetching/parsing behind one function so your app doesn't care about the source.
  4. πŸ–₯️ Render server-side (or prefetch + hydrate) so crawlers see real content β€” that's the actual SEO lever.
  5. βž• 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)