DEV Community

Sebastian Casvean
Sebastian Casvean

Posted on • Originally published at zenndra.com

Sync Your Medium Portfolio to a Static Site Automatically

Sync Your Medium Portfolio to a Static Site Automatically

Hiring managers Google you and compare your domain to your Medium profile. When they diverge, you look inactive—even if you shipped twelve essays last quarter.

This is a small automation tool: resolve your handle → list articles → write Markdown into git → deploy.


Medium is distribution; your site is proof

Medium optimizes reach. Your portfolio optimizes narrative: order, categories, case studies beside a contact form.

Static generators (Hugo, Astro, Eleventy) love files in git. Treat Medium as an upstream feed—like RSS used to work.


The automation pattern

  1. Resolve @username → stable user_id (guide).
  2. GET /user/{user_id}/articles on a schedule.
  3. For each new article_id, GET /article/{id}/markdown.
  4. Write content/writing/{slug}.md with front matter including article_id (idempotent rebuilds).
  5. CI builds and deploys.

Run nightly or on deploy—nightly is enough for most portfolios.


GitHub Actions sketch

# .github/workflows/sync-medium.yml
name: Sync Medium writing
on:
  schedule: [{ cron: '0 6 * * *' }]
  workflow_dispatch:
jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: node scripts/sync-medium-portfolio.mjs
        env:
          ZENNDRA_API_KEY: ${{ secrets.ZENNDRA_API_KEY }}
          MEDIUM_USERNAME: ${{ vars.MEDIUM_USERNAME }}
      - uses: stefanzweifel/git-auto-commit-action@v5
        with:
          commit_message: 'chore: sync Medium posts'
Enter fullscreen mode Exit fullscreen mode

sync-medium-portfolio.mjs (core logic)

import fs from 'node:fs/promises';
import path from 'node:path';

const API = 'https://api.zenndra.com';
const headers = { Authorization: `Bearer ${process.env.ZENNDRA_API_KEY}` };
const handle = process.env.MEDIUM_USERNAME;

const idRes = await fetch(`${API}/user/id_for/${handle}`, { headers });
const { user_id } = await idRes.json();

const listRes = await fetch(`${API}/user/${user_id}/articles`, { headers });
const { articles } = await listRes.json();

for (const a of articles) {
  const outPath = path.join('content/writing', `${a.id}.md`);
  try {
    await fs.access(outPath);
    continue; // already synced
  } catch {}

  const mdRes = await fetch(`${API}/article/${a.id}/markdown`, { headers });
  const { markdown } = await mdRes.json();

  const frontMatter = `---
title: "${a.title.replace(/"/g, '\\"')}"
date: ${a.published_at ?? new Date().toISOString()}
medium_id: ${a.id}
canonical: ${a.url}
---
`;
  await fs.writeFile(outPath, frontMatter + '\n' + markdown);
}
Enter fullscreen mode Exit fullscreen mode

Tune paths for your generator. Add reading time from /article/{id} metadata when you want a premium layout.


SEO note

Pick one canonical home early:

  • Medium canonical + on-site teaser, or
  • Your domain canonical + Medium as syndication.

Document the choice; flip when analytics justify redirects.


Keywords

sync medium to static site, medium portfolio automation, medium markdown export, hugo medium sync, developer portfolio blog.


Further reading

Top comments (0)