Integrating Tweets: Twitter API, Seed Data, and the Unicode Page

Jun 6
·

After building the video feature, I wanted to add another content type — tweets. Not a Twitter clone, but a curated collection of tweets that capture sentiments I want to remember. Think of it as a digital commonplace book for Twitter.

The pattern is similar to what we did for videos: a content type, an API client, seed data for development, and a dedicated page. But tweets have their own complexities — recursive quote tweets, media attachments, and link previews.

Tweet Component + Twitter Lib + Seed Data

The Twitter API Client

I started by building the Twitter API client in lib/twitter.ts. The Twitter API v2 returns a deeply nested response with users, media, and referenced tweets all in separate includes objects. My job was to flatten that into something the UI could consume.

The key type is FormattedTweet — a clean, flat structure that the Tweet component can render directly:

export type FormattedTweet = {
  id: string
  text: string
  createdAt: string
  likeUrl: string
  retweetUrl: string
  replyUrl: string
  tweetUrl: string
  author: {
    name: string
    authorUrl: string
    imageUrl: string
    username: string
    verified: boolean
  }
  quoteTweet?: FormattedTweet
  linkPreview?: Url
  type?: LinkedTweetType
  media?: Media[]
}

Notice that quoteTweet is recursive — a FormattedTweet that contains another FormattedTweet. This handles the common pattern of quoting a tweet that itself quotes another tweet.

The main export is getTweets(ids: string[]). It follows the same seed data pattern as the video feature — when no TWITTER_BEARER_TOKEN is configured, it falls back to mock data:

export const getTweets = async (ids: string[]) => {
  if (ids.length === 0) return []
 
  // Use seed data when no Twitter bearer token is configured
  if (!process.env.TWITTER_BEARER_TOKEN) {
    return ids.map((id) => seedData.find((t) => t.id === id)).filter(Boolean) as FormattedTweet[]
  }
 
  // Fetch from Twitter API v2
  const queryParams = encode({
    ids: ids.join(","),
    expansions: ["author_id", "attachments.media_keys", "referenced_tweets.id"].join(","),
    "tweet.fields": ["id", "author_id", "created_at", "text", "entities"].join(","),
    "user.fields": ["id", "name", "profile_image_url", "username", "verified"].join(","),
    "media.fields": ["media_key", "type", "height", "width", "url", "preview_image_url"].join(","),
  })
 
  const api: ApiResponse = await fetch(`https://api.twitter.com/2/tweets?${queryParams}`, {
    headers: { Authorization: `Bearer ${process.env.TWITTER_BEARER_TOKEN}` },
  }).then((x) => x.json())
 
  // ... format each tweet
}

The formatTweet helper cleans up the raw API response — it strips URLs that are just link previews, formats dates, and resolves author information from the includes.users array.

The Tweet Component

The Tweet component in ui/Tweet.tsx renders a single tweet card. It handles several content types:

  • Plain text tweets — just the text
  • Tweets with media — images rendered via BlurImage
  • Quote tweets — recursive rendering (a tweet inside a tweet)
  • Link previews — title, description, and display URL

Here's the core structure:

export const Tweet = ({ text, author, media, createdAt, quoteTweet, linkPreview, ... }) => {
  return (
    <div className="rounded-2xl bg-card p-6 shadow-sm">
      {/* Author meta */}
      <div className="flex items-start text-lg">
        <a href={author.authorUrl} className="group flex items-center truncate">
          <Image alt={author.username} height={48} width={48} src={author.imageUrl} className="rounded-full" />
          <span className="font-medium text-foreground">{author.name}</span>
          {author.verified ? <BadgeCheckIcon className="ml-0.5 h-5 w-5 text-blue-400" /> : null}
          <span className="ml-1.5 text-muted-foreground">@{author.username}</span>
        </a>
      </div>
 
      {/* Text */}
      <div className="mt-2 whitespace-pre-wrap text-base text-foreground/80">{text}</div>
 
      {/* Media */}
      {media?.map((m) => <BlurImage key={m.media_key} src={m.preview_image_url || m.url} />)}
 
      {/* Recursive quote tweet */}
      {quoteTweet ? <Tweet {...quoteTweet} /> : null}
    </div>
  )
}

