Integrating Tweets: Twitter API, Seed Data, and the Unicode Page
- Setting Up the Foundation: Next.js, Tailwind, and Contentlayer
- Building the Layout Shell: Navigation, Footer, and Shared Components
- Creating the Blog: MDX Content, Posts, and Category Filtering
- Polishing the Experience: Animations, Loading States, and Accessibility
- Adding Video Support: YouTube Integration and Feature Flags
- Integrating Tweets: Twitter API, Seed Data, and the Unicode Page
- Theming the Site: CSS Variables, Theme Presets, and a Settings Page
- Going Multilingual: English and Chinese Content with Language Switching
- The Backend: Prisma, API Routes, Metrics, and the Like Button
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.
- Setting Up the Foundation: Next.js, Tailwind, and Contentlayer
- Building the Layout Shell: Navigation, Footer, and Shared Components
- Creating the Blog: MDX Content, Posts, and Category Filtering
- Polishing the Experience: Animations, Loading States, and Accessibility
- Adding Video Support: YouTube Integration and Feature Flags
- Integrating Tweets: Twitter API, Seed Data, and the Unicode Page
- Theming the Site: CSS Variables, Theme Presets, and a Settings Page
- Going Multilingual: English and Chinese Content with Language Switching
- The Backend: Prisma, API Routes, Metrics, and the Like Button