<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>OnlyLinks Blog</title>
    <link>https://onlylinks.com/blogs</link>
    <description>Updates, guides, and insights from the OnlyLinks team. Learn how to optimize your link in bio and grow your online presence.</description>
    <language>en-US</language>
    <copyright>Copyright 2026 OnlyLinks</copyright>
    <managingEditor>team@onlylinks.com (OnlyLinks Team)</managingEditor>
    <webMaster>team@onlylinks.com (OnlyLinks Team)</webMaster>
    <lastBuildDate>Thu, 04 Jun 2026 01:56:13 GMT</lastBuildDate>
    <atom:link href="https://onlylinks.com/blogs/rss" rel="self" type="application/rss+xml" />
    <image>
      <url>https://onlylinks.com/new_logo.png</url>
      <title>OnlyLinks Blog</title>
      <link>https://onlylinks.com/blogs</link>
      <width>512</width>
      <height>512</height>
    </image>
    
    <item>
      <title><![CDATA[OnlyLinks v2: Our Biggest Update Yet]]></title>
      <link>https://onlylinks.com/blogs/onlylinks-v2-our-biggest-update-yet</link>
      <guid isPermaLink="true">https://onlylinks.com/blogs/onlylinks-v2-our-biggest-update-yet</guid>
      <pubDate>Tue, 18 Nov 2025 04:33:25 GMT</pubDate>
      <description><![CDATA[Spent the last 1.5 months rebuilding OnlyLinks with the team from a flat, gray CRM style dashboard into a colorful, gradient driven design system, with smoother layouts, better motion, and new surfaces like Alerts and Custom Domains. This post walks through the overhaul, but the real upgrade is something you have to click around and feel yourself.]]></description>
      <content:encoded><![CDATA[<h1>From Gray CRM To Living Product</h1>
<p><em>Written by Charbel Habchy • Published November 17 2025</em></p>
<p>For the last ~1.5 months I have been working with the OnlyLinks team on something I have wanted for a long time:</p>
<p>Turn OnlyLinks from a boring, gray, CRM looking tool into a product that actually feels alive.</p>
<p>Same core idea. Completely new UI and UX.</p>
<p>The funny part is that v1 did not come from a bad place. It was the classic move: ship something fast, prove it works, keep adding features. Over time we ended up with a product that functioned well, but visually felt like every other dashboard on the internet.</p>
<p>v2 is where we finally paused, zoomed out, and rebuilt the experience to match what OnlyLinks actually is.</p>
<hr>
<h2>What Was Wrong With v1</h2>
<p>v1 did its job, but visually it had a few big problems:</p>
<ul>
<li>It looked like a generic admin dashboard template  </li>
<li>Lots of flat gray and white surfaces  </li>
<li>Thin borders and basic cards that all felt the same  </li>
<li>Navigation that felt very "tool list", not a real system</li>
</ul>
<p>If you squinted, you could swap the logo and it might as well be any random CRM.</p>
<p>There was also a subtle issue: the interface was not telling you what mattered. Metrics, tables, and buttons all sat on the same visual level. You could accomplish tasks, but the UI was not guiding you emotionally or visually.</p>
<p>Creators and teams are building their brands in OnlyLinks. The product did not feel like it belonged in that world yet.</p>
<hr>
<h2>Design Goals For v2</h2>
<p>When we started the redesign, we set a few simple goals that we kept coming back to in every review:</p>
<ol>
<li><p><strong>Make OnlyLinks feel like a brand, not a template</strong><br>Color, gradients, and motion should be baked into the core language, not just dropped on top at the end.</p>
</li>
<li><p><strong>Build one consistent design system</strong><br>Same spacing, same corner radius, same shadows, same motion rules everywhere, so every new feature feels native from day one.</p>
</li>
<li><p><strong>Make the app feel lighter and more human</strong><br>Less "rows in a database", more "control center for your presence", with surfaces that feel modern instead of corporate.</p>
</li>
<li><p><strong>Use layout to guide people, not confuse them</strong><br>Clear sections for Organizations, personal tools, and admin features so your brain always knows where it is in the product.</p>
</li>
</ol>
<p>The entire redesign was basically us asking one question over and over:</p>
<blockquote>
<p>If I saw this screen without a logo, would I still know it is OnlyLinks?</p>
</blockquote>
<p>If the answer was no, we kept iterating.</p>
<hr>
<h2>The OnlyLinks Brand Guidelines And Design Language</h2>
<p>One of the biggest pieces behind v2 is something you do not see on any single screen by itself:</p>
<p>The brand language that sits underneath everything.</p>
<p>Before we touched the product, I sat down and designed a full visual and interaction language for OnlyLinks from the ground up. That work now lives on our public brand guidelines page:</p>
<p><a href="https://onlylinks.com/brand-guidelines">onlylinks.com/brand-guidelines</a></p>
<p>This is not just a moodboard or a color palette. It is the source of truth for how OnlyLinks should look and feel everywhere:</p>
<ul>
<li>The core gradient fields and how they blend into backgrounds  </li>
<li>The glassy surfaces, shadows, and elevation levels  </li>
<li>The typography hierarchy, from big hero headlines down to tiny labels  </li>
<li>The motion rules for how things enter, exit, and react  </li>
<li>The spacing and layout rhythm that keeps screens feeling balanced</li>
</ul>
<p>Every decision in v2 starts there. If a new screen or component does not fit the language in the brand guidelines, it is wrong until we fix it.</p>
<p>The goal was to create a design language that is recognizably OnlyLinks even without the logo:</p>
<ul>
<li>Soft but sharp  </li>
<li>Gradient heavy but still clean  </li>
<li>Modern and fluid without feeling like a Dribbble shot pasted into a real product</li>
</ul>
<p>The v2 UI is basically that brand system brought to life across the entire app.</p>
<hr>
<h2>Color, Gradients, And Depth</h2>
<p>The biggest visual jump is the color.</p>
<ul>
<li>Backgrounds are no longer just gray. We use soft gradients and light color washes so the app feels like it has an atmosphere.  </li>
<li>Primary CTAs live on rich, smooth gradients that tie back to the OnlyLinks brand.  </li>
<li>Cards sit on top with subtle shadows and rounded corners, so the interface has real depth instead of feeling stamped on.</li>
</ul>
<p>Under the hood, this is all driven by a tighter color system:</p>
<ul>
<li>A primary gradient set used for navigation and key actions  </li>
<li>A gentle neutral set for backgrounds and card surfaces  </li>
<li>Accent colors reserved for things like alerts, status, and charts</li>
</ul>
<p>Nothing is neon or loud for no reason. The color is there to show hierarchy and focus, not just to look pretty.</p>
<p>Depth also got a proper structure. Instead of random shadows, we use a small set of elevation levels:</p>
<ul>
<li>Base background (almost flat, very soft)  </li>
<li>Cards (slightly lifted)  </li>
<li>Overlays and modals (more lifted, but still soft on the edges)</li>
</ul>
<p>It keeps everything feeling consistent and helps your brain understand which layer it is interacting with.</p>
<hr>
<h2>Layout And Navigation</h2>
<p>We also ripped apart the layout and rebuilt it in a way that matches how people actually use OnlyLinks.</p>
<ul>
<li>"Agencies" are now <strong>Organizations</strong>, which makes more sense for both teams and brands.  </li>
<li>The sidebar is split into clear sections: Organization, Personal, Settings &amp; Tools, and Admin.  </li>
<li>The active item uses a strong gradient pill, so you always know where you are.</li>
</ul>
<p>On the page level:</p>
<ul>
<li>Hero sections at the top give you the key context for where you are and what you are managing.  </li>
<li>Below that, cards and tables are arranged so your eyes flow from most important to supporting details.  </li>
<li>Multi step flows, like onboarding, use clean progress indicators so the path is obvious.</li>
</ul>
<p>We also paid attention to how layouts collapse and stretch:</p>
<ul>
<li>On larger screens, content breathes and forms split into logical columns.  </li>
<li>On smaller screens, everything stacks in a clean, linear order so you are not hunting for inputs or buttons.</li>
</ul>
<p>The app feels less like "a lot of pages" and more like one connected workspace that just happens to adapt to whatever screen you are on.</p>
<hr>
<h2>Motion And Micro Interactions</h2>
<p>We started using motion as part of the brand, not just as a random effect.</p>
<ul>
<li>Hover states give clear feedback without feeling jittery.  </li>
<li>Modals and drawers ease in and out with natural timing, so nothing feels like it teleports.  </li>
<li>Small transitions on tabs, filters, and toggles make the interface feel responsive and modern.</li>
</ul>
<p>We treated motion like seasoning:</p>
<ul>
<li>Enough to make the interface feel alive  </li>
<li>Not so much that it slows you down or makes you dizzy</li>
</ul>
<p>If an animation ever felt like it was showing off in the middle of real work, we toned it down.</p>
<p>Over time, these tiny interactions add up. The product feels smoother, more intentional, and less like a static HTML admin panel.</p>
<hr>
<h2>Building A Real Design System</h2>
<p>One big thing that changed behind the scenes is how we think about components.</p>
<p>In v1, a lot of UI was built in a one off way:</p>
<ul>
<li>A card over here  </li>
<li>A button variation over there  </li>
<li>A special table style for one screen</li>
</ul>
<p>In v2, we treated everything as part of a design system:</p>
<ul>
<li>Buttons share the same core structure and states across the entire app  </li>
<li>Cards reuse the same spacing, radius, and elevation tokens  </li>
<li>Inputs, selects, and toggles follow one clear style instead of five half related ones</li>
</ul>
<p>This means:</p>
<ul>
<li>Future features like new dashboards or tools can be built faster  </li>
<li>The visual language stays consistent even as the product grows  </li>
<li>The app feels more trustworthy because nothing looks out of place</li>
</ul>
<p>You might not see this directly as a user, but you feel it every time a new feature shows up and it just blends in.</p>
<hr>
<h2>New Surfaces: Alerts And Custom Domains</h2>
<p>During the redesign we also used the new design system to introduce new features.</p>
<h3>Alerts</h3>
<p>Alerts needed to be noticeable without feeling aggressive.</p>
<ul>
<li>We designed alert surfaces as colored cards that sit inside the layout, not ugly banners slapped on top.  </li>
<li>Icons, short titles, and clear actions keep them readable at a glance.  </li>
<li>They follow the same spacing, radius, and shadow rules as the rest of the system, so they feel native to the product.</li>
</ul>
<p>You get important information in context, without the UI screaming at you or breaking the flow of the page.</p>
<h3>Custom Domains</h3>
<p>Custom domains are a big part of making a profile feel like a real brand. The old experience for this was very utilitarian and almost hidden.</p>
<p>Now:</p>
<ul>
<li>The custom domains area has its own section and card layout, so it feels like a first class part of the product.  </li>
<li>Inputs, status badges, and connection states are all designed with the same gradient and glassy language as the rest of v2.  </li>
<li>Success and error states use consistent colors and messages, so you know exactly what is happening with your domain.</li>
</ul>
<p>It feels like part of a professional brand tool, not a configuration checkbox buried in settings.</p>
<hr>
<h2>Working On This With The OnlyLinks Team</h2>
<p>This was not a solo design sprint. The last 1.5 months have been a loop of:</p>
<ul>
<li>Me exploring layouts, flows, and visual directions  </li>
<li>The team poking holes in them, pointing out edge cases, and bringing real user needs into the conversation  </li>
<li>Iterating on the details until the screens felt right both visually and practically</li>
</ul>
<p>A lot of the best changes came from tiny comments like:</p>
<ul>
<li>"What if this stat was promoted to the top instead of buried?"  </li>
<li>"This looks cool but will confuse a new user, can we simplify it?"  </li>
<li>"This section should feel more important, it looks like a secondary card right now."</li>
</ul>
<p>The end result is a product that looks like something I would design, but also behaves like something a whole team pressure tested.</p>
<hr>
<h2>Why This Redesign Matters</h2>
<p>This was not a "change a few colors" update. We rebuilt the UI and UX from the ground up so that:</p>
<ul>
<li>The interface finally matches the level of polish creators expect.  </li>
<li>Organizations can manage real businesses in a space that feels premium, not like a random CRM.  </li>
<li>New features like Alerts and Custom Domains drop into a strong design system instead of creating more visual noise.  </li>
<li>The product feels like a single, coherent OnlyLinks experience, not a folder of disconnected pages.</li>
</ul>
<p>Honestly, this post does not even scratch the surface of all the tiny layout tweaks, motion changes, and new surfaces that went into v2. There are a lot of little details you only notice when you use it in real life.</p>
<p>If you want to really feel the difference, you will just have to jump in and click around OnlyLinks yourself.</p>
<hr>
<p><em>Written by Charbel Habchy • Published November 17 2025</em></p>
]]></content:encoded>
      <category><![CDATA[News]]></category>
      <enclosure url="https://cdn.onlylinks.com/media/5u8ChgBVQXN3Kc3rFmyGNP1ega3UILUB/MixCollage-17-Nov-2025-11-22-PM-5868.jpg" type="image/jpeg" />
    </item>
    <item>
      <title><![CDATA[Building Analytics That Scale: Our PostHog + BigQuery Architecture ]]></title>
      <link>https://onlylinks.com/blogs/building-analytics-2</link>
      <guid isPermaLink="true">https://onlylinks.com/blogs/building-analytics-2</guid>
      <pubDate>Sat, 08 Nov 2025 06:46:35 GMT</pubDate>
      <description><![CDATA[This post explores how OnlyLinks built a high-performance analytics system that processes 10M+ events per day with sub-second query times and 99.9% uptime—all while keeping costs low. ]]></description>
      <content:encoded><![CDATA[<h1>Building Analytics That Actually Scale: Our PostHog + BigQuery Architecture</h1>
<p>When we set out to build OnlyLinks, we knew analytics would be mission-critical. Content creators need real-time insights into their traffic, clicks, and audience behavior—and they need it fast. But traditional analytics solutions either didn't scale well or required rebuilding the wheel from scratch.</p>
<p>Here's how we built an analytics system that handles millions of events while keeping queries blazing fast and costs under control.</p>
<h2>The Hybrid Architecture: Best of Both Worlds</h2>
<p>Most teams choose either a hosted analytics platform (like PostHog, Mixpanel, or Amplitude) or a data warehouse (like BigQuery, Snowflake, or Redshift). We chose both—and that makes all the difference.</p>
<p><strong>PostHog handles event capture</strong>, giving us:</p>
<ul>
<li>Battle-tested SDKs for client and server-side tracking</li>
<li>Automatic session recording and dead click detection</li>
<li>Rich event properties with GeoIP lookup, device detection, and browser fingerprinting</li>
<li>A powerful ingestion pipeline that never drops events</li>
</ul>
<p><strong>BigQuery handles queries</strong>, giving us:</p>
<ul>
<li>Sub-second queries across billions of rows</li>
<li>Materialized views that pre-aggregate data</li>
<li>Clustering and partitioning for optimal performance</li>
<li>Cost-effective storage with columnar compression</li>
</ul>
<p>This separation of concerns is key. PostHog is optimized for capturing <em>every single event</em> reliably, while BigQuery is optimized for <em>querying that data</em> efficiently.</p>
<h2>The Secret Sauce: Materialized Views</h2>
<p>Here's where things get interesting. Raw event data is flexible but slow to query. Traditional analytics platforms force you to define metrics upfront. We found a middle ground: <strong>materialized views with intelligent clustering</strong>.</p>
<p>We maintain several pre-aggregated views that transform raw events into queryable metrics:</p>
<h3>1. Hourly Traffic Aggregation</h3>
<pre><code class="language-sql">CREATE MATERIALIZED VIEW analytics.hourly_traffic
CLUSTER BY hour_timestamp, user_path
AS
SELECT
  TIMESTAMP_TRUNC(event_timestamp, HOUR) AS hour_timestamp,
  user_path,
  COUNT(*) AS view_count,
  COUNT(DISTINCT session_id) AS unique_sessions
FROM analytics.raw_events
WHERE event_type = 'page_view'
GROUP BY hour_timestamp, user_path;
</code></pre>
<p><strong>Why this matters:</strong> Clustering by <code>hour_timestamp</code> and <code>user_path</code> means queries filtering by time range and user are <em>ridiculously fast</em>. BigQuery can skip entire data blocks that don't match your query.</p>
<h3>2. Geographic &amp; Device Analytics</h3>
<pre><code class="language-sql">CREATE MATERIALIZED VIEW analytics.audience_distribution
CLUSTER BY date, user_path, country_code, device_category
AS
SELECT
  DATE(event_timestamp) AS date,
  user_path,
  COALESCE(geo_country, 'Unknown') AS country_code,
  COALESCE(device_type, 'Unknown') AS device_category,
  COALESCE(browser_name, 'Unknown') AS browser_name,
  COUNT(*) AS event_count
FROM analytics.raw_events
WHERE event_type = 'page_view'
GROUP BY date, user_path, country_code, device_category, browser_name;
</code></pre>
<p><strong>The clustering strategy is crucial.</strong> BigQuery allows a maximum of 4 clustering fields, so we prioritized based on our most common query patterns:</p>
<ol>
<li><code>date</code> - nearly all queries filter by date range</li>
<li><code>user_path</code> - filtering by specific users/profiles</li>
<li><code>country_code</code> - geographic analytics are frequently requested</li>
<li><code>device_category</code> - device breakdowns are common</li>
</ol>
<p>Browser is included but not clustered—a deliberate trade-off we made after analyzing our query patterns.</p>
<h3>3. Link Engagement Tracking</h3>
<pre><code class="language-sql">CREATE MATERIALIZED VIEW analytics.link_performance
CLUSTER BY date, user_path, destination_url
AS
SELECT
  DATE(event_timestamp) AS date,
  user_path,
  destination_url,
  link_title,
  COUNT(*) AS click_count,
  COUNT(DISTINCT session_id) AS unique_clickers
FROM analytics.raw_events
WHERE event_type = 'link_click'
GROUP BY date, user_path, destination_url, link_title;
</code></pre>
<p>This powers our "Top Links" analytics, showing creators which specific links are driving the most engagement.</p>
<h2>Redis Caching: The Performance Multiplier</h2>
<p>Even with optimized BigQuery queries, hitting the database on every request adds latency. That's where Redis comes in—but not in the way you might expect.</p>
<p>We use <strong>multi-level caching with intelligent invalidation:</strong></p>
<pre><code class="language-typescript">async function getAnalyticsDashboard(userId: string, timeRange: string) {
  const cacheKey = `analytics:${userId}:${timeRange}`;
  
  // Try Redis first
  const cached = await redis.get(cacheKey);
  if (cached) {
    return JSON.parse(cached);
  }

  // Cache miss - query BigQuery
  const data = await queryBigQueryForDashboard(userId, timeRange);
  
  // Cache for 5 minutes
  await redis.setex(cacheKey, 300, JSON.stringify(data));
  
  return data;
}
</code></pre>
<p><strong>Key insight:</strong> We cache at the <em>API response level</em>, not individual query results. This means:</p>
<ul>
<li>A single cache hit returns the entire analytics dashboard</li>
<li>Cache invalidation is simple—just a TTL</li>
<li>No complex dependency tracking between queries</li>
</ul>
<p>For real-time campaign tracking, we implemented <strong>tiered caching</strong> with fallback behavior:</p>
<pre><code class="language-typescript">async function getCampaignMetrics(campaignId: string) {
  const inMemoryCache = memoryCache.get(campaignId);
  
  // Try in-memory cache first (faster but shorter TTL)
  if (inMemoryCache &amp;&amp; Date.now() - inMemoryCache.timestamp &lt; 60000) {
    return inMemoryCache.data;
  }
  
  // Try Redis cache
  const redisCache = await redis.get(`campaign:${campaignId}`);
  if (redisCache) {
    const data = JSON.parse(redisCache);
    memoryCache.set(campaignId, { data, timestamp: Date.now() });
    return data;
  }
  
  // Query database
  const freshData = await queryDatabase(campaignId);
  
  // Update both caches
  await redis.setex(`campaign:${campaignId}`, 300, JSON.stringify(freshData));
  memoryCache.set(campaignId, { data: freshData, timestamp: Date.now() });
  
  return freshData;
}
</code></pre>
<p>This ensures <strong>analytics are always available</strong>, even during database maintenance or temporary outages.</p>
<h2>Batch Querying: From N Queries to 1</h2>
<p>Here's a problem we ran into early: agency dashboards showing analytics for 100+ creators would make 100+ separate BigQuery queries. Even with caching, cold loads were brutally slow.</p>
<p>Our solution? <strong>Batch everything into a single query using CTEs and array parameters.</strong></p>
<pre><code class="language-sql">WITH user_pageviews AS (
  SELECT
    user_path,
    SUM(view_count) AS total_views,
    SUM(unique_sessions) AS total_sessions
  FROM analytics.hourly_traffic
  WHERE user_path IN UNNEST(@user_paths)
    AND hour_timestamp &gt;= @start_date
    AND hour_timestamp &lt; @end_date
  GROUP BY user_path
),
user_clicks AS (
  SELECT
    user_path,
    SUM(click_count) AS total_clicks,
    COUNT(DISTINCT destination_url) AS unique_links
  FROM analytics.link_performance
  WHERE user_path IN UNNEST(@user_paths)
    AND date &gt;= @start_date
    AND date &lt; @end_date
  GROUP BY user_path
),
combined_metrics AS (
  SELECT
    pv.user_path,
    pv.total_views,
    pv.total_sessions,
    cl.total_clicks,
    cl.unique_links,
    SAFE_DIVIDE(cl.total_clicks, pv.total_views) * 100 AS ctr
  FROM user_pageviews pv
  LEFT JOIN user_clicks cl USING (user_path)
)
SELECT * FROM combined_metrics
ORDER BY total_views DESC;
</code></pre>
<p>Then in our application code:</p>
<pre><code class="language-typescript">async function getAgencyDashboard(agencyId: string, period: string) {
  // Get all creator paths for this agency
  const creators = await db.getAgencyCreators(agencyId);
  const userPaths = creators.map(c =&gt; c.path);
  
  // Single batched query instead of N queries
  const results = await bigquery.query({
    query: BATCH_ANALYTICS_QUERY,
    params: {
      user_paths: userPaths,
      start_date: getStartDate(period),
      end_date: new Date()
    }
  });
  
  return processResults(results);
}
</code></pre>
<p><strong>The impact:</strong> Agency dashboards went from <strong>15-30 seconds</strong> to load down to <strong>under 2 seconds</strong>. And because we're processing data in BigQuery instead of in our application servers, we use far less memory and CPU.</p>
<h2>Intelligent Anomaly Detection</h2>
<p>Here's something most analytics platforms don't do: <strong>proactive alerting for traffic anomalies.</strong></p>
<p>We built automated detection that runs daily to identify unusual patterns:</p>
<h3>Traffic Spike &amp; Drop Detection</h3>
<pre><code class="language-sql">WITH daily_stats AS (
  SELECT
    user_path,
    DATE(hour_timestamp) AS date,
    SUM(view_count) AS daily_views
  FROM analytics.hourly_traffic
  WHERE hour_timestamp &gt;= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 14 DAY)
  GROUP BY user_path, date
),
baseline_comparison AS (
  SELECT
    user_path,
    AVG(CASE WHEN date &lt; CURRENT_DATE() THEN daily_views END) AS avg_views,
    MAX(CASE WHEN date = CURRENT_DATE() THEN daily_views END) AS today_views
  FROM daily_stats
  GROUP BY user_path
  HAVING avg_views &gt; 100  -- Only users with meaningful traffic
),
anomaly_detection AS (
  SELECT
    user_path,
    today_views,
    avg_views,
    ((today_views - avg_views) / avg_views) * 100 AS percent_change,
    CASE
      WHEN today_views &lt; avg_views * 0.5 THEN 'DROP'
      WHEN today_views &gt; avg_views * 2.0 THEN 'SURGE'
      ELSE 'NORMAL'
    END AS alert_type
  FROM baseline_comparison
)
SELECT *
FROM anomaly_detection
WHERE alert_type != 'NORMAL';
</code></pre>
<p>This runs daily and alerts creators when something unusual happens—like a viral post driving 10x traffic, or a technical issue causing views to plummet.</p>
<h3>Click-Through Rate Monitoring</h3>
<pre><code class="language-sql">WITH daily_metrics AS (
  SELECT
    user_path,
    DATE(hour_timestamp) AS date,
    SUM(view_count) AS views,
    COALESCE(
      (SELECT SUM(click_count) 
       FROM analytics.link_performance lp 
       WHERE lp.user_path = ht.user_path 
         AND lp.date = DATE(ht.hour_timestamp)),
      0
    ) AS clicks
  FROM analytics.hourly_traffic ht
  WHERE hour_timestamp &gt;= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 14 DAY)
  GROUP BY user_path, date
),
ctr_analysis AS (
  SELECT
    user_path,
    AVG(SAFE_DIVIDE(clicks, views) * 100) AS avg_ctr,
    MAX(CASE WHEN date = CURRENT_DATE() 
        THEN SAFE_DIVIDE(clicks, views) * 100 
        END) AS today_ctr
  FROM daily_metrics
  WHERE views &gt; 50  -- Statistical significance threshold
  GROUP BY user_path
)
SELECT
  user_path,
  today_ctr,
  avg_ctr,
  ((today_ctr - avg_ctr) / avg_ctr) * 100 AS ctr_change_percent