The verified badge uses BadgeCheckIcon from Heroicons — a small blue checkmark next to the author's name. It's a tiny detail but it makes the tweets feel authentic.

Seed Data

The seed data in data/tweet-seed.json contains 5-6 sample tweets with various content types — some with just text, some with media, some with quote tweets. This lets me develop the entire tweets feature without needing a Twitter API key.

Tweets Page + Revalidate + Unicode

The Tweets Page

The tweets page at pages/tweets.tsx is straightforward. It loads tweet IDs from GitHub GraphQL (a project board where I store tweet URLs), fetches the tweet data, and renders them in a list:

export const getStaticProps = async () => {
  let tweets = []
 
  if (process.env.GITHUB_PERSONAL_ACCESS_TOKEN) {
    // Fetch tweet IDs from GitHub GraphQL
    const response = await fetch("https://api.github.com/graphql", {
      method: "POST",
      headers: { Authorization: `Bearer ${process.env.GITHUB_PERSONAL_ACCESS_TOKEN}` },
      body: JSON.stringify({
        query: `query ($columnId: ID!) {
          node(id: $columnId) {
            ... on ProjectColumn { cards { nodes { note } } }
          }
        }`,
        variables: { columnId: "your-project-column-id" },
      }),
    }).then((res) => res.json())
 
    const tweetIds = response?.data?.node?.cards?.nodes?.map((card) => card.note) ?? []
    if (tweetIds.length > 0) tweets = await getTweets(tweetIds)
  }
 
  // Fallback to seed data
  if (tweets.length === 0) tweets = seedData
 
  return { props: { tweets } }
}

The page renders with the standard Layout wrapper:

export default function TweetsPage({ tweets }) {
  return (
    <Layout>
      <div>
        <h1 className="text-foreground text-3xl font-medium lg:text-4xl">Inspired Tweets</h1>
        <div className="text-foreground/40 mt-2 text-lg">
          Tweets that capture a sentiment I'd like to remember
        </div>
      </div>
      <div className="mt-16 space-y-14">
        {tweets.map((tweet) => (
          <Tweet key={tweet.id} {...tweet} />
        ))}
      </div>
    </Layout>
  )
}

ISR Revalidation

I also created an API route at pages/api/tweets/revalidate.ts for Incremental Static Regeneration. When new tweets are added to the GitHub project board, a webhook can hit this endpoint to re-generate the tweets page without a full rebuild:

export default async function handler(req, res) {
  if (req.body.secret !== process.env.REVALIDATE_SECRET) {
    return res.status(401).json({ message: "Invalid secret" })
  }
  await res.revalidate("/tweets")
  return res.json({ revalidated: true })
}

The Unicode Page

As a bonus, I created a standalone unicode character reference page at pages/unicode.tsx. It's completely independent of the other features — no API calls, no content types, just a utility page I find useful.

Unicode Page Features

The unicode page started simple but grew into a handy tool:

  • Search — filter characters by keyword (e.g., "arrow", "heart", "math")
  • Click-to-copy — click any character to copy it to clipboard via navigator.clipboard.writeText
  • Keyboard navigation — tab to select, enter to copy
  • Visual feedback — border for selected state, solid color for currently copied
  • 152 characters across 10 categories — from basic symbols to mathematical operators

The grouping with section headers makes it easy to browse. It's one of those pages that's surprisingly useful when you need it.

Tweets Feature Flag

Following the same pattern as the video feature, I added a siteConfig.features.tweets flag:

features: {
  video: false,
  tweets: false,  // Set to true to enable tweets page and navigation
}

When the flag is false:

  • The "Twitter" link is hidden from the navigation bar
  • The "Twitter" link is hidden from the footer
  • The tweets page still exists (you can navigate directly) but it's not prominently linked

This is the same feature flag pattern we used for videos — the code is always there, just the visibility is controlled. It's useful for work-in-progress features or for deployments where certain content types aren't ready yet.


Phase 6 is the big one — theming the entire site with CSS variables, theme presets, and a dedicated settings page. It's where the visual identity really comes together.