Skip to main content

Publisher Syndication

Purpose: How published content gets distributed across platforms and channels.

Syndication Strategy

Blog Post Published

   ┌────┴────┐
   ↓         ↓
Social    Email
Posts     Newsletter
   ↓         ↓
   └────┬────┘

   Republishing
   (Medium, LinkedIn, etc.)
Goal: One piece of content → Multiple touchpoints → Maximum reach

Distribution Channels

ChannelContent TypeTimingAutomation
Twitter/XThread + linkImmediateFull
LinkedInArticle excerptImmediateFull
InstagramCarousel+1 hourPartial
TikTokVideo summary+2 hoursPartial
EmailNewsletter digestWeeklyFull
MediumFull republish+7 daysFull
RSSFeed updateImmediateAutomatic

Database Tables

distributions (From SCHEMA.md)

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)

-- 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

// 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

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
    }
  };
}
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

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

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

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

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

// 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)

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}`
  };
}

LinkedIn

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)

// 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:
<?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

// 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

-- 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

-- 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

-- 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"
  }
}

DocumentWhat It Covers
SCHEMA.mddistributions table
SCHEMA_CONCEPTS.mdDistribution system
editorial-flow.mdPre-syndication pipeline
verticals.mdPer-vertical channel config