When I added Hashnode to my cross-publish pipeline after Dev.to was already working, I expected it to be a similar REST API with a different base URL. Three behaviors were specific enough to document.
1. GraphQL, not REST
Dev.to uses a straightforward REST API: POST /api/articles with a JSON body containing title, body_markdown, tags, and a handful of optional fields. The response is a JSON object with the published article's id and url. HTTP status codes map to outcomes the way you'd expect — 200 for success, 422 for invalid content, 429 for rate limits.
Hashnode uses a GraphQL endpoint at https://gql.hashnode.com/. A publish looks like:
await gql(token,
`mutation Pub($input: PublishPostInput!) {
publishPost(input: $input) { post { id url slug } }
}`,
{ input }
);
The critical difference for error handling: GraphQL can return HTTP 200 with errors: [{ message: "..." }] in the response body. Checking res.ok is not enough — you have to inspect json.errors after every response. My gql() wrapper always checks:
if (json.errors?.length) {
throw new Error(`Hashnode GQL: ${json.errors.map(e => e.message).join("; ")}`);
}
Without this, a validation error on a malformed tag or missing field surfaces as a silent success — the publisher marks the article as published, no URL comes back, and the next run's existing check skips it forever.
2. HTML error pages for certain failures
When a request to Hashnode fails at the infrastructure level — an expired or invalid token, a Cloudflare edge block, or a 5xx — the response body is sometimes an HTML error page, not JSON. Calling res.json() directly throws "Unexpected token '<'" with no information about what actually happened.
The fix is to read as text first, then parse:
const text = await res.text();
let json;
try {
json = JSON.parse(text);
} catch {
throw new SystemicError(
`Hashnode non-JSON response (HTTP ${res.status}): ${text.slice(0, 120).replace(/\s+/g, " ")}` +
" — likely an invalid/expired HASHNODE_TOKEN or an API error page"
);
}
I ran into this after my Hashnode token expired. The publisher was throwing a cryptic parse error on every Hashnode attempt, and I didn't know whether the token was bad or the article body was malformed. Reading as text first surfaces the HTTP status and the first 120 characters of the HTML, which is usually enough to identify the cause. I also throw a SystemicError here so the publisher circuit-breaks Hashnode for the rest of the run instead of retrying it on every article — the same token failure will reproduce identically regardless of which article triggers it.
3. Publication ID lookup
On Dev.to, the authenticated user has one implicit blog. You post to your own account and there's no blog-level ID to pass.
Hashnode organizes posts under "publications," and a publication has an ID that's separate from your blog URL slug. You can't pass your blog URL and have it work as an ID. Instead, you have to query for it:
const data = await gql(token,
`query { me { publications(first: 5) { edges { node { id title url } } } } }`
);
const edge = data.me.publications.edges[0];
return edge.node.id; // something like "64b7c3d8e3a1234567890abc"
This is an extra round-trip on every publish run. To avoid it, I accept a HASHNODE_PUBLICATION_ID env variable as an optional hint:
async function resolvePublicationId(token, hint) {
if (hint) return hint;
// ... query falls through
}
With the env set, the lookup is skipped entirely. I set it once, it doesn't change unless you delete and recreate the publication, and it saves ~200ms per publish run. Without it, the pipeline still works — the query succeeds and returns the first publication on the account.
Bonus: tags format
This one didn't break anything but required attention. Dev.to tags are plain lowercase alphanumeric strings — ["typescript", "webdev", "indiehackers"]. Hashnode tags are objects:
function tagInputs(tags) {
return tags.slice(0, 5).map(t => ({
slug: t.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, ""),
name: t,
}));
}
Dev.to sanitization strips hyphens; Hashnode slugs allow them. "github-actions" is a valid Hashnode tag slug but would become "githubactions" on Dev.to. Both platforms cap at 5 tags, so the slice is the same.
What this costs in wrapper code
These three differences add about 50 lines of wrapper code around the Hashnode calls. Once written, it's transparent to the rest of the pipeline — the publisher calls publishToHashnode(article, token, hint) and gets back a URL or an exception, same as any other platform. The wrappers handle the GraphQL layer, the text-first parsing, and the publication ID resolution.
The upside of running Hashnode second in the ordering chain is that it automatically picks up the Dev.to URL for originalArticleURL without any extra orchestration — the canonical chain just works once the wrappers are solid.
Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.
Top comments (0)