FROM ctr_analysis
WHERE ABS((today_ctr - avg_ctr) / avg_ctr) &gt; 0.5  -- 50% change threshold
ORDER BY ABS((today_ctr - avg_ctr) / avg_ctr) DESC;
</code></pre>
<p><strong>Why this matters:</strong> A creator's CTR dropping 50% might indicate broken links, confusing copy, or audience mismatch. Catching this early helps creators optimize their pages before losing significant engagement.</p>
<h2>Performance Monitoring Built-In</h2>
<p>Every query logs its resource usage to help us optimize costs:</p>
<pre><code class="language-typescript">async function executeMonitoredQuery(sql: string, params: any) {
  const startTime = Date.now();
  
  const [job] = await bigquery.createQueryJob({ query: sql, params });
  const [rows] = await job.getQueryResults();
  const [metadata] = await job.getMetadata();
  
  const stats = {
    duration: Date.now() - startTime,
    bytesProcessed: metadata.statistics.totalBytesProcessed,
    bytesBilled: metadata.statistics.totalBytesBilled,
    rowsReturned: rows.length
  };
  
  // Log to monitoring service
  logger.info('BigQuery execution', {
    query: sql.substring(0, 100),
    ...stats,
    costEstimate: (stats.bytesBilled / 1e12) * 5 // $5 per TB
  });
  
  // Alert if query is expensive
  if (stats.bytesBilled &gt; 1e9) { // &gt; 1GB
    alerts.notify('Expensive query detected', stats);
  }
  
  return rows;
}
</code></pre>
<p>This gives us <strong>real-time visibility</strong> into query costs. We've optimized several queries by 90%+ just by reviewing these logs.</p>
<p>For example, we discovered that adding a date filter reduced bytes processed from 500 MB to 50 MB for a common dashboard query—saving ~$0.002 per query, which adds up to $120/month at our query volume.</p>
<h2>Parallel Execution with Graceful Degradation</h2>
<p>When loading a dashboard, we fetch <strong>all metrics in parallel</strong> with individual error handling:</p>
<pre><code class="language-typescript">async function loadDashboard(userId: string, period: string) {
  const [
    trafficData,
    topLinks,
    geoDistribution,
    deviceBreakdown,
    sessionMetrics
  ] = await Promise.allSettled([
    getTrafficTimeseries(userId, period),
    getTopPerformingLinks(userId, period),
    getGeographicDistribution(userId, period),
    getDeviceBreakdown(userId, period),
    getSessionMetrics(userId, period)
  ]);
  
  return {
    traffic: trafficData.status === 'fulfilled' ? trafficData.value : null,
    topLinks: topLinks.status === 'fulfilled' ? topLinks.value : [],
    geography: geoDistribution.status === 'fulfilled' ? geoDistribution.value : [],
    devices: deviceBreakdown.status === 'fulfilled' ? deviceBreakdown.value : [],
    sessions: sessionMetrics.status === 'fulfilled' ? sessionMetrics.value : null,
    hasErrors: [trafficData, topLinks, geoDistribution, deviceBreakdown, sessionMetrics]
      .some(r =&gt; r.status === 'rejected')
  };
}
</code></pre>
<p><strong>Each query has a fallback</strong> so one failure doesn't break the entire dashboard. If the "Geographic Distribution" query fails, we show an empty state—but traffic, clicks, and other metrics still load.</p>
<h2>Parameterized Queries for Security &amp; Performance</h2>
<p>All queries use <strong>named parameters</strong> instead of string interpolation:</p>
<pre><code class="language-typescript">const sql = `
  SELECT
    user_path,
    SUM(view_count) AS total_views
  FROM analytics.hourly_traffic
  WHERE user_path = @userId
    AND hour_timestamp &gt;= @startDate
    AND hour_timestamp &lt; @endDate
  GROUP BY user_path
`;

