Docs
Syndication
Automated cross-posting to Hashnode and Dev.to with canonical URLs.
Key features
-
Frontmatter control
-
Tag and category filters
-
State tracking
-
Dry run mode
Setup
Get API keys
Hashnode:
- Navigate to Hashnode → Settings → Developer
- Generate a Personal Access Token
- Find your Publication ID in your blog's settings
Dev.to:
- Navigate to Dev.to → Settings → Account → API Keys
- Generate a new API key
Add secrets to GitHub
- Go to repository Settings → Secrets and variables → Actions
- Add the following secrets:
HASHNODE_API_TOKEN: Your Hashnode personal access tokenDEVTO_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 platformpublicationId: Platform-specific publication identifier (Hashnode only)supportsBackdating: Whether to preserve original publication dates
Filter logic
Syndication filters determine post eligibility:
- Explicit frontmatter override:
syndicate: truealways syndicates,syndicate: falsenever syndicates - Excluded tags/categories: Posts with these are skipped
- Included tags/categories: If defined, posts must match at least one
- Default behavior: Respects
syndicateByDefaultsetting 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
-
02. Main deployment
-
03. Syndication runs
-
04. State updated
Publication process
For each eligible post:
- Check if already published (via state file)
- Validate against filters and frontmatter
- Prepare content with canonical URL notice
- Call platform API (GraphQL for Hashnode, REST for Dev.to)
- Record publication URL and timestamp
- 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
- Go to Actions tab in GitHub
- Select "Syndicate Posts" workflow
- Click "Run workflow"
- 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
mainbranch
Troubleshooting
Posts not syndicating
Check eligibility in order:
-
Run dry-run to see which posts would be published:
npm run syndicate:dry-run -
Verify post has no
syndicate: falsein frontmatter -
Check if post tags/categories are excluded in config
-
Confirm post isn't already in
.syndication-state.json- Use
--forceflag to re-publish
- Use
-
Review workflow logs in GitHub Actions
API errors
Hashnode errors:
Invalid token: VerifyHASHNODE_API_TOKENsecret is correctPublication not found: CheckpublicationIdin configRate limit exceeded: Wait and retry later
Dev.to errors:
Unauthorized: VerifyDEVTO_API_KEYsecret is correctToo many tags: Dev.to allows max 4 tags (automatically limited)Duplicate article: Post may already exist on platform
Workflow failures
If syndication fails:
- Check error message in workflow logs
- Deployment proceeds normally (syndication errors don't block)
- An issue is created automatically with troubleshooting steps
- Retry manually from Actions tab or with next push
Architecture
Components
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:
- Update
schemas/syndication-config.schema.jsonwith platform schema - Add platform configuration to
.syndication.config.json - Implement publishing function in
scripts/syndication/syndicate.mjs - Add platform case to main syndication loop
- Update workflow to pass API secret
- 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 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
- Test with dry-run first before enabling live syndication
- Start with one post to validate API integration
- Review state file after initial syndication
- Monitor workflow runs in GitHub Actions
- Keep configuration simple until you need advanced filters
- Document platform quirks as you discover them