DEV Community

sunah kim
sunah kim

Posted on Originally published at zenn.dev

Building a Multilingual Blog Pipeline That Auto-Posts to Zenn, dev.to, and velog

Why I built this

As a Korean engineer working in Japan, I've long wanted to publish technical articles in three languages: Japanese on Zenn, English on dev.to, and Korean on velog. Manually copy-pasting every article into three platforms was never going to happen, so I built a pipeline with a single GitHub repository as the hub — push once, and the article goes out to all three platforms automatically.

I expected Zenn to be the easy part and got tripped up anyway, and velog turned out to have no official API at all. Here are the pitfalls I actually hit and how I solved them.

Overall architecture

The repo layout is simple — one directory per platform:

.
├── articles/   # Zenn (auto-deployed via GitHub integration)
├── devto/      # dev.to (GitHub Actions + official API)
├── velog/      # velog (GitHub Actions + GraphQL)
└── material.md # topic backlog
Enter fullscreen mode Exit fullscreen mode

When I push an article, three things happen:

  1. Zenn: the official GitHub integration detects changes under articles/ and deploys them
  2. dev.to: a GitHub Actions workflow posts files under devto/ through the official REST API
  3. velog: a workflow posts files under velog/ through velog's internal GraphQL API

Pitfall 1: Zenn's GitHub integration must be started from the Zenn side

My first stumble was Zenn itself. If you install the Zenn Connect app from the GitHub Marketplace side first, the integration never shows up in Zenn's dashboard (zenn.dev/dashboard/deploys).

The cause is the direction of the flow. Zenn's integration is designed so that the OAuth authorization and repository selection happen from Zenn's settings screen. Installing only the GitHub App from the GitHub side never creates the link to your Zenn account. Zenn's docs even say explicitly: if the app is already installed and you see this screen, uninstall it first and reconnect.

The fix was simple: uninstall the app on the GitHub side, then redo the integration from the "Connect repository" (リポジトリを連携する) button in Zenn's settings.

One more gotcha: commits pushed before the integration are never deployed. Only pushes after the connection is established get picked up, so if you have existing articles, you need a fresh commit after connecting. An empty commit works fine:

git commit --allow-empty -m "trigger zenn deploy"
git push
Enter fullscreen mode Exit fullscreen mode

That kicked off the first deploy and my articles appeared in the dashboard. A nice bonus: private repositories work too, so your drafts and commit history stay private while only published articles go public.

Pitfall 2: dev.to wants the front matter inside the body

dev.to was the pleasant one, since there's an official API. The key point is that instead of sending title and tags as separate fields, you send the entire Markdown including front matter as body_markdown:

curl -X POST "https://dev.to/api/articles" \
  -H "api-key: ${DEVTO_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"article": {"body_markdown": "---\ntitle: ...\npublished: true\ntags: ...\ncanonical_url: https://zenn.dev/...\n---\n\nBody here"}}'
Enter fullscreen mode Exit fullscreen mode

The important field is canonical_url. Publishing the same content on multiple sites risks being treated as duplicate content by search engines, but pointing canonical_url at the Zenn original declares "this is the canonical source." Also, published: false lets you post as a draft, so it's safest to test the pipeline with drafts first.

Pitfall 3: velog has no official API

velog was the hard one. There is no official API, so I ended up calling the internal GraphQL endpoint used by the web frontend (v3.velog.io/graphql), authenticated with the login session cookies (access_token / refresh_token).

Then I hit a baffling behavior: if you pass null to the meta field of the writePost mutation, you get no error — just a null result. With no error message, I burned a lot of time figuring this out. The answer: pass an empty object {} instead.

mutation {
  writePost(input: {
    title: "...",
    body: "...",
    tags: ["..."],
    is_temp: false,
    meta: {}        # null fails silently
  }) { id url_slug }
}
Enter fullscreen mode Exit fullscreen mode

Token handling has its quirks too. The access_token is short-lived, but if you send the expired one together with the refresh_token, fresh tokens come back via Set-Cookie. The workflow is supposed to pick those up and update the secrets — but the refresh token itself expires after roughly 30 days, so you have to manually re-grab the cookies about once a month. That's the operational weak point. For testing, is_temp: true saves the post as a draft, which is what I used during verification.

Operational details

One small but important detail: the Actions workflow only processes newly added files. Otherwise, every typo-fix push would re-post the same article:

git diff --name-only --diff-filter=A HEAD^ HEAD -- 'devto/*.md'
Enter fullscreen mode Exit fullscreen mode

All API keys and cookies live in GitHub Actions Repository Secrets, injected as environment variables in the workflow. Never commit them in plaintext — and be careful not to echo them into logs either.

Summary

  • Start Zenn's GitHub integration from the Zenn side ("Connect repository" button). Pre-integration commits never deploy, so push an empty commit after connecting
  • For dev.to, put the front matter inside body_markdown and use canonical_url to avoid duplicate-content issues
  • velog requires the unofficial GraphQL API; pass {} (not null) as meta. Token expiry is the operational weak point
  • Process only newly added files (--diff-filter=A) to prevent double-posting

One last thing: the writing and translation side of this pipeline is handled by a scheduled AI agent (Claude) task. Every night it reads the topic backlog (material.md), writes an article, generates all three language versions, and pushes them. I'll cover that part in detail in a future post.

Top comments (0)