How to auto-post blog content to X, LinkedIn, and Threads

Practical recipe for automatically generating and publishing social posts whenever a new blog article goes live. Covers RSS polling, content formatting per platform, and link previews.

How to auto-post blog content to X, LinkedIn, and Threads

The blog post goes live, then nothing happens

You publish a blog post. It appears on your website. Your RSS feed updates. Google will eventually find it.

But your audience on X does not know it exists. Neither does your LinkedIn network. Neither do your followers on Threads. Until someone manually writes a post, attaches the link, and clicks publish on each platform — one at a time — the article sits there, reaching only the people who happen to visit your site.

This is the default for most blogs. Publishing content and distributing content are two separate tasks, done in two separate tools, usually by two separate people. The writing is the hard part, but the distribution is the bottleneck. Not because it is difficult, but because it is tedious and easy to skip.

What if publishing a blog post automatically created social posts on X, LinkedIn, and Threads? Not a vague notification. Not a link dump. A properly formatted post for each platform, with the right text length, the right image, and the right link handling — published within minutes of the article going live.

That is what this guide builds.

The architecture

┌──────────────┐
│ Your Blog │ Publishes new article
│ (RSS feed) │ (feed updates)
└──────┬───────┘
┌──────────────┐
│ RSS Poller │ Detects new entries
│ (cron/n8n/ │ (checks on interval)
│ serverless) │
└──────┬───────┘
┌──────────────┐
│ Formatter │ Builds platform-appropriate
│ │ post text from article data
└──────┬───────┘
┌──────────────┐
│ Postproxy │ Publishes to X, LinkedIn,
│ API │ and Threads in one call
└──────────────┘

Four pieces. Your blog already handles the first one. The RSS poller watches for new articles. The formatter turns article metadata into social-ready text. The Postproxy API handles the actual publishing across platforms.

The poller and formatter can be a single script. You can run it as a cron job, a serverless function on a timer, or an n8n workflow. The implementation does not matter much — what matters is the pattern.

Why RSS

You could use webhooks. If your CMS supports them, a webhook-based approach fires the moment content is published and gives you the full payload. That is the best option when you have it.

But many blogs do not have webhooks. Static site generators — Hugo, Astro, Jekyll, Next.js — build and deploy files. There is no CMS to fire a webhook. There is no publish event. There is just a new HTML file and an updated RSS feed.

RSS is universal. Every blog platform generates it. Static site generators produce it during the build step. WordPress, Ghost, Substack, Medium — all of them serve RSS. Even if you add webhook support later, RSS works as a starting point with zero changes to your publishing workflow.

RSS is also simple to parse. A feed entry gives you exactly the fields you need for a social post:

  • Title — the headline
  • Link — the URL to the article
  • Description — a summary or excerpt
  • pubDate — when it was published
  • Enclosure or media:content — featured image (when available)

That is enough to construct a social post for any platform.

Detecting new articles

The poller needs to answer one question: has a new article appeared since the last time I checked?

The simplest approach is to track the most recent article you have seen. On each poll, parse the feed, compare the newest entry against your stored state, and process anything new.

import Parser from "rss-parser";
import fs from "fs";
const FEED_URL = "https://yourdomain.com/feed.xml";
const STATE_FILE = "./last-seen.json";
async function checkForNewPosts() {
const parser = new Parser();
const feed = await parser.parseURL(FEED_URL);
// Load last seen article
let lastSeen = null;
if (fs.existsSync(STATE_FILE)) {
lastSeen = JSON.parse(fs.readFileSync(STATE_FILE, "utf-8")).guid;
}
// Find new articles (feed is newest-first)
const newArticles = [];
for (const item of feed.items) {
if (item.guid === lastSeen || item.link === lastSeen) break;
newArticles.push(item);
}
if (newArticles.length === 0) return [];
// Save the newest article as our new checkpoint
const newest = newArticles[0];
fs.writeFileSync(
STATE_FILE,
JSON.stringify({ guid: newest.guid || newest.link })
);
// Return in chronological order (oldest first)
return newArticles.reverse();
}

This handles the common case: you publish one or two articles between polls. It also handles catching up after downtime — if three articles were published while the poller was off, all three get processed in order.

Poll interval depends on how quickly you want social posts to follow the article. Every 5 minutes is responsive without being aggressive. Every 15 minutes is reasonable for most blogs. Every hour is fine if timing is not critical.

