<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Junhan Pang</title>
    <description>The latest articles on DEV Community by Junhan Pang (@junhanpang).</description>
    <link>https://dev.to/junhanpang</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4137539%2Fa58d0c08-9c7f-4627-9e74-9f0387c84219.png</url>
      <title>DEV Community: Junhan Pang</title>
      <link>https://dev.to/junhanpang</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/junhanpang"/>
    <language>en</language>
    <item>
      <title>I built a daily analytics app for Malaysian creators (Next.js + Neon + Vercel)</title>
      <dc:creator>Junhan Pang</dc:creator>
      <pubDate>Mon, 28 Sep 2026 05:25:40 +0000</pubDate>
      <link>https://dev.to/junhanpang/i-built-a-daily-analytics-app-for-malaysian-creators-nextjs-neon-vercel-fk3</link>
      <guid>https://dev.to/junhanpang/i-built-a-daily-analytics-app-for-malaysian-creators-nextjs-neon-vercel-fk3</guid>
      <description>&lt;p&gt;A lot of creators in Malaysia post the same video on TikTok, Instagram and YouTube, some on Douyin and Xiaohongshu too, and every app shows its numbers in its own place and its own way.&lt;/p&gt;

&lt;p&gt;So I built MyCreatorPulse: one dashboard for all of them. Every night at 2am Malaysia time it reads a creator's public numbers on TikTok, Instagram, YouTube, Facebook, Douyin and Xiaohongshu, saves a snapshot, and in the morning shows what each post gained overnight. No platform login, you just give it your username.&lt;/p&gt;

&lt;p&gt;It's still in its pilot with a handful of creators. Here's what I've learned building it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Next.js 15 (App Router), React 19, TypeScript&lt;/li&gt;
&lt;li&gt;Tailwind v4&lt;/li&gt;
&lt;li&gt;Postgres on Neon, with Drizzle&lt;/li&gt;
&lt;li&gt;Better Auth for sign-in&lt;/li&gt;
&lt;li&gt;Vercel (Singapore region) with two cron jobs&lt;/li&gt;
&lt;li&gt;Resend for email, Cloudflare for DNS&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Two crons do most of the work
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"crons"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/api/cron/snapshot"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"schedule"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0 18 * * *"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/api/cron/digest"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="nl"&gt;"schedule"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0 0 * * *"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;18:00 UTC is 2am in Malaysia, when everything gets read. 00:00 UTC is 8am, when the morning email goes out. Each run claims the day before doing anything, so if a cron ever fires twice, the second one does nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Time zones will get you
&lt;/h2&gt;

&lt;p&gt;Every date in the app is pinned to Malaysia time explicitly. My PC is on UTC+8, so a bug that leaned on the machine's clock would never show up locally. The tests run with &lt;code&gt;TZ=UTC&lt;/code&gt;, same as Vercel, to catch those.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tests that block the deploy
&lt;/h2&gt;

&lt;p&gt;The numbers are the whole product, so the checks run inside the build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm run check &amp;amp;&amp;amp; next build"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They replay real bugs on an in-memory Postgres (PGlite) through the same code the dashboard uses. If one fails, Vercel refuses the deploy and the site stays on the last good version.&lt;/p&gt;

&lt;p&gt;Lesson from this week: a test must never depend on the build machine's environment. One of mine created fake PayPal subscriptions on a made-up plan ID. Locally that env var was empty, so it passed. On Vercel the real one was set, the check failed, and three deploys quietly never went out. Now the test sets what it needs and puts it back after.&lt;/p&gt;

&lt;h2&gt;
  
  
  Engagement rate is easy to get wrong
&lt;/h2&gt;

&lt;p&gt;Sounds simple: likes + comments ÷ views. But photos don't have views. Add up likes over every post and views only over videos, and a photo-heavy account ends up with a crazy rate. Only count posts that actually have a view count:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;withViews&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;views&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;views&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;views&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;withViews&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;views&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reactions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;withViews&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;likes&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;comments&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;views&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reactions&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;views&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I turned this into a free calculator anyone can use, no sign-up: &lt;a href="https://mycreatorpulse.com/engagement-rate-calculator" rel="noopener noreferrer"&gt;https://mycreatorpulse.com/engagement-rate-calculator&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Platforms don't all tell you the same thing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;YouTube rounds public subscriber counts to three figures (12.3K), and the API does too, so the app shows what YouTube shows instead of pretending it's exact.&lt;/li&gt;
&lt;li&gt;Instagram only has view counts on reels and videos, and a hidden like count is unknown, not zero.&lt;/li&gt;
&lt;li&gt;Xiaohongshu doesn't show views publicly at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rule I ended up with: if a platform doesn't publish a number, the app doesn't guess it.&lt;/p&gt;

&lt;h2&gt;
  
  
  HTML email is 2005 again
&lt;/h2&gt;

&lt;p&gt;The weekly summary email has charts in it. Gmail strips &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; tags, SVG and plenty more, so the "charts" are table cells with a height and a background colour:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;td&lt;/span&gt; &lt;span class="na"&gt;valign=&lt;/span&gt;&lt;span class="s"&gt;"bottom"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"height:36px;background:#1f9d6b;border-radius:3px 3px 0 0"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tables and inline styles only. Ugly to write, works everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Instagram and YouTube pages just went up, Douyin and Xiaohongshu are there for creators who post in Chinese, and there are plans for talent agencies now too. If you're a creator, or know one in Malaysia, the live demo works without signing up: &lt;a href="https://mycreatorpulse.com" rel="noopener noreferrer"&gt;https://mycreatorpulse.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Happy to answer anything about the build in the comments.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>nextjs</category>
      <category>webdev</category>
      <category>buildinpublic</category>
    </item>
  </channel>
</rss>
