Publisher Syndication
Purpose: How published content gets distributed across platforms and channels.Syndication Strategy
Copy
Blog Post Published
↓
┌────┴────┐
↓ ↓
Social Email
Posts Newsletter
↓ ↓
└────┬────┘
↓
Republishing
(Medium, LinkedIn, etc.)
Distribution Channels
| Channel | Content Type | Timing | Automation |
|---|---|---|---|
| Twitter/X | Thread + link | Immediate | Full |
| Article excerpt | Immediate | Full | |
| Carousel | +1 hour | Partial | |
| TikTok | Video summary | +2 hours | Partial |
| Newsletter digest | Weekly | Full | |
| Medium | Full republish | +7 days | Full |
| RSS | Feed update | Immediate | Automatic |
Database Tables
distributions (From SCHEMA.md)
Copy
CREATE TABLE distributions (
id uuid PRIMARY KEY,
content_id uuid REFERENCES generated_content(id),
platform_id uuid REFERENCES platforms(id),
-- Platform identifiers
platform_post_id text, -- Platform's ID for this post
published_url text,
-- Content snapshot
caption_used text,
hashtags_used text[],
-- Timing
scheduled_at timestamptz,
published_at timestamptz,
-- Status
status text DEFAULT 'draft', -- 'draft', 'scheduled', 'published', 'failed'
error_message text,
UNIQUE(content_id, platform_id)
);
Syndication Queue (Proposed)
Copy
-- publisher_syndication_queue
CREATE TABLE publisher_syndication_queue (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
post_id uuid REFERENCES publisher_posts(id),
-- Target
channel text NOT NULL, -- 'twitter', 'linkedin', 'instagram', 'email', 'medium'
-- Content adaptation
adapted_content jsonb, -- Channel-specific content
-- Scheduling
scheduled_at timestamptz,
published_at timestamptz,
-- Status
status text DEFAULT 'pending', -- 'pending', 'scheduled', 'published', 'failed'
error_message text,
-- Result
platform_post_id text,
platform_url text,
created_at timestamptz DEFAULT now()
);
CREATE INDEX idx_syndication_post ON publisher_syndication_queue(post_id);
CREATE INDEX idx_syndication_status ON publisher_syndication_queue(status, scheduled_at);
Channel Adaptations
Twitter/X Thread
Copy
// Convert blog post to Twitter thread
function adaptForTwitter(post) {
const thread = [];
// Hook tweet
thread.push({
text: `${post.hook}\n\n🧵 Thread:`,
type: 'hook'
});
// Key points (from extracted_facts)
for (const point of post.keyPoints.slice(0, 5)) {
thread.push({
text: `• ${point}`,
type: 'point'
});
}
// CTA tweet
thread.push({
text: `Full article with more details:\n${post.url}\n\nLike + RT if helpful! 🙏`,
type: 'cta'
});
return {
thread,
hashtags: post.hashtags.slice(0, 3)
};
}
LinkedIn Post
Copy
function adaptForLinkedIn(post) {
return {
text: `${post.hook}
${post.excerpt}
Key takeaways:
${post.keyPoints.map(p => `→ ${p}`).join('\n')}
Read more: ${post.url}
#${post.hashtags.slice(0, 5).join(' #')}`,
// LinkedIn prefers native content
article: {
title: post.title,
description: post.meta_description,
thumbnail: post.featured_image_url
}
};
}
Instagram Carousel
Copy
function adaptForInstagram(post) {
return {
slides: [
// Slide 1: Hook
{
type: 'text_overlay',
background: post.featured_image_url,
text: post.title,
template: 'title_slide'
},
// Slides 2-6: Key points
...post.keyPoints.slice(0, 5).map((point, i) => ({
type: 'text_only',
text: point,
number: i + 1,
template: 'point_slide'
})),
// Final slide: CTA
{
type: 'cta',
text: 'Link in bio for full article!',
template: 'cta_slide'
}
],
caption: `${post.hook}\n\n${post.excerpt}\n\n.\n.\n.\n#${post.hashtags.join(' #')}`,
hashtags: post.hashtags
};
}
Email Newsletter
Copy
function adaptForEmail(posts) {
return {
subject: `This Week: ${posts[0].title} + More`,
preview: posts[0].excerpt.slice(0, 100),
sections: posts.map(post => ({
title: post.title,
excerpt: post.excerpt,
image: post.featured_image_url,
cta_url: post.url,
cta_text: 'Read More →'
})),
footer: {
unsubscribe: '{{unsubscribe_link}}',
preferences: '{{preferences_link}}'
}
};
}
Medium Republish
Copy
function adaptForMedium(post) {
return {
title: post.title,
content: post.content,
// Add canonical link to avoid duplicate content
canonicalUrl: post.url,
// Medium-specific formatting
tags: post.hashtags.slice(0, 5),
// Delay republish by 7 days for SEO
publishAt: new Date(post.published_at.getTime() + 7 * 24 * 60 * 60 * 1000)
};
}
Syndication Flow
On Post Publish
Copy
async function onPostPublished(postId) {
const post = await getPost(postId);
const vertical = await getVertical(post.vertical_id);
// Get enabled channels for this vertical
const channels = vertical.syndication_channels || [
'twitter', 'linkedin', 'instagram', 'email'
];
for (const channel of channels) {
// Adapt content for channel
const adapted = await adaptContent(post, channel);
// Calculate schedule time
const scheduleAt = calculateScheduleTime(channel, post.published_at);
// Queue syndication
await supabase
.from('publisher_syndication_queue')
.insert({
post_id: postId,
channel,
adapted_content: adapted,
scheduled_at: scheduleAt,
status: 'scheduled'
});
}
}
Schedule Timing
Copy
function calculateScheduleTime(channel, publishedAt) {
const delays = {
twitter: 0, // Immediate
linkedin: 0, // Immediate
instagram: 60, // +1 hour (needs image prep)
tiktok: 120, // +2 hours (needs video)
email: 'weekly', // Batched weekly
medium: 7 * 24 * 60 // +7 days (SEO canonical)
};
const delayMinutes = delays[channel];
if (delayMinutes === 'weekly') {
return getNextWeeklyEmailTime();
}
return new Date(publishedAt.getTime() + delayMinutes * 60 * 1000);
}
Syndication Worker
Copy
// Runs every minute
async function processSyndicationQueue() {
const due = await supabase
.from('publisher_syndication_queue')
.select('*')
.eq('status', 'scheduled')
.lte('scheduled_at', new Date().toISOString())
.limit(10);
for (const item of due.data) {
try {
const result = await publishToChannel(item.channel, item.adapted_content);
await supabase
.from('publisher_syndication_queue')
.update({
status: 'published',
published_at: new Date(),
platform_post_id: result.id,
platform_url: result.url
})
.eq('id', item.id);
} catch (error) {
await supabase
.from('publisher_syndication_queue')
.update({
status: 'failed',
error_message: error.message
})
.eq('id', item.id);
}
}
}
Platform APIs
Twitter (X)
Copy
async function publishToTwitter(content) {
const client = new TwitterApi(process.env.TWITTER_BEARER);
// Post thread
let lastTweetId = null;
for (const tweet of content.thread) {
const result = await client.v2.tweet({
text: tweet.text,
reply: lastTweetId ? { in_reply_to_tweet_id: lastTweetId } : undefined
});
lastTweetId = result.data.id;
}
return {
id: lastTweetId,
url: `https://twitter.com/trendingsociety/status/${lastTweetId}`
};
}
Copy
async function publishToLinkedIn(content) {
// Using LinkedIn Marketing API
const response = await fetch('https://api.linkedin.com/v2/ugcPosts', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.LINKEDIN_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
author: `urn:li:organization:${process.env.LINKEDIN_ORG_ID}`,
lifecycleState: 'PUBLISHED',
specificContent: {
'com.linkedin.ugc.ShareContent': {
shareCommentary: { text: content.text },
shareMediaCategory: 'ARTICLE',
media: [{
status: 'READY',
originalUrl: content.article.url
}]
}
}
})
});
return response.json();
}
Buffer (Multi-Platform)
Copy
// Alternative: Use Buffer for scheduling
async function scheduleViaBuffer(content, profiles) {
for (const profile of profiles) {
await bufferClient.createUpdate({
profile_ids: [profile.id],
text: content.text,
media: content.media,
scheduled_at: content.scheduledAt
});
}
}
RSS Feed
Auto-generated from published posts:Copy
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Golf Insider | Trending Society</title>
<link>https://golf.trendingsociety.com</link>
<description>Expert golf tips, equipment reviews, and course guides.</description>
<language>en-us</language>
<lastBuildDate>Wed, 18 Dec 2025 12:00:00 GMT</lastBuildDate>
<item>
<title>Best Golf Drivers for High Handicappers 2025</title>
<link>https://golf.trendingsociety.com/best-drivers-high-handicap</link>
<description>Discover the most forgiving drivers...</description>
<pubDate>Wed, 18 Dec 2025 09:00:00 GMT</pubDate>
<guid>https://golf.trendingsociety.com/best-drivers-high-handicap</guid>
</item>
<!-- More items -->
</channel>
</rss>
RSS Generation
Copy
// Auto-update RSS on publish
async function updateRSSFeed(verticalId) {
const posts = await supabase
.from('publisher_posts')
.select('*')
.eq('vertical_id', verticalId)
.eq('status', 'published')
.order('published_at', { ascending: false })
.limit(50);
const rss = generateRSS(posts.data);
// Upload to CDN
await uploadToCloudinary(
rss,
`feeds/${verticalSlug}/rss.xml`,
{ resource_type: 'raw' }
);
}
Performance Tracking
Syndication Metrics
Copy
-- Syndication performance by channel
SELECT
psq.channel,
COUNT(*) as total_syndicated,
COUNT(CASE WHEN psq.status = 'published' THEN 1 END) as successful,
COUNT(CASE WHEN psq.status = 'failed' THEN 1 END) as failed
FROM publisher_syndication_queue psq
WHERE psq.created_at > now() - interval '30 days'
GROUP BY psq.channel
ORDER BY total_syndicated DESC;
Traffic by Source
Copy
-- Which syndication channels drive traffic
SELECT
ae.utm_source as channel,
COUNT(DISTINCT ae.audience_id) as unique_visitors,
COUNT(ae.id) as total_visits,
SUM(CASE WHEN ae.event_type = 'conversion' THEN 1 ELSE 0 END) as conversions
FROM audience_events ae
WHERE ae.utm_medium = 'syndication'
AND ae.created_at > now() - interval '30 days'
GROUP BY ae.utm_source
ORDER BY unique_visitors DESC;
Vertical-Specific Settings
Copy
-- In publisher_verticals.settings
{
"syndication": {
"enabled_channels": ["twitter", "linkedin", "instagram", "email"],
"twitter_handle": "@golfinsider",
"linkedin_page_id": "golf-insider-ts",
"instagram_account": "golfinsider.ts",
"email_list_id": "golf-weekly",
"medium_publication": "golf-insider"
}
}
Related Documentation
| Document | What It Covers |
|---|---|
| SCHEMA.md | distributions table |
| SCHEMA_CONCEPTS.md | Distribution system |
| editorial-flow.md | Pre-syndication pipeline |
| verticals.md | Per-vertical channel config |