iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🔄

Act 3: Building the Creators Dashboard — Three Pivots in Embedding X Posts

に公開

About This Article

Claude Code created a draft based on AI interaction logs and experimental records, which I then edited and polished.
The content of this article represents my decisions for a personal development project in the PoC phase and does not claim to represent general best practices.

In my previous article, I introduced the internal structure of the fetch pipeline. It covered topics such as retrieving diffs from GitHub, a five-layer verification pipeline, and API budget management—the mechanisms for "automatically creating reliable data."

As promised at the end of the last article, this time the theme is "how to safely embed external content." While implementing the feature to embed X (formerly Twitter) posts into cards, I experienced three major pivots in my approach.

I started out thinking, "This should be easy." However, in reality, the terms of service, DOM structure, and the assumptions of widgets.js—these three things collided, forcing me to change my method three times. This article is not about "how it worked," but rather a record of what goes wrong when integrating external widgets.


Introduction

What I'll Cover

  • The journey of changing the X post display method three times and the issues encountered with each approach.
  • An explanation of the incompatibility between display: none and widgets.js.
  • The final implementation using twttr.widgets.createTweet().
  • The evolution of the data model in the data files (from containing all text to just postId).
  • Fallback design and loading UX improvements.

What I Won't Cover

  • X API usage registration procedures or general tutorials.
  • API reference for widgets.js.
  • Basic explanations of CSP.

What I Wanted to Achieve

Creators Dashboard has "cards" for each project. Each card has two pages: the first page contains project information, and the second is an area for embedding the developer's X posts.

Two-page card structure — Page 1 (Project Info) and Page 2 (X post embedding)

What I wanted to achieve was simple: Display the developer's latest X post on the second page of the card.

Individual developers often share progress tweets on X daily, many of which include videos or screenshots. However, since X is a "flow-type" medium, these posts eventually drift away. By letting the Creators Dashboard act as a repository, I could create a mechanism to "post via flow (X) while sharing the latest state via stock (CD)."

It seemed simple. I never imagined I would have to change my approach three times.


First Attempt: Custom Rendering — Recreating with X-like CSS

What I Did

The first approach was to fetch post data (text, media URL, posting time, author name) using the X API v2 and render it to look like X using custom CSS components.

I designed the data file to include everything: the text and the media.

{
  "social": {
    "x": {
      "text": "Post text",
      "mediaUrls": ["https://..."],
      "mediaType": "photo",
      "postedAt": "2026-03-26T12:00:00Z",
      "postId": "2037208350895812662",
      "author": "DEVOUR_JP"
    }
  }
}

What Was the Problem?

I managed to implement it, and it looked the part. However, I stopped here. Reconstructing and displaying post text or media fetched via API in a custom UI could potentially violate the X Developer Agreement. I wanted to avoid making gray-area decisions during the PoC phase. I decided not to build a custom UI and instead use the official widgets.js. I rolled back to v1.1.1 (no X integration) and started working on switching to the official embedding method.


Second Attempt: blockquote + widgets.js — The Official "Correct" Way

What I Did

Following X's official documentation, I switched to an approach where I output a <blockquote class="twitter-tweet"> via SSR and convert it using widgets.js.

<blockquote class="twitter-tweet" data-dnt="true">
  <a href="https://x.com/i/status/2037208350895812662"></a>
</blockquote>
<script async src="https://platform.twitter.com/widgets.js"></script>

This is the embedding method recommended by X's official guidelines. You place a blockquote in the HTML, and widgets.js detects it and converts it into an iframe.

What Was the Problem? — The Wall of display: none

The Creators Dashboard cards have a two-page structure, and the second page is set to display: none by default. When a user clicks a tab, it switches to display: flex.

.card-page--2 {
  display: none;
}
.card-page--2.card-page--visible {
  display: flex;
}

widgets.js cannot correctly process blockquotes that exist inside display: none elements.

When the page loads, widgets.js scans the DOM for blockquotes, but the blockquotes inside hidden containers are either not converted or are converted with a size of zero. Consequently, the embedded tweet would not appear even after switching tabs.

Fix for the Second Attempt: Using twttr.widgets.load()

I thought, "Perhaps I should call twttr.widgets.load() when the tab is displayed, rather than at page load." The idea was to preload widgets.js and have it process the blockquote inside the specified container only after the tab is clicked.

However, this didn't work either.

widgets.js automatically processes any <blockquote class="twitter-tweet"> in the DOM upon page load. The blockquote was converted into a link while still hidden, so calling load() afterwards was too late.

With the blockquote approach, you cannot avoid the incompatibility between widgets.js's automatic processing and display: none. I had to change my approach fundamentally.

This wasn't just a simple issue with display: none. It was a problem where "the state of the DOM expected by the external library" and "my app's UI structure" did not match. widgets.js operates under the assumption that the "blockquote is visible." The Creators Dashboard operates under the assumption that "the second page is hidden by default." This mismatch in assumptions was the reason it wouldn't work even when using the official "correct" method.


