Hashnode cross-posting in the blog manager
I’ve been slowly automating cross-posting for my blog. Medium was first, then Dev.to. Hashnode is the third target, and it turned out to be the most interesting to implement because it uses GraphQL instead of REST.
Why Hashnode
Dev.to has a larger raw audience, but Hashnode skews more toward engineering. Posts with code tend to get better engagement there. It also supports custom domains, which means readers land on username.hashnode.dev but the canonical URL still points back to my site; exactly what I want for SEO.
The auth model is simple: a Personal Access Token from hashnode.com/settings/developer, passed as an Authorization header on mutations. Queries (for syncing published state) are public; no auth needed at all.
No GraphQL gem
The blog manager already has a pattern for HTTP clients with injectable connections for testing: Medium::Client and Devto::Client both take a connection: Proc in their constructors. Tests pass a lambda that returns a canned response. No WebMock, no VCR.
GraphQL over HTTP is just a POST with a JSON body containing {query:, variables:}. So I kept the same pattern rather than pulling in graphlient or graphql-client:
def graphql(query, variables, auth: true)
uri = URI.parse("https://gql.hashnode.com/")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = @token if auth
req.body = { query: query, variables: variables }.to_json
connect = @connection || Net::HTTP.method(:start)
res = connect.call(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
handle_response(res)
end
The auth: flag matters here. Mutations like createDraft need the token, but the list query for syncing is public. Passing auth: false omits the header.
The two-step publish flow
Hashnode’s createDraft mutation creates a draft. You can’t publish directly via API in the same call; it’s a separate publishDraft mutation. That actually fits this tool’s workflow well:
- Import to Hashnode → calls
createDraft, stores the draft ID and URL, flips status to:draft. A “View draft →” link appears in the UI. - Review in the Hashnode editor: check that the cover image rendered, canonical URL is set, tags look right.
- Publish: either click “Publish” in the blog manager UI (which calls
publishDraftsynchronously) or publish from the Hashnode editor and let the daily sync job pick it up.
Sync by slug, not ID
This was the one non-obvious design choice. The sync job calls publication(host:).posts to get the list of published posts, then matches against local drafts. I match by slug, not draft ID.
Why? The list API returns published posts. Once a draft is published, the relationship between the draft ID and the post is unclear in the API response. Slugs are stable; they don’t change between draft creation and publish. So:
remote_slugs = remote_posts.map { |p| p["slug"] }.to_set
@blog.posts
.where(hashnode_status: Post.hashnode_statuses[:draft])
.where(slug: remote_slugs.to_a)
.each { |post| post.update!(hashnode_status: :published, ...) }
Simple. No cross-referencing IDs between mutations and queries.
Three columns on Blog, not one
Medium needs two fields: token + author ID. Dev.to needs one: API key. Hashnode needs three:
hashnode_token: the PAT, encrypted via Active Record Encryptionhashnode_publication_id: a UUID used in mutations (creating/publishing drafts)hashnode_publication_host: a hostname likeusername.hashnode.dev, used for the public list query
The split exists because the two API operations use different identifiers. Mutations need the opaque publication UUID. The public publication(host:) query uses the hostname. You can’t use one in place of the other.
LinkedIn announce still fires once
The Syndication::LinkedInAnnounce service runs after each sync job; Medium, Dev.to, and Hashnode all call it. It finds posts that are published on any platform but not yet announced on LinkedIn. The eligibility query uses .or():
@blog.posts
.where(medium_status: :published)
.or(posts.where(devto_status: :published))
.or(posts.where(hashnode_status: :published))
.select { |p| p.linkedin_not_posted? || (p.linkedin_failed? && p.linkedin_attempts < 3) }
A post published on all three platforms in the same day is only in this set once; DISTINCT handles it at the SQL level. So LinkedIn gets one announcement, not three.
What I’d change
The three-column approach for Hashnode credentials is slightly awkward to explain in the UI. A tooltip would help. I also left out any publication discovery; you have to look up your publication ID manually. A GET /users/me equivalent would let the UI fetch it on token-save, but that’s a nice-to-have.
The next syndication target on my list is newsletter (Substack or ConvertKit). That’ll probably mean rethinking the per-post row UI. It’s getting wide.
Related reading
Deleting a publishing integration I just built
Removing the never-used native Hashnode integration: 31 files, +14/-1126, a grep that lied a little, and the architecture question the deletion forced.
The normalization bug that only shows up on tags made of nothing
A strip-based normalizer meets an all-punctuation tag: empty string as a hash key, wrong-tag substitution, and an autocomplete that matches everything. Three symptoms, one root cause.
The same button choice cost me a bigger bug than expected
Embedding the hero flow in the editor looked like the smaller option - until 'replace' met 166 real files that had never gone through the insertion-only path, and a migration with no backfill.