Docs

Syndication

Automated cross-posting to Hashnode and Dev.to with canonical URLs.

Publish once, syndicate everywhere
The syndication system automatically publishes blog posts to external platforms (Hashnode and Dev.to) after deployment while maintaining canonical URLs that point back to this site. Posts are tracked to prevent duplicates, and errors never block the main deployment.
Hashnode Dev.to Fault tolerant Pluggable

Key features

  • Frontmatter control
    Each post can opt in or out via syndicate: true/false.
  • Tag and category filters
    Configure which posts to syndicate based on tags and categories.
  • State tracking
    Publication URLs and timestamps stored in .syndication-state.json.
  • Dry run mode
    Test syndication logic without actually publishing content.

Setup

Get API keys

Hashnode:

  1. Navigate to Hashnode → Settings → Developer
  2. Generate a Personal Access Token
  3. Find your Publication ID in your blog's settings

Dev.to:

  1. Navigate to Dev.to → Settings → Account → API Keys
  2. Generate a new API key

Add secrets to GitHub

  1. Go to repository Settings → Secrets and variables → Actions
  2. Add the following secrets:
    • HASHNODE_API_TOKEN: Your Hashnode personal access token
    • DEVTO_API_KEY: Your Dev.to API key

Configure platforms

Edit .syndication.config.json in the repository root:

{
  "platforms": {
    "hashnode": {
      "enabled": true,
      "publicationId": "YOUR_HASHNODE_PUBLICATION_ID",
      "supportsBackdating": true
    },
    "devto": {
      "enabled": true,
      "supportsBackdating": false
    }
  },
  "filters": {
    "includedTags": [],
    "excludedTags": ["draft", "private"],
    "includedCategories": [
      "Architecture",
      "Programming",
      "Programming/Architecture",
      "Programming/Automation",
      "Programming/Databases",
      "Programming/Fun",
      "Programming/Tooling",
      "Software Engineering"
    ],
    "excludedCategories": ["Personal", "Personal/Travel", "Private"]
  },
  "defaults": {
    "syndicateByDefault": true,
    "canonicalUrlBase": "https://jerrettdavis.com"
  }
}

Configuration

Platform settings

Each platform in the platforms object supports:

  • enabled: Whether syndication is active for this platform
  • publicationId: Platform-specific publication identifier (Hashnode only)
  • supportsBackdating: Whether to preserve original publication dates

Filter logic

Syndication filters determine post eligibility:

  1. Explicit frontmatter override: syndicate: true always syndicates, syndicate: false never syndicates
  2. Excluded tags/categories: Posts with these are skipped
  3. Included tags/categories: If defined, posts must match at least one
  4. Default behavior: Respects syndicateByDefault setting when no override exists

Example filter configuration:

{
  "filters": {
    "includedTags": ["javascript", "webdev"],
    "excludedTags": ["draft", "private"],
    "includedCategories": ["Programming", "Software Engineering", "Architecture"],
    "excludedCategories": ["Personal", "Personal/Travel"]
  }
}

Frontmatter control

Posts can explicitly opt in or out:

---
title: 'My Post'
syndicate: false  # Prevents syndication regardless of filters
---

When omitted, configuration filters determine eligibility.

How it works