Third Attempt: twttr.widgets.createTweet() — The Destination

A Shift in Perspective

Because I was placing the blockquote in the HTML, widgets.js was processing it automatically. Therefore, I should simply not place the blockquote at all.

twttr.widgets.createTweet() is an API that dynamically generates an embedded tweet from a post ID into a specified container. No blockquote is needed in the initial HTML, and I can control exactly when to call it from JavaScript.

Simplification of the Data Model

With the change in approach, I also significantly simplified the data model in the data file.

Field First Attempt (v1.2.2) Third Attempt (v1.2.4)
text Included Removed
mediaUrls Included Removed
mediaType Included Removed
postedAt Included Removed
author Included Removed
postId Included Included

Just the postId. The URL is derived on the front end from https://x.com/i/status/{postId}. The design was changed so that content retrieved via API (text and media) is not stored on the server.

{
  "social": {
    "x": {
      "postId": "2037208350895812662"
    }
  }
}

There are three advantages to createTweet():

  • Does not depend on the DOM — Since no blockquote is required, no automatic widgets.js processing occurs.
  • Execution timing can be controlled — Because it can be explicitly called after the tab is displayed, the display: none issue is avoided.
  • Does not retain post content — It works with just the postId, so there is no need to redistribute text or media.

By satisfying these three points simultaneously, the conflicts I mentioned at the beginning—regulations, DOM structure, and widgets.js assumptions—were all resolved.

SSR Output

On the server side, instead of a blockquote, I output an empty container with a data-tweet-id and a fallback link.

<div class="card-social-embed"
     data-tweet-id="2037208350895812662"
     data-tweet-url="https://x.com/i/status/2037208350895812662">
  <a class="card-social-fallback" href="https://x.com/..."
     target="_blank" rel="noopener noreferrer">
    View post on X →
  </a>
</div>

Since there is no blockquote, there is no target for widgets.js to process automatically.

Client-side JavaScript Implementation

The process is divided into four steps.

  1. DOMContentLoaded: Preload widgets.js (by injecting a <script> tag).
  2. Tab click → Page 2 changes to display: flex.
  3. renderTweetIn(container):
    • Retrieve the data-tweet-id.
    • Call twttr.widgets.createTweet(id, container, { dnt: true }).
    • Prevent double-rendering using a data-tweet-rendered flag.
  4. On createTweet failure → Revert to the fallback link.

The key point is separating the preloading of widgets.js from the rendering. I load the script at page startup but do not call createTweet() until the tab is displayed. This completely bypasses the display: none issue.

// Called after tab click → page 2 is displayed
function renderTweetIn(container) {
  var embed = container.querySelector('.card-social-embed')
  if (!embed || embed.getAttribute('data-tweet-rendered')) return
  var tweetId = embed.getAttribute('data-tweet-id')
  if (!tweetId) return

  embed.setAttribute('data-tweet-rendered', '1')

  window.twttr.widgets.createTweet(tweetId, embed, {
    dnt: true,
    theme: 'light'
  }).then(function (el) {
    if (el) {
      // Display embedded tweet with a fade-in effect
      el.style.opacity = '0'
      el.style.transition = 'opacity 0.4s ease-in'
      void el.offsetHeight
      el.style.opacity = '1'
    } else {
      // e.g., if the post has been deleted
      showFallback(embed)
    }
  })
}

dnt: true is the Do Not Track setting, which prevents the embedded tweet from being used to track users.

CSP Changes

createTweet() performs external script loading and iframe generation. I needed to add two directives to the Content Security Policy (CSP) header:

script-src: + https://platform.twitter.com
frame-src:  + https://platform.twitter.com

script-src allows loading widgets.js, and frame-src allows displaying the iframe generated by widgets.js. Both are necessary because widgets.js loads external scripts and then renders the embedded tweet within an iframe.

Note that due to X's domain consolidation (the migration to x.com), it may be necessary to add platform.x.com or cdn.syndication.twimg.com in the future. As of now, it works with platform.twitter.com alone.


Loading UX

Since createTweet() involves communication with an external server, the response can take several seconds. I designed the UX during that loading period.

Tab Pulse

While the embedded tweet is loading, the tab's background color gently pulses with X's brand color (Twitter Blue).

.card-tab--loading {
  animation: tab-pulse 1.8s ease-in-out infinite;
}
@keyframes tab-pulse {
  0%, 100% { background-color: transparent; }
  50% { background-color: rgba(29, 155, 240, 0.1); }
}

rgba(29, 155, 240, 0.1) is the X brand color at 10% opacity. I designed the effect to be subtle enough not to be distracting while clearly indicating that loading is in progress.

8-Second Timeout

To prepare for network latency or widgets.js response failures, I implemented an 8-second timeout. After the timeout, it displays a fallback link ("View post on X →") and a "Retry" button.

var timeout = setTimeout(function () {
  if (loadingTab) loadingTab.classList.remove('card-tab--loading')
  showFallback(embed)
}, 8000)

