One Stat, One Image: Two Small Changes with Outsized Impact
Two Improvements, One Theme
This post covers two recent changes that look unrelated on the surface โ a landing page section and a WhatsApp delivery mechanism โ but share the same underlying instinct: stop diluting the signal. The landing page was burying its most important fact inside a wall of copy. The carousel delivery was spreading one coherent piece of content across six separate messages. Both were fixable by saying less and showing more.
The UMKM Painpoint Strip
The Jiwa AI landing page opens with a pitch for social media automation. The hero section establishes what the product does. The How It Works section explains how it works. Between them, there was nothing that answered the first question a skeptical business owner actually asks: "Why does this matter?"
We added a dark interstitial section between the hero and the How It Works flow. It carries a single piece of information: three out of five small businesses in Indonesia close because they cannot compete on social media. The source is Kemenkop UKM and the Google e-Conomy SEA 2023 report โ two credible references that anchor the claim in research rather than marketing.
The visual design makes the number unavoidable. The "3/5" renders at up to 10rem in a gradient white, font-weight black โ large enough that it reads before the surrounding copy does. The headline follows in Indonesian and English: "3 dari 5 UMKM tutup karena kalah bersaing di social media." Below it, a subdued footnote names the sources. A bridge line at the bottom pivots directly into the solution: Jiwa AI exists to close that gap.
This ordering matters more than it might seem. Landing pages that lead with capability โ "we generate AI content automatically" โ are asking visitors to do work before they understand why the work is worth doing. A statistic that contextualizes the problem before describing the solution reframes the entire product. The user arrives at the How It Works section already persuaded that the problem is real, not still deciding whether it applies to them.
The section is fully bilingual. Indonesian text surfaces for Indonesian-locale visitors; English text for everyone else. The stat strip does not assume the visitor speaks either language fluently โ the "3/5" is legible regardless.
Carousel Collation: Six Messages Become One
When Jiwa AI generates a six-slide carousel, it produces six images: cover, hook, slides two through five, and a call-to-action. Each image carries an overlay of text, a product placement, and a styled visual. The carousel is designed to be consumed as a sequence โ a narrative arc from hook to close.
The original WhatsApp delivery sent these as six individual messages. Six messages in sequence looks like spam. Chat apps visually group consecutive messages from the same sender, so the user receives a stack of six thumbnails where the intent was a scrollable story. In practice, many users tapped the first image, looked at it in isolation, and never opened the rest.
The fix is mechanical but effective: before sending, all six slides are composited into a single 3ร2 grid image. The grid is 1620ร1080 pixels โ three columns of 540 pixels, two rows of 540 pixels, yielding a 3:2 landscape image that WhatsApp renders at full width. Each cell shows a complete slide, cover-cropped to fill its 540ร540 space.
The implementation lives in collateCarouselImages() in src/lib/text-overlay.ts. It fetches all six slide URLs in parallel using Promise.all, resizes each to 540ร540 using Sharp's fit: "cover" mode, composites them onto a dark #141414 background, and encodes the final image as JPEG at quality 88. One Sharp pipeline, one output buffer, one WhatsApp message.
const cells = await Promise.all(
urls.map((url) =>
fetch(url)
.then((r) => r.arrayBuffer())
.then((b) => Buffer.from(b))
.then((buf) => sharp(buf).resize(540, 540, { fit: "cover" }).toBuffer()),
),
);
return sharp({
create: { width: 1620, height: 1080, channels: 3, background: { r: 20, g: 20, b: 20 } },
})
.composite(cells.map((buf, i) => ({ input: buf, left: (i % 3) * 540, top: Math.floor(i / 3) * 540 })))
.jpeg({ quality: 88 })
.toBuffer();
The cover sits top-left. Reading order โ left to right, top to bottom โ matches the intended narrative sequence. A viewer who opens the image sees the full carousel at a glance, rather than having to open six attachments.
The delivery function sendCollatedCarousel() in src/lib/carousel-sender.ts is a thin wrapper: it calls collateCarouselImages(), persists the resulting buffer to storage, and sends the URL as a single WhatsApp image message.
The Sharp Bundling Problem
Moving to Sharp for carousel collation introduced a complication that took a separate commit to fix.
Sharp is a native Node.js module. It ships precompiled binaries for specific platforms and runtimes. Next.js's edge runtime โ used in middleware and some auth flows โ does not support native Node.js modules. The problem is not that Sharp is called in an edge context; it isn't. The problem is module graph resolution: if any file in the auth chain imports a file that imports Sharp, the bundler pulls Sharp into the edge bundle and fails at build time.
Our auth configuration (auth.config) imports a utility for magic-token validation, which in turn had been importing from fonnte.ts. When we first added collateCarouselImages to text-overlay.ts and called it from a path that touched fonnte, the chain looked like: auth.config โ magic-token โ fonnte โ text-overlay โ sharp. The build produced a hard error about native modules in edge-compatible bundles.
The fix was architectural rather than cosmetic. We moved sendCollatedCarousel into a dedicated file โ src/lib/carousel-sender.ts โ so that nothing in the auth chain imports anything that transitively reaches Sharp. text-overlay.ts retains the collateCarouselImages function, but nothing in the auth or middleware path ever touches carousel-sender.ts.
We also added serverExternalPackages: ["sharp"] to next.config.js. This tells the Next.js bundler to treat Sharp as an external package โ resolved at runtime from node_modules rather than bundled at compile time. The two changes together ensure Sharp works correctly in server-side API routes while remaining invisible to the edge bundle.
// next.config.js
serverExternalPackages: ["sharp"],
This is a pattern worth knowing for any Next.js project that uses native Node.js packages: the module graph from your auth configuration must not transitively reach any native module, and serverExternalPackages must explicitly exclude those packages from the client and edge bundles.
What Changed in Delivery
From the user's perspective, the carousel delivery change is invisible in a good way. A business owner who receives a WhatsApp notification about their new carousel now gets one message with one image. They open it, see all six slides arranged in a grid, and tap to save or share. No scrolling through a thread of six separate images. No ambiguity about which message belongs to which piece of content.
The stat strip change is visible in the most literal sense โ it is hard to miss a 10rem number on a dark background. What it adds is not visual complexity but narrative clarity: by the time a visitor reaches the How It Works section, they already understand why the section exists.
Both changes are examples of the same design discipline. When you have something important to communicate, don't distribute it across six touchpoints or hide it inside a longer paragraph. Find the right container, put the signal in the center, and get out of the way.