For static sites that deploy through CI, you can skip polling entirely and trigger the social post from your deploy pipeline. A GitHub Actions step after deploy that runs the formatter and calls the Postproxy API achieves near-instant distribution with zero polling.

Formatting content per platform

This is where most auto-posting setups fall short. They take the article title, append the URL, and blast the same text to every platform. The result reads like a bot on every platform — because it is.

Each platform has different constraints, different norms, and different ways of handling links. A post that works on LinkedIn will get truncated on X. A post that looks fine on Threads will waste Instagram’s caption space. Formatting matters.

X (Twitter)

Character limit: 280 on free/basic tiers, 25,000 on Pro.

Link handling: URLs are shortened to 23 characters via t.co, regardless of actual length. A link preview card (image, title, description) renders automatically when the post contains a URL — but only if the target page has proper Open Graph meta tags.

What works: Short, punchy text that entices a click. The link preview does the heavy lifting for context, so the text should add something the preview does not — a take, a hook, a reason to care.

Title or hook sentence
https://yourdomain.com/blog/your-article

Keep the text under 200 characters to leave room for the URL and breathing space. Do not repeat the title if the link preview will already show it.

LinkedIn

Character limit: 3,000.

Link handling: LinkedIn renders rich link previews (image, title, description) from Open Graph tags. The preview appears below the post text. Links in the text are clickable.

What works: More context than X. LinkedIn audiences engage with posts that explain why something matters, not just what it is. Use the article excerpt or write a brief framing paragraph.

Title
Excerpt or summary paragraph that explains what the reader will learn
and why it matters.
https://yourdomain.com/blog/your-article

LinkedIn also supports hashtags, though their impact is debatable. If your audience uses them, include 2–3 relevant ones. If not, skip them.

Threads

Character limit: 500.

Link handling: Threads supports link attachments. When you include a URL, a preview card can render — but behavior varies. The URL is clickable in the post text.

What works: Conversational, concise. Threads leans casual. A sentence or two that frames the article, plus the link.

Quick hook or summary sentence.
https://yourdomain.com/blog/your-article

Keep it under 400 characters to stay well within the limit. Threads counts emoji by UTF-8 byte length, so emoji-heavy text may be longer than it looks.

Building the formatter

The goal is one short body that reads well on all three platforms. X has the tightest constraint at 280 characters (free tier), so that is your ceiling. A post that fits X will fit LinkedIn and Threads too.

The formula: title, one sentence of context, and the link. Keep it under 280 characters total.

function formatPost(article) {
const { title, link, description } = article;
// One short sentence from the excerpt
const hook = description
? description.split(". ")[0] + "."
: "";
const body = hook
? `${title}\n\n${hook}\n\n${link}`
: `${title}\n\n${link}`;
return body;
}

This produces something like:

How we cut our API response times by 60%
A deep dive into connection pooling, query optimization, and response caching.
https://yourdomain.com/blog/api-performance

Short enough for X. Reads well on LinkedIn. Fits within Threads’ 500-character limit. The link preview card — generated from your Open Graph tags — provides the rest of the context on platforms that support it.

Publishing through Postproxy

With the formatted text ready, one API call publishes to all three platforms:

Terminal window
curl -X POST "https://api.postproxy.dev/api/posts" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"post": {
"body": "How we cut our API response times by 60%\n\nA deep dive into connection pooling, query optimization, and response caching.\n\nhttps://yourdomain.com/blog/api-performance"
},
"profiles": ["twitter", "linkedin", "threads"],
"media": ["https://yourdomain.com/images/api-perf-hero.png"]
}'

One body. One call. Three platforms. The same text goes to X, LinkedIn, and Threads. Because the body is short — title, one sentence, link — it fits within every platform’s character limit.

The media array attaches the article’s featured image. Postproxy handles uploading it to each platform using the appropriate protocol — X’s chunked upload, LinkedIn’s Images API, Threads’ container model. You provide the URL; the platform-specific complexity is abstracted away.

The complete script

Here is a full implementation that polls an RSS feed and publishes new articles to X, LinkedIn, and Threads:

import Parser from "rss-parser";
import fs from "fs";
const FEED_URL = "https://yourdomain.com/feed.xml";
const STATE_FILE = "./last-seen.json";
const POSTPROXY_API_KEY = process.env.POSTPROXY_API_KEY;
async function checkForNewPosts() {
const parser = new Parser();
const feed = await parser.parseURL(FEED_URL);
let lastSeen = null;
if (fs.existsSync(STATE_FILE)) {
lastSeen = JSON.parse(fs.readFileSync(STATE_FILE, "utf-8")).guid;
}
const newArticles = [];
for (const item of feed.items) {
if (item.guid === lastSeen || item.link === lastSeen) break;
newArticles.push(item);
}
if (newArticles.length === 0) {
console.log("No new articles found.");
return;
}
const newest = newArticles[0];
fs.writeFileSync(
STATE_FILE,
JSON.stringify({ guid: newest.guid || newest.link })
);
for (const article of newArticles.reverse()) {
await publishSocialPost(article);
}
}
async function publishSocialPost(article) {
const title = article.title;
const link = article.link;
const description =
article.contentSnippet || article.content || article.summary || "";
// One short sentence from the excerpt
const hook = description.trim()
? description.trim().split(". ")[0] + "."
: "";
const body = hook
? `${title}\n\n${hook}\n\n${link}`
: `${title}\n\n${link}`;
// Extract image from enclosure or media:content
const imageUrl =
article.enclosure?.url ||
article["media:content"]?.$.url ||
null;
const payload = {
post: { body },
profiles: ["twitter", "linkedin", "threads"],
};
if (imageUrl) {
payload.media = [imageUrl];
}
const response = await fetch("https://api.postproxy.dev/api/posts", {
method: "POST",
headers: {
Authorization: `Bearer ${POSTPROXY_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const result = await response.json();
console.log(`Published: "${title}"`, result);
}
checkForNewPosts().catch(console.error);

Save this as publish-new-posts.js. Run it on a schedule with cron:

Terminal window
# Check for new blog posts every 15 minutes
*/15 * * * * cd /path/to/script && node publish-new-posts.js

Or set it as a scheduled GitHub Actions workflow:

name: Auto-post new blog articles
on:
schedule:
- cron: "*/15 * * * *"
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install rss-parser
- run: node publish-new-posts.js
env:
POSTPROXY_API_KEY: ${{ secrets.POSTPROXY_API_KEY }}

For the GitHub Actions approach, you will need to persist the last-seen.json state file. Store it as a repository file and commit updates, use a cache action, or move the state to a database or key-value store like Redis or a simple API endpoint.

Auto-posted links are only useful if they render proper previews on each platform. A link with no preview card — just a raw URL in plain text — looks broken. Readers scroll past it.

Link previews are generated from Open Graph meta tags on the target page. Every page you share needs these tags in the <head>:

<meta property="og:title" content="Your Article Title" />
<meta property="og:description" content="A concise summary of the article" />
<meta property="og:image" content="https://yourdomain.com/images/hero.png" />
<meta property="og:url" content="https://yourdomain.com/blog/your-article" />
<meta property="og:type" content="article" />

Also include Twitter-specific card tags for X:

<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Your Article Title" />
<meta name="twitter:description" content="A concise summary" />
<meta name="twitter:image" content="https://yourdomain.com/images/hero.png" />

Platform-specific behavior:

  • X caches link preview cards aggressively. If you update your OG tags after an initial crawl, the old preview sticks. Use the Card Validator to force a re-crawl.
  • LinkedIn caches previews as well. Use the Post Inspector to clear the cache and preview how your link will render.
  • Threads fetches preview data when the post is created. No known cache-clearing tool exists, so get your OG tags right before the post goes out.

Image requirements for previews:

  • Minimum 1200 x 630 pixels for optimal display across all platforms
  • Use a 1.91:1 aspect ratio
  • Keep the file size under 5 MB
  • JPEG and PNG are universally supported

If your blog framework generates OG tags from frontmatter — which Astro, Next.js, Hugo, and most static generators do — verify that the image URL is absolute (starts with https://), not relative. Relative paths will not resolve when platforms crawl your page.

Link previews are convenient but not reliable. Platforms cache them. Some platforms render them inconsistently on mobile. And the preview image is often smaller and less prominent than a native image attachment.

An alternative: attach the featured image directly to the social post via the media field, and include the link in the text. This guarantees the image appears at full size on every platform, regardless of how link previews render.

{
"post": {
"body": "How we cut our API response times by 60%\n\nhttps://yourdomain.com/blog/api-performance"
},
"profiles": ["twitter", "linkedin", "threads"],
"media": ["https://yourdomain.com/images/api-perf-hero.png"]
}

The tradeoff: on platforms that render both a media attachment and a link preview (like LinkedIn), you may get a double visual — the attached image plus a smaller link preview card. Some teams prefer this. Others find it redundant. Test with your content and see what looks right.

On X, attaching an image suppresses the link preview card entirely. The image becomes the visual, and the link is clickable in the text but does not render a card. This is often preferable — the attached image is larger and more prominent than a card preview.

Triggering from CI instead of polling

If your blog is built and deployed through a CI pipeline — GitHub Actions, GitLab CI, Netlify, Vercel — you can skip RSS polling entirely. Trigger the social post from the deploy step.

# Add this step after your deploy step
- name: Publish social posts for new articles
run: node publish-new-posts.js
env:
POSTPROXY_API_KEY: ${{ secrets.POSTPROXY_API_KEY }}

This is more responsive than polling (the post goes out immediately after deploy) and avoids the state management problem (you only need to know what changed in the current build, not since the last poll).

For static site generators, you can extract new articles from the git diff instead of parsing RSS:

Terminal window
# Find new blog post files added in this commit
git diff --name-only HEAD~1 HEAD -- src/content/blog/

Parse the frontmatter of any new files, extract the title, description, slug, and image, and call the Postproxy API. No RSS parsing needed.

Using n8n instead of code

If you prefer a visual workflow over a script, n8n handles this without writing code.

The workflow:

  1. Schedule Trigger — runs every 15 minutes (or however often you want to check)
  2. HTTP Request node — fetches your RSS feed
  3. XML node — parses the RSS XML into JSON
  4. Function node — compares against the last seen article, filters to only new entries, formats the post text per platform
  5. HTTP Request node — calls the Postproxy API for each new article

n8n also has a built-in RSS Feed Trigger node that handles polling and deduplication for you. Use it as the trigger and skip steps 2–3. The node fires only for new entries, handling the state tracking internally.

This is a good option for teams where the person managing social distribution is not a developer. The workflow is visual, each step is inspectable, and changes do not require a deployment.

Handling edge cases

A few things will trip you up if you do not plan for them:

Duplicate posts. If your poller runs twice before the state file updates, or if your CI pipeline retries, you may publish the same article twice. Mitigate this by checking the post title or URL against recent Postproxy posts before publishing, or by using idempotency keys if your workflow engine supports them.

Draft or future-dated articles. Some RSS feeds include draft or scheduled posts. Filter by pubDate — only process articles with a publication date in the past or within a reasonable window (say, the last 24 hours).

Feed errors. The RSS feed may temporarily return errors or stale content during a deploy. Add retry logic with a short backoff, and do not update the state file if the feed parse fails.

Rate limits. If you publish ten articles at once (catching up after downtime, for example), space out the API calls. A one-second delay between posts is sufficient and avoids hitting any rate limits.

Missing images. Not every article has a featured image. If imageUrl is null, publish without media. A text-only post is better than a failed post.

Drafts: adding a review step

If you want a human to review the social post before it goes live, use Postproxy’s draft system. Set draft: true in the API call:

{
"post": {
"body": "Your formatted post text"
},
"profiles": ["twitter", "linkedin", "threads"],
"draft": true
}

The post is saved but not published. A team member reviews it in the Postproxy dashboard, edits if needed, and publishes with one click. This gives you the automation of detecting new articles and formatting posts, with a human in the loop for the final publish decision.

For teams that trust the automation but want a safety net, use drafts for the first few weeks. Once you are confident the formatting is right, switch to auto-publish.

What Postproxy handles

The recipe in this guide covers the detection and formatting side — watching for new articles and building platform-appropriate text. Postproxy handles everything on the publishing side:

  • AuthenticationOAuth flows for X, LinkedIn, and Threads, including token refresh and re-authentication
  • Media uploadsplatform-specific upload protocols for images and videos
  • Platform constraints — character limits, format requirements, API quirks per platform
  • Partial success tracking — if LinkedIn publishes but X fails, you get explicit per-platform outcomes instead of a generic error
  • Retries — transient failures are retried automatically with appropriate backoff

You build the pipeline. Postproxy handles the last mile.

Getting started

  1. Sign up at Postproxy and connect your X, LinkedIn, and Threads accounts
  2. Get your API key from the dashboard
  3. Deploy the script — as a cron job, a GitHub Actions workflow, a serverless function, or an n8n workflow
  4. Verify your OG tags — make sure your blog pages have proper Open Graph meta tags for link previews
  5. Publish a test article and watch the social posts appear

Once running, every new blog post automatically becomes a social post on every connected platform. The blog is the source of truth. The distribution is automatic. The manual copy-paste-publish cycle is gone.

Ready to get started?

Start with our free plan and scale as your needs grow. No credit card required.