Workflow trigger

  • 01. Push to main
    Changes to posts/** trigger the workflow.
  • 02. Main deployment
    Vercel builds and deploys the site normally.
  • 03. Syndication runs
    GitHub Actions executes the syndication script.
  • 04. State updated
    Publication data committed back to repository.

Publication process

For each eligible post:

  1. Check if already published (via state file)
  2. Validate against filters and frontmatter
  3. Prepare content with canonical URL notice
  4. Call platform API (GraphQL for Hashnode, REST for Dev.to)
  5. Record publication URL and timestamp
  6. Commit state updates to repository

State tracking

.syndication-state.json maintains publication history:

{
  "posts": {
    "my-post-slug": {
      "hashnode": {
        "id": "platform-post-id",
        "url": "https://hashnode.com/...",
        "publishedAt": "2024-01-01T00:00:00Z",
        "lastUpdated": "2024-01-01T00:00:00Z"
      },
      "devto": {
        "id": "12345",
        "url": "https://dev.to/...",
        "publishedAt": "2024-01-01T00:00:00Z",
        "lastUpdated": "2024-01-01T00:00:00Z"
      }
    }
  }
}

Usage

Command line

# Test without publishing
npm run syndicate:dry-run

# Publish all eligible posts
npm run syndicate

# Force re-publish already published posts
npm run syndicate -- --force

# Publish a specific post
npm run syndicate -- --post=my-post-slug

Manual workflow trigger

  1. Go to Actions tab in GitHub
  2. Select "Syndicate Posts" workflow
  3. Click "Run workflow"
  4. Configure options:
    • Dry run: Test without publishing
    • Force: Re-publish existing posts
    • Post ID: Syndicate only specific post

Automatic syndication

The workflow runs automatically when:

  • You push changes to posts/** or .syndication.config.json
  • Changes are merged to the main branch

Troubleshooting

Posts not syndicating

Check eligibility in order:

  1. Run dry-run to see which posts would be published:

    npm run syndicate:dry-run
    
  2. Verify post has no syndicate: false in frontmatter

  3. Check if post tags/categories are excluded in config

  4. Confirm post isn't already in .syndication-state.json

    • Use --force flag to re-publish
  5. Review workflow logs in GitHub Actions

API errors

Hashnode errors:

  • Invalid token: Verify HASHNODE_API_TOKEN secret is correct
  • Publication not found: Check publicationId in config
  • Rate limit exceeded: Wait and retry later

Dev.to errors:

  • Unauthorized: Verify DEVTO_API_KEY secret is correct
  • Too many tags: Dev.to allows max 4 tags (automatically limited)
  • Duplicate article: Post may already exist on platform

Workflow failures

If syndication fails:

  1. Check error message in workflow logs
  2. Deployment proceeds normally (syndication errors don't block)
  3. An issue is created automatically with troubleshooting steps
  4. Retry manually from Actions tab or with next push

Architecture

Components

Configuration
Syndication script
State tracking
GitHub Actions

Configuration (.syndication.config.json):

  • Platform settings and API endpoints
  • Tag and category filters
  • Default syndication behavior

Syndication script (scripts/syndication/syndicate.mjs):

  • Loads posts and applies filtering logic
  • Integrates with Hashnode GraphQL API
  • Integrates with Dev.to REST API
  • Handles errors and logging

State tracking (.syndication-state.json):

  • Records publication URLs and timestamps
  • Prevents duplicate syndication
  • Version-controlled with repository

GitHub Actions workflow (.github/workflows/syndicate.yml):

  • Triggers on posts changes or manual dispatch
  • Runs after main deployment
  • Commits state updates back to repo
  • Creates issues on failure

Design principles

The syndication system follows these core principles:

Separation of concerns:

  • Configuration in JSON files
  • Business logic in syndication script
  • Orchestration in GitHub Actions
  • No syndication code in main site

Fault tolerance:

  • Syndication errors don't block deployment
  • Safe to re-run (idempotent)
  • State tracked in version control

Pluggable:

  • Easy to add new platforms
  • Platform-specific logic isolated
  • Settings centralized in config

Transparent:

  • Clear logging at each step
  • State visible in repository
  • Workflow status in GitHub UI

Adding new platforms

To add support for a new platform:

  1. Update schemas/syndication-config.schema.json with platform schema
  2. Add platform configuration to .syndication.config.json
  3. Implement publishing function in scripts/syndication/syndicate.mjs
  4. Add platform case to main syndication loop
  5. Update workflow to pass API secret
  6. Test with dry-run mode

Example structure for new platform:

async function publishToMedium(post, config, state) {
  const platformConfig = config.platforms.medium;
  if (!platformConfig.enabled) {
    return { skipped: true, reason: 'Platform disabled' };
  }
  
  const existingState = state.posts[post.id]?.medium;
  if (existingState && !isForce) {
    return { skipped: true, reason: 'Already published' };
  }
  
  if (isDryRun) {
    return { skipped: true, reason: 'Dry run' };
  }
  
  // API integration logic here
  
  return {
    success: true,
    id: result.id,
    url: result.url,
    publishedAt: result.publishedAt,
    lastUpdated: new Date().toISOString()
  };
}

Security

API keys and canonical URLs
API keys are stored as GitHub Secrets and never committed to the repository. The state file contains only public URLs. Canonical URLs prevent duplicate content penalties by identifying this site as the original source.
  • API keys stored as GitHub Secrets (never in code)
  • Scripts execute in isolated GitHub Actions environment
  • State file contains no secrets (only public URLs)
  • Canonical URLs prevent SEO penalties
  • CodeQL scans detect vulnerabilities automatically

Best practices

  1. Test with dry-run first before enabling live syndication
  2. Start with one post to validate API integration
  3. Review state file after initial syndication
  4. Monitor workflow runs in GitHub Actions
  5. Keep configuration simple until you need advanced filters
  6. Document platform quirks as you discover them