Skip to content
Development

Hashnode cross-posting in the blog manager

By Victor Da Luz
railsrubyhashnodegraphqldev-logblog-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:

  1. Import to Hashnode → calls createDraft, stores the draft ID and URL, flips status to :draft. A “View draft →” link appears in the UI.
  2. Review in the Hashnode editor: check that the cover image rendered, canonical URL is set, tags look right.
  3. Publish: either click “Publish” in the blog manager UI (which calls publishDraft synchronously) 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 Encryption
  • hashnode_publication_id: a UUID used in mutations (creating/publishing drafts)
  • hashnode_publication_host: a hostname like username.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