window.twttr.widgets.createTweet(tweetId, embed, { ... })
  .then(function (el) {
    clearTimeout(timeout)
    // Success processing or fallback
  })

Fallback Design

Since I am relying on an external widget, I needed to anticipate cases where it might not display. I designed responses for four different states.

State Display
JS Disabled Fallback link output via SSR
widgets.js blocked (Adblocker, etc.) Fallback link
Post deleted createTweet returns null → Fallback link + Retry button
Normal Official embedded tweet (iframe)

Since I output <a class="card-social-fallback"> during SSR, it functions as a link even in environments where JavaScript is disabled. If loading widgets.js fails (e.g., an ad blocker blocks platform.twitter.com), the fallback link remains in place.

I adopted a design philosophy of "if it breaks, it shows a link" rather than "if it breaks, nothing appears." External widgets break if designed under the assumption of "guaranteed success." By designing with failure in mind, you can finally achieve stability.


Evolution of Social Design in v1.3.0

The technical problems with embedding were resolved in v1.2.4. In v1.3.0, I further refined the data design.

Separating fix / latest

Type Purpose How to specify
fix Posts to pin/display Publisher manually provides postId
latest Auto-fetch latest post Search by account + query keywords

fix is a deliberate choice by the publisher to show a specific post, while latest is about operational automation to display the latest progress. Since their purposes differ, I separated the data models.


Lessons Learned from Three Pivots

Pivot Method Cause of Failure
1st Draft Custom CSS Rendering Risk of violating X's display policy
2nd Draft blockquote + widgets.js widgets.js cannot process in display: none
2nd Revised blockquote + load() Automatic widgets.js processing runs first
3rd Draft createTweet() Dynamic Generation Success

In all three cases, the essence of the failure was a "mismatch of assumptions." The 1st draft failed due to policy assumptions, while the 2nd and revised drafts had DOM state assumptions that clashed with my app's structure.

"It works if you follow the official documentation" only applies when the prerequisites are aligned. When integrating external widgets, I should have checked the DOM state expected by the widget against the DOM state my app actually provides in advance.


Conclusion

In this article, I shared the process of changing the implementation method three times for embedding X posts.

Planning, developing, and operating a website from scratch is a first for me, and I discover new challenges with every step. Terms of service, CSP, and the behavior of external widgets—all were things I could not have imagined beforehand. I hope to understand each one, address them, and slowly accumulate Web knowledge.

  • Withdrawal of Custom Rendering: The method of manually drawing text and media risked violating X's Terms of Service, so I simplified the data file to contain only the postId.
  • Incompatibility between display: none and widgets.js: The blockquote method does not function correctly within initially hidden elements. Even twttr.widgets.load() could not avoid this.
  • Resolution via createTweet(): By not placing a blockquote in the initial HTML and generating it dynamically after the tab is displayed, I avoided both the display issue and the widget's automatic processing.
  • Fallback Design: By outputting a link via SSR, the link remains functional regardless of whether JS is disabled, an ad blocker is present, or the post has been deleted.

In external widget integration, "consistency of prerequisites" is more important than the "official method." Is the DOM visible? When is the execution timing? Should it hold data or not? Unless these three points are aligned, it won't work even with the correct method.

Remaining Challenges

I am now able to embed X posts safely. However, displaying data received from an external source creates different security risks. Even if the postId is just a number, the path used to pass it to the DOM carries the risk of XSS.

In Act IV, I will take up the security design of 3-layer DOM XSS defense. I will discuss the story of structurally preventing external data from being executed as a script by performing validation at three points: entrance, middle, and exit.


Lastly, let me briefly introduce the service. The overall picture of Creators Dashboard was introduced in Act I.


Performance Index (Development Records via docs × AI)

*In this series, the trial-and-error process is likened to a "performance," and progress is described in "acts."

Part 7: Building Creators Dashboard (Planned for 5 Acts)

Part 1: docs × AI (5 Chapters)

Part 2: Organizing Conversation Logs (6 Acts + Intermission)

Part 3: Workflow and Task Management (4 Acts)

Part 4: Building My First App (6 Acts + Curtain Call)

Part 5: The Aftermath of the SLM Pipeline (3 Acts)

Part 6: Leveraging SLM in Production (TBD)


Created: 2026-04-03
Source: pg10 docs/architecture.md (v1.2.4), docs/design.md, docs/versions/v1.2.2.md, AI conversation summaries slm.2026-03-27.claude.01-02, slm.2026-03-28.claude.01

Article Creation Process

  • Plotting: Claude Code (Material research + structural proposal)
  • Initial Drafting: Claude Code
  • First Review: Me
  • Editing: ChatGPT
  • Pre-publication: Zenn AI
  • Post-publication Review: NotebookLM

*Used pg10's design documents (architecture.md, design.md, v1.2.2.md), implementation code (card-stack.js, card.tsx, app.tsx, main.css), 3 sessions of AI conversation summaries, and 2 sessions of raw logs as materials.

Discussion