Social Posts in Drupal with the Juicer API – from zero to hero

What we’re building

A Drupal site that automatically mirrors a brand’s X (Twitter) posts as first-class Drupal content. BBC Earth’s posts flow from X into Juicer, from Juicer’s API into social_post nodes, and onto a styled card-grid page – with no manual work after setup.

🌐

See it live: juicerfeed.com – a live example of this integration, running on a small VPS. It re-syncs from the Juicer API every hour, so the posts you see are whatever BBC Earth last published.

Video Overview: https://cleanshot.com/share/pqfZNMJlY3fHSZWR2MTQ

The finished /bbc-earth card grid: a dark forest-green page of BBC Earth photo cards, headed '@BBCEarth · 48 posts · synced from the Juicer API'.

Why nodes instead of a JS embed? Once posts are real Drupal entities you get Views, search indexing, permissions, custom rendering, editorial workflows – in short, the whole Drupal toolbox.

The pipeline has two independent sync layers:

X (Juicer's own workers, per feed sync rate) -> Juicer
Juicer API (our cron module, hourly) -> Drupal nodes
Drupal View (cache tags, automatic) -> /bbc-earth page

Prerequisites

  • A Drupal 10.3+ / 11 site – this guide used 11.4.4 on DDEV, PHP 8.3
  • A Juicer account and API key generated at – juicer.io
  • drush 13

Part 1 – Juicer: one feed, one X source

Everything downstream needs exactly one thing from Juicer: a feed with an X source in it. In the spirit of this guide we’ll create it with two API calls – that said, the dashboard UI works just as well (see the aside below).

First, get an API key: in the Juicer dashboard, open the API page and generate a key. All Integration API requests authenticate with it as a Bearer token. The Juicer API docs cover authentication and every endpoint in detail.

The Juicer dashboard API page with the 'Generate API key' dialog open and a list of existing API keys.

Create the feed

curl -X POST https://api.juicer.io/v1/feeds \
  -H "Authorization: Bearer $JUICER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "BBC Earth X"}'

The response contains two identifiers you’ll use for different things:

{ "data": { "id": 123456, "name": "BBC Earth X", "slug": "bbc-earth-x", "...": "..." } }
  • id – addresses the feed on the authenticated Integration API (/v1/...), like the next call.
  • slug – auto-generated from the name. This guide addresses the feed by id throughout, but the slug is the feed’s readable handle in the dashboard and in Juicer’s embed URLs, so a good feed name earns you a good slug for free.

Add the X source

curl -X POST https://api.juicer.io/v1/feeds/123456/sources \
  -H "Authorization: Bearer $JUICER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"platform": "Twitter", "term": "bbcearth", "term_type": "username"}'

Then, Juicer starts pulling posts immediately. Two things to know:

  • The platform is "Twitter" even though the product is X today – GET /v1/platforms lists every valid platform + term_type combination.
  • Plan limits apply on the API exactly as in the UI: a maxed-out account gets a feed_limit_reached error on feed creation, and adding a source can return insufficient_credits on API-credit accounts.
🖱️

Prefer clicking? The Juicer dashboard does the same job. Click + New feed, pick X (Twitter) from the source-type grid, choose Username and enter bbcearth. Then rename the feed – pencil icon next to the name, set “BBC Earth X”, tick “Also update the feed’s URL” – so the auto-generated slug becomes a friendly bbc-earth-x.

Juicer dashboard: creating a new feed and choosing X (Twitter) as the source type.
Juicer dashboard: renaming the feed to 'BBC Earth X' with the option to also update the feed's URL.

Mind the feed’s sync rate

However you created it, note the sync rate in the feed header – e.g. “Once a Day”. That’s how often Juicer pulls new posts from X, and everything downstream can only ever be as fresh as it – the hourly Drupal cron in Part 7 cannot invent posts Juicer hasn’t fetched yet.

That rate isn’t fixed: it comes from your Juicer plan, and you can change it. At the time of writing, Free and Ad-Free feeds sync once a day, Mini Lite, Lite and Starter once an hour, and Pro every 10 minutes. On Team, Enterprise and Custom plans – and on paid API accounts – you can also override it per feed from the pencil beside the sync rate in the feed header, anywhere from every minute up to once a day, or set the feed to Archive so it stops updating altogether.

So if the posts ever look stale, check the plan and the feed’s sync rate before you go looking for a bug in the code.

A Juicer feed header showing the sync rate control.

Part 2 – Reading the feed through the Integration API

The key from Part 1 does double duty: the same Bearer token that created the feed also reads its posts. Everything below runs against the versioned Integration API, documented in the Juicer API reference.

curl -H "Authorization: Bearer $JUICER_API_KEY" \
  "https://api.juicer.io/v1/feeds/123456/posts?per_page=50&page=1"

Note the feed is addressed by the numeric id from Part 1, not the slug. If you built your feed in the dashboard and only know its slug, GET /v1/feeds lists your feeds with both ids and slugs, and GET /v1/feeds/{id}?include=sources returns a single feed’s details and its sources. Neither of those returns posts – that is this endpoint’s job.

The response shape

{
  "data": [
    {
      "id": 1284419901,
      "external_id": "2057823941054615950",
      "platform": "X (Twitter)",
      "url": "https://x.com/BBCEarth/status/2057823941054615950",
      "message": "Tiger Island 🧡 🐾\n\nDeep in western Nepal…",
      "external_created_at": "2026-05-22T07:00:34.000-07:00",
      "poster": {
        "display_name": "BBC Earth",
        "name": "BBCEarth",
        "url": "https://x.com/BBCEarth",
        "image": "https://www.juicer.io/api/posts/…/poster_images.jpg"
      },
      "media": [
        {
          "type": "image",
          "url": "https://www.juicer.io/api/media/32722190?s=…",
          "preview_image_url": null,
          "alt_text": null
        }
      ],
      "like_count": 188,
      "comment_count": 24,
      "share_count": 31,
      "moderation_status": "public",
      "pinned": false
    }
  ],
  "meta": { "page": 1, "per_page": 50, "total_count": 48, "total_pages": 1 }
}

Gotchas worth knowing

  • Pagination is explicit. meta.total_pages and meta.total_count tell you exactly how far to go, so there is no paging until you hit an empty array. per_page defaults to 25 and caps at 100.
  • platform is the display name – "X (Twitter)", not an internal identifier. Match on that string.
  • Metrics are *_count, and a missing key means “not collected”. X returns like_count, comment_count, share_count, quote_count, bookmark_count and impression_count; a platform that doesn’t collect one omits it rather than reporting a misleading zero. Check with array_key_exists, not ?? 0, if the distinction matters to you.
  • message is plain text, not HTML. That suits Drupal: the Restricted HTML format we store it in runs filter_autop, which turns the line breaks into paragraphs, and filter_url, which linkifies the bare t.co URLs.
  • Media is an array. Images carry url; videos carry preview_image_url for the thumbnail, alongside width, height and alt_text where the platform supplies them. Media URLs are Juicer-proxied and hotlinkable.
  • moderation_status rides along on every post, and status defaults to public – so anything you moderated away in Juicer stays out of Drupal without any extra work on your side.

Part 3 – The Social Post content type in Drupal

First, in your Drupal site create a plain content type – no custom entity code needed. Structure → Content types → Add content type: name Social Post, machine name social_post. Uncheck “Promoted to front page” in publishing options.

Drupal 'Add content type' admin form for the Social Post content type.

Then add fields under Manage fields:

Label Machine name Type Notes
External ID field_external_id Text (plain) Required – the tweet id, our dedupe key
Post URL field_post_url Link Required – permalink on X
Image URL field_image_url Link Post photo, rendered as an image by our template
Poster name field_poster_display_name Text (plain) “BBC Earth”
Poster handle field_poster_handle Text (plain) “BBCEarth”
Poster avatar URL field_poster_avatar_url Link
Likes field_likes Number (integer)
Comments field_comments Number (integer)
Drupal 'Manage fields' screen listing the fields added to the Social Post content type.

Three built-ins do double duty, saving three custom fields:

  • Title → set by the sync to “BBC Earth – 22 May 2026” – nodes must have one anyway
  • Body → the post’s message. v1 hands this over as plain text, so we store it with the Restricted HTML format and let Drupal do the formatting: filter_autop rebuilds the paragraphs and filter_url turns the bare t.co URLs back into links on output
  • Authored on (created) → set to the post’s original X publish time, so date sorting and display need zero extra fields

If your content type is missing Body, attach it programmatically:

// drush php:eval
$type = \Drupal\node\Entity\NodeType::load('social_post');
node_add_body_field($type, 'Message');

Part 4 – The sync service

All sync logic lives in one autowired service. It pages through the API, keeps only X posts, and upserts by field_external_id – new tweets become nodes, existing ones are updated only when something actually changed (likes, comments, text, image) – in short, everything else is untouched.

The service reads two things from its environment: JUICER_API_KEY, the Bearer token from Part 1, and JUICER_FEED_ID, the numeric feed id. Both are required. As a result, the service throws when either is missing – an absent key would otherwise look exactly like a feed with no posts, and the sync would quietly report zero rather than telling you it was never authenticated. Keep the key out of the repo: on the demo site it lives only in the container’s env file.

web/modules/custom/social_post_sync/src/SocialPostSyncService.php – the core of it:

public function sync(): array {
  $storage = $this->entityTypeManager->getStorage('node');
  $result = ['created' => 0, 'updated' => 0, 'unchanged' => 0, 'skipped' => 0];

  for ($page = 1; $page <= self::MAX_PAGES; $page++) {
    ['data' => $items, 'meta' => $meta] = $this->fetchPage($page);

    foreach ($items as $item) {
      if (($item['platform'] ?? '') !== self::PLATFORM) {
        $result['skipped']++;
        continue;
      }
      $values = $this->nodeValuesFor($item);
      $existing = $storage->loadByProperties([
        'type' => 'social_post',
        'field_external_id' => $values['field_external_id'],
      ]);
      if ($existing === []) {
        $storage->create($values)->save();
        $result['created']++;
        continue;
      }
      $this->updateIfChanged(reset($existing), $values)
        ? $result['updated']++ : $result['unchanged']++;
    }

    if ($page >= $meta['total_pages']) {
      break;
    }
  }
  return $result;
}

A thin drush script – scripts/sync_social_posts.php – triggers it manually:

$result = \Drupal::service(SocialPostSyncService::class)->sync();
printf("Sync finished: %d created, %d updated, %d unchanged, %d skipped.\n", ...);

First run vs. second run – the upsert proof:

$ ddev drush php:script scripts/sync_social_posts.php
Sync finished: 48 created, 0 updated, 0 unchanged, 0 skipped (non-X).
$ ddev drush php:script scripts/sync_social_posts.php
Sync finished: 0 created, 0 updated, 48 unchanged, 0 skipped (non-X).
The Drupal content list showing the 48 imported BBC Earth posts as Social Post nodes, all published.

Part 5 – The listing page: a View

Structure -> Views -> Add view: show Content, create a page – e.g. at /bbc-earth – then adjust:

  • Filter on the bundle! The wizard can leave this out – add Content type = Social Post or the page will happily list your Basic pages too.
  • Row style: Fields, not rendered entity – the card template needs individual values.
  • Add the fields: Body, Post URL, Image URL, Poster name, Poster handle, Poster avatar URL, Likes, Comments, Authored on.
  • Sort: newest first on Authored on – which is the X publish time thanks to Part 3.
  • Pager: your call; we show all.
Drupal Views admin building the /bbc-earth page listing Social Post content.

Part 6 – Making it beautiful

An Olivero subtheme carries the whole design – no page builder, no build step, ~700 lines of plain CSS across three files, two small JS files and two Twig overrides:

web/themes/custom/juicer_social/
├── juicer_social.info.yml          # base theme + regions + global library
├── juicer_social.libraries.yml     # social-cards, site-header, post-reel
├── logo-juicer-drupal-v2.svg       # Juicer mark + Drupal drop lockup
├── css/
│   ├── social-cards.css            # the card grid
│   ├── site-header.css             # Juicer chrome: header, nav, footer
│   └── post-reel.css               # the full-screen post viewer
├── js/
│   ├── post-reel.js                # post viewer behaviour
│   └── post-links.js               # in-post links open in a new tab
└── templates/
    ├── views-view-unformatted--bbc-earth-posts.html.twig   # grid + viewer dialog
    └── views-view-fields--bbc-earth-posts.html.twig        # one card

The grid wrapper attaches the library, renders an eyebrow line, and features the newest post at double width:

{{ attach_library('juicer_social/social-cards') }}
<div class="bbc-earth-canopy">
  <p class="bbc-earth-canopy__eyebrow">@BBCEarth &middot; {{ rows|length }} posts &middot; synced from the Juicer API</p>
  <ul class="social-card-grid">
    {% for row in rows %}
      <li class="social-card{{ loop.first ? ' social-card--featured' }}">{{ row.content }}</li>
    {% endfor %}
  </ul>
</div>

The card template reads raw values straight off the row’s entity – cleaner than fighting field formatters:

{% set post = row._entity %}
<article class="social-card__inner">
  {% if post.field_image_url.uri %}
  {# Opens the post viewer; the footer link is the only route off-site. #}
  <button type="button" class="social-card__media social-card__open"
    aria-label="Open post from {{ posted|date('j M Y') }} in the post viewer">
    <img src="{{ post.field_image_url.uri }}" alt="" loading="lazy">
  </button>
  {% endif %}
  ...
  <footer class="social-card__meta">
    <time datetime="{{ post.created.value|date('Y-m-d') }}">{{ post.created.value|date('j M Y') }}</time>
    <span><span aria-hidden="true">&hearts;</span> {{ post.field_likes.value }}<span class="visually-hidden"> likes</span></span>
    <a href="{{ post.field_post_url.uri }}" target="_blank" rel="noopener noreferrer">
      View on X<span class="visually-hidden">, post from {{ posted|date('j M Y') }}, opens in a new tab</span> <span aria-hidden="true">&#8599;</span>
    </a>
  </footer>
</article>

Design notes: dark forest-green panel so the wildlife photography leads; a responsive grid that steps 1 -> 2 -> 3 columns with the newest post spanning two; one amber accent reserved for links; engagement data in a monospace “field log” strip; prefers-reduced-motion respected; visually-hidden labels on the stats for screen readers.

Clicking a post: the viewer

The card image is a <button>, not a link, and it opens every post in a full-screen viewer that snap-scrolls one post per screen – the reading pattern people already know from Reels and Stories. “View on X” in the card footer stays the only route off-site, so browsing the feed no longer dead-ends on someone else’s domain.

The full-screen post viewer: a single BBC Earth photo post filling the screen.

Two decisions did most of the work:

  • A native <dialog>. showModal() gives focus trapping, inertness of the page behind, and Esc-to-close from the browser, so none of that is hand-written. The dialog ships empty in the grid template; only its shell is markup.
  • The slides are clones of the cards. On first open the JS clones each .social-card__inner, swapping the trigger button back for the bare <img> – inside the viewer the photo is content, not a control. A post is therefore described in exactly one place. This works because the card only visually clamps its message with -webkit-line-clamp; the full text was in the DOM all along.

Finally, the rest is CSS: scroll-snap-type: y mandatory on the track, scroll-snap-align: start on each slide, and object-fit: contain to undo the card’s 3:2 crop so landscape photography is never cut.

Two traps to avoid

  • Use scroll-snap-align: start, not center. With slides at least a full screen tall, centre alignment lands a neighbour when you jump with scrollIntoView({block: 'start'}).
  • behavior: 'auto' does not mean instant – it defers to the CSS scroll-behavior, which here is smooth. Home/End across 48 slides crawled through every one and announced each to the aria-live counter. Multi-slide jumps need 'instant'; only single steps should animate.

Also, keyboard parity comes free of the pattern: arrows, PageUp/PageDown and Home/End move between posts, Esc closes and returns focus to the card image you came from, and previous/next buttons give a single-pointer alternative to swiping.

One related touch, in js/post-links.js: the links inside a post body all lead off-site, so they get target="_blank" and rel="noopener noreferrer" too. That has to happen in JS rather than in the markup, because the body renders through Restricted HTML, whose filter_html only permits <a href hreflang> – a target written into the stored HTML would simply be stripped on output, and widening the filter would let any third-party content set its own target.

Enable and switch:

ddev drush theme:install juicer_social
ddev drush config:set system.theme default juicer_social -y
ddev drush cr
The finished, themed juicerfeed site rendered with the juicer_social subtheme.

Part 7 – Automating the sync with cron

A tiny module makes the whole thing self-updating. social_post_sync registers the sync service plus one OOP cron hook – Drupal 11 #[Hook] attribute style:

#[Hook('cron')]
public function cron(): void {
  $last_sync = $this->state->get(self::LAST_SYNC_STATE_KEY, 0);
  if ($this->time->getRequestTime() - $last_sync < self::SYNC_INTERVAL_SECONDS) {
    return;
  }
  $logger = $this->loggerFactory->get('social_post_sync');
  try {
    $result = $this->syncService->sync();
    $logger->info('Juicer sync: @created created, @updated updated, @unchanged unchanged, @skipped skipped.', [...]);
  }
  catch (\Throwable $exception) {
    $logger->error('Juicer sync failed: @message', ['@message' => $exception->getMessage()]);
  }
  // Stamp even on failure so a broken API is retried hourly,
  // not hammered on every cron run.
  $this->state->set(self::LAST_SYNC_STATE_KEY, $this->time->getRequestTime());
}

Every Drupal cron run checks a State timestamp; if the last sync is over an hour old it syncs and logs to watchdog. Failures are logged and retried next hour instead of hammering the API.

Drupal watchdog log showing the hourly Juicer sync entries.
⚠️

Production note: don’t rely on core’s request-triggered automated_cron – wire real cron (drush cron in crontab, or an external ping of /cron/{key}) so the hourly sync actually happens hourly.

Recap

Layer Mechanism Frequency
X → Juicer Juicer’s own sync workers The feed’s sync rate – set by plan (daily on Free, hourly on Lite/Starter, every 10 min on Pro), set per feed on Team/Enterprise/Custom
Juicer → Drupal social_post_sync module on cron Hourly
Drupal → page View + cache tags Instant on new content

In short, zero to hero: one Juicer feed, one API key, one content type, one service, one view, one subtheme, one cron hook.


See it live at: https://juicerfeed.com


Running Drupal? Skip the build with our module

This guide wires the pipeline up from scratch so you get full control over how posts become Drupal entities. If you just want a social feed on your Drupal site without writing a line of code, we maintain an official module: the Juicer Social Feed module for Drupal 10 and 11.

It drops your Juicer feed onto any page as a configurable block – place it in a sidebar, content region or Layout Builder, filter by network, set post limits, and get a responsive masonry grid with post overlays and a “Load More” pager. It works with Instagram, LinkedIn, Facebook, X (Twitter), TikTok, Bluesky and YouTube, no coding required.

Get it from the Drupal project page: drupal.org/project/juicer.

Get your beautiful social media feed from Juicer today!

Juicer pulls in your social posts and updates your feed, so you don’t have to lift a finger.

You might also like

Social Posts in Drupal with the Juicer API – from zero to hero

What we’re building A Drupal site that automatically mirrors a brand’s X (Twitter) posts as first-class Drupal content. BBC Earth’s

Juicer api

Juicer Launches Unified Social Media Data API and MCP Server for AI Agents

On June 29, 2026, Juicer officially launched the Juicer API, a unified social media aggregator API designed to streamline multi-platform

Reddit brand monitoring with Juicer

How to Monitor What People Say About Your Brand on Reddit

Juicer added Reddit as a feed source on 10 June 2026, which means you can now monitor every public Reddit post