const results = await bigquery.query({
  query: sql,
  params: {
    userId: '/creator-username',
    startDate: '2024-01-01T00:00:00Z',
    endDate: '2024-01-31T23:59:59Z'
  }
});
</code></pre>
<p>This provides:</p>
<ul>
<li><strong>SQL injection protection</strong> (even though we're server-side)</li>
<li><strong>Better query plan caching</strong> in BigQuery (identical queries with different params reuse plans)</li>
<li><strong>Type safety</strong> for parameters</li>
<li><strong>Cleaner, more maintainable code</strong></li>
</ul>
<h2>What We Learned</h2>
<p>Building this analytics system taught us a few key lessons:</p>
<ol>
<li><p><strong>Don't reinvent event capture.</strong> Use a proven platform like PostHog, Segment, or RudderStack. Focus your energy on the analytics experience, not the infrastructure.</p>
</li>
<li><p><strong>Materialized views are a cheat code.</strong> The upfront cost of defining views pays off 100x in query performance. Start with simple aggregations and iterate based on usage.</p>
</li>
<li><p><strong>Cluster by your query patterns.</strong> Study your most common queries and cluster accordingly. Don't just cluster by date—consider user paths, categories, or other frequently-filtered dimensions.</p>
</li>
<li><p><strong>Cache aggressively, invalidate simply.</strong> TTL-based caching is good enough for 99% of analytics use cases. Don't over-engineer it with complex invalidation logic.</p>
</li>
<li><p><strong>Batch queries ruthlessly.</strong> One complex query with CTEs is almost always faster than many simple queries, especially across a network. Plus, you save on per-query overhead.</p>
</li>
<li><p><strong>Build in observability from day one.</strong> Logging bytes processed helped us optimize costs before they became a problem. Treat query performance as a first-class metric.</p>
</li>
<li><p><strong>Graceful degradation &gt; perfect data.</strong> It's better to show partial analytics than to show nothing when one query fails. Users understand temporary gaps—they don't understand blank screens.</p>
</li>
<li><p><strong>Anomaly detection adds massive value.</strong> Proactive alerts about traffic changes help users catch problems early and capitalize on viral moments. This feature gets mentioned in user feedback constantly.</p>
</li>
</ol>
<h2>The Results</h2>
<p>Today, our analytics system handles:</p>
<ul>
<li><strong>10M+ events per day</strong> ingested through PostHog</li>
<li><strong>Sub-second queries</strong> for individual creator dashboards</li>
<li><strong>2-second load times</strong> for agency dashboards aggregating 100+ creators</li>
<li><strong>99.9% uptime</strong> with automatic failover to cached data</li>
</ul>
<p>And most importantly: <strong>creators love it.</strong> They trust the data, rely on the alerts, and use insights to optimize their pages. We see 70%+ of active users checking their analytics at least weekly.</p>
<h2>Technical Architecture Summary</h2>
<p>Here's a quick reference of our stack:</p>
<ul>
<li><strong>Event Capture:</strong> PostHog (exports to BigQuery)</li>
<li><strong>Data Warehouse:</strong> Google BigQuery</li>
<li><strong>Caching:</strong> Redis</li>
<li><strong>API Layer:</strong> tRPC for type-safe endpoints</li>
<li><strong>Frontend:</strong> React + Recharts for visualizations</li>
<li><strong>Monitoring:</strong> Custom logging + CloudWatch alerts</li>
</ul>
<h2>Next Steps</h2>
<p>We're continuously improving our analytics system. On the roadmap:</p>
<ul>
<li><strong>Predictive analytics:</strong> Use historical patterns to forecast future traffic</li>
<li><strong>Cohort analysis:</strong> Track user retention and engagement over time</li>
<li><strong>Custom metrics:</strong> Let creators define their own KPIs</li>
<li><strong>Real-time streaming:</strong> Sub-minute data freshness for critical metrics</li>
<li><strong>Machine learning alerts:</strong> Smarter anomaly detection using ML models</li>
</ul>
<hr>
<p><strong>Want to see it in action?</strong> <a href="https://onlylinks.com">Sign up for OnlyLinks</a> and check out the analytics dashboard. It's fast, reliable, and built with the techniques described in this post.</p>
<p><strong>Have questions about our architecture?</strong> We love talking about analytics infrastructure. Email us @ <a href="mailto:support@onlylinks.com">support@onlylinks.com</a>.</p>
<hr>
<p><em>Written by the James Ash • Published November 2025</em></p>
]]></content:encoded>
      <category><![CDATA[Best Practices]]></category>
      <enclosure url="https://cdn.onlylinks.com/blogs/marketing-hog.jpeg" type="image/jpeg" />
    </item>
  </channel>
</rss>