bebas.kompas.id
Building a user acquisition funnel for Indonesia's leading digital newspaper
Tech Stack: WordPress, Timber (Twig), Vue.js 2, Vuex, Tailwind CSS, Webpack, ACF, REST API, Google Analytics, GTM
I owned the frontend architecture and conversion systems for bebas.kompas.id, working closely with editorial, marketing, and backend teams. As the primary contributor (~57% of commits in a 2-person core team), I made key architectural decisions that shaped how the platform balanced SEO, performance, and conversion optimization.
The Strategic Challenge
In 2018, kompas.id faced a strategic dilemma. As Indonesia's premium paywalled news platform, they needed to compete with free news outlets while protecting subscription revenue. The solution: create bebas.kompas.id, a separate platform for free-to-read articles that would serve as a user acquisition funnel.
The requirements were clear:
Editors control which articles are free—not algorithms, not time-based unlocks
7-day availability window—after which articles redirect to kompas.id's paywall
Seamless conversion path—turn free readers into paying subscribers
SEO-first architecture—content must be indexable and fast-loading
Performance on Indonesian mobile networks—3G was still dominant outside major cities
The Architecture Decision: Islands of Interactivity
I chose a hybrid "islands" architecture: WordPress + Timber (Twig templating) for server-side rendering, with Vue.js powering isolated interactive components. This approach predates frameworks like Astro that popularized the pattern. We arrived at it through constraint-driven design.
Why not a full SPA?
SEO was non-negotiable. Server-rendered HTML meant instant indexability by Google.
kompas.id's ecosystem was WordPress-based. Integration had to be seamless with existing editorial workflows.
Indonesian mobile networks demanded minimal JavaScript. A full SPA would have killed performance outside Jakarta.
Why not pure WordPress?
Conversion flows needed real-time state management. When a user logged in, the UI had to react instantly and no page reloads.
A/B testing required client-side experiment assignment. Server-side only would mean cache-busting nightmares.
Interactive components (sticky headers, share tools, onboarding banners) demanded reactivity that jQuery spaghetti couldn't maintain
The Result: Best of Both Worlds
┌─────────────────────────────────────────────────────────┐
│ Browser │
└─────────────────────────┬───────────────────────────────┘
│ Request
▼
┌─────────────────────────────────────────────────────────┐
│ WordPress + Timber (PHP) │
│ Server-renders HTML with article content │
│ SEO-ready, cacheable, fast │
└─────────────────────────┬───────────────────────────────┘
│ HTML + embedded data
▼
┌─────────────────────────────────────────────────────────┐
│ Vue.js Hydration (Client) │
│ "Islands" of interactivity mount on static HTML │
│ Header, Share Tools, Onboarding Banners, Paywall UI │
└─────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Vuex Store │
│ User state, membership, UI preferences │
│ Enables instant UI response to auth changes │
└─────────────────────────────────────────────────────────┘This architecture meant editors could publish 10+ articles daily without developer involvement. This meant no build step, no deployment, and no frontend bottlenecks.
Designing for Failure: Graceful Degradation
In a subscription business, auth failures directly hurt revenue. If the membership check fails and the UI breaks, subscribers can't read and non-subscribers don't see conversion prompts. Both scenarios lose money.
The bebas.kompas.id architecture had a critical dependency: the Vuex user store fetched membership state from a PHP endpoint on every page load. If that endpoint failed (network issues, server overload, or misconfiguration), the entire conversion flow could break.
The defensive approach: Rather than letting errors propagate, the store was designed to fail gracefully by defaulting to "logged out" state if the auth check failed. This ensured:
The page always rendered (no blank screens)
Non-subscribers still saw conversion prompts (revenue protected)
Subscribers experienced degraded service (no personalization) rather than broken pages
// Defensive user state management
const actions = {
async fetch({ commit, state }) {
try {
const { data } = await axios.get(endpoint)
commit('setUser', data)
commit('setIsLoading', false)
} catch (error) {
// Graceful degradation: assume logged-out, don't break the page
commit('setUser', { membership: null })
commit('setIsLoading', false)
// Silent failure > broken page
console.error('Auth endpoint failed:', error.message)
}
}
}The tradeoff: A subscriber experiencing a failed auth check would see onboarding banners they shouldn't see. That's a bad experience, but it's recoverable (refresh the page). A blank screen or JavaScript error is not.
Key Systems Built
1. Time-Limited Free Access Engine
Problem: Editors wanted articles to be free for exactly 7 days, then redirect to kompas.id's paywall. Manually managing redirects for 10+ daily articles was unsustainable.
Solution: Built server-side age calculation that ran on every article request. Articles older than 7 days returned a 302 redirect to the kompas.id equivalent URL.
private function redirect_if_necessary() {
$today = Carbon::now('Asia/Jakarta');
$publish_date = Carbon::parse($post->post_date_gmt)->tz('Asia/Jakarta');
$diff_date = $publish_date->diffInDays($today);
// 302 (not 301) preserves SEO link equity for the destination
if ($diff_date > 7) {
$redirect_url = str_replace('bebas.kompas.id', 'kompas.id', get_permalink());
wp_redirect($redirect_url, 302);
exit;
}
}Why 302 instead of 301? A 301 would permanently transfer SEO value away from bebas.kompas.id. With 302, we preserved the funnel's search rankings while still redirecting users.
Result: Zero manual intervention required. 100% of articles transitioned correctly over 18 months of operation.
2. Decoupled Marketing Engine (Onboarding System)
Problem: Marketing needed to run conversion experiments weekly, but our deployment pipeline took 2+ hours. They couldn't iterate fast enough on banner copy, CTAs, or promotional campaigns.
Solution: Built a REST API-powered banner system where marketers controlled everything via WordPress admin (ACF). Vue components fetched configuration on page load. This meant zero deployments required for campaign changes.
// Banner component fetches live configuration
async get_settings() {
const { data } = await axios.get(
'/wp-json/kompas/v1/theme/settings/banners/onboardings/single'
)
// Respect campaign scheduling (start/end dates set by marketing)
const now = Date.parse(data.bottom.now)
const start = Date.parse(data.bottom.start_airing)
const end = Date.parse(data.bottom.finished_airing)
if (now >= start && now <= end) {
this.item = data // Show banner
} else {
this.item = null // Campaign not active
}
}The power move: Marketing could schedule campaigns weeks in advance. "Black Friday promo starts Nov 25 at midnight" just worked. No developer needed at midnight.
Result: Marketing ran 15+ conversion experiments in 6 months (vs. 2-3 in the previous year under the old workflow).
3. Multi-Format Content Architecture
Problem: Kompas journalists produced four distinct content types, i.e standard articles, photo galleries, photo stories (visual narratives), and video articles. Each had different layout needs and interactive behaviors.
Solution: Built a template routing system that detected content type via WordPress categories and rendered the appropriate Twig template with matched Vue components.
// Content-type routing in single.php
if ($context['post_has_galeri_foto']) {
// Photo gallery: Vue lightbox component, image lazy-loading
Timber::render('single/single-has-galeri-foto.twig', $context);
} elseif ($context['post_has_foto_cerita']) {
// Photo story: immersive scroll-driven layout
Timber::render('single/single-has-foto-cerita.twig', $context);
} elseif ($context['post_has_video']) {
// Video article: custom Kompas Video player integration
Timber::render('single/single-has-video.twig', $context);
} else {
// Standard article with A/B test tracking
Timber::render('single/single.twig', $context);
}Result: Editors published any content type without thinking about "frontend requirements". The system handled it.
4. Conversion Tracking & A/B Testing Infrastructure
Problem: We needed to understand which content, layouts, and CTAs drove subscriptions, but couldn't instrument everything server-side without cache-busting.
Solution: Client-side experiment assignment via GTM, with Vuex managing experiment state. Every conversion touchpoint (login click, register click, paywall view) fired tracked events with UTM parameters.
// Experiment assignment on component mount
mounted() {
this.$nextTick(() => {
ga('set', 'exp', window.kompas_gtm_vars.exp_type)
ga('send', { hitType: 'pageview' })
})
}
// Conversion tracking with full attribution
gaSend(e, action) {
const params = new URLSearchParams({
utm_source: 'kompasid',
utm_medium: `${action}_paywall`,
utm_campaign: action,
utm_content: this.uri
})
ga('send', 'event', action, 'click', window.kompas_local_vars.permalink, {
hitCallback: () => {
window.location.href = `${this.signInLink}?next=${this.uri}&${params}`
}
})
}Result: Marketing could finally answer "which banner copy converts better?" with data, not opinions.
5. Membership-Aware UI Layer
Problem: Non-subscribers needed to see conversion prompts. Subscribers needed a clean reading experience. The UI had to switch instantly when auth state changed without page reload.
Solution: Vuex store held membership state, fetched on page load. All conversion UI components were reactive to this state.
<template>
<!-- Only show onboarding banner to non-subscribers -->
<div v-if="!membership || membership === undefined">
<OnboardingBanner :expired-at="expiredAt" />
</div>
<!-- Subscribers see clean reading experience -->
<div v-else class="subscriber-content">
<slot />
</div>
</template>
<script>
import { mapState, mapGetters } from 'vuex'
export default {
computed: {
...mapState({
membership: state => state.user.user.membership
}),
...mapGetters({
is_loading: 'user/is_loading'
})
}
}
</script>Result: Authenticating via kompas.id SSO triggered an instantaneous UI transition; conversion banners vanished and premium features initialized without a single browser refresh.
Key Learnings: Conversion Psychology
Building a subscription funnel taught me things no tutorial covers:
Scroll depth matters more than time-on-page.
Users who scrolled past 60% of an article before seeing the paywall prompt converted better than those who saw it immediately. We delayed banner reveal accordingly.
"Free for 7 days" created urgency.
The countdown timer in the bottom banner ("This articles is free-to-read until [date]") outperformed generic "Subscribe now" messaging.
Photo stories engaged but didn't convert.
Visual content had 18% higher engagement (scroll depth, time on page) but lower conversion rates. Hypothesis: users wanted free visual content, not subscriptions. We adjusted CTA copy for these formats.
Login friction killed conversions.
Every additional click between "I want to subscribe" and "I'm on the payment page" lost users. We optimized the redirect chain obsessively.
Impact & Scale
bebas.kompas.id launched in early 2019 and became Kompas's primary top-of-funnel acquisition channel:
Editorial velocity: 10+ articles published daily without frontend deployments
Marketing agility: Campaign changes deployed in minutes, not hours
Technical foundation: Architecture supported 18+ months of iteration without major refactoring
Team enablement: Subsequent features (responsive ads, video embeds, Twitter integration) built on patterns I established
The platform continued operating through 2020+, with the team extending it based on the hybrid architecture I designed.
Technical Decisions I'd Make Differently
Use Nuxt.js from the start.
The Vue + Timber hybrid worked, but Nuxt's SSR would have simplified hydration and routing. We essentially built a poor man's Nuxt.
Implement feature flags.
Deployments and releases were coupled. Feature flags would have let us ship code continuously while controlling rollout.
Deeper analytics instrumentation.
We tracked conversions but not the micro-behaviors leading to them. Scroll depth segments, banner hover times, CTA hover-to-click latency—these would have enabled finer optimization.
Team & Contributions
I was the primary contributor with ~57% of all commits across 396 total commits. The project was essentially built by a 2-person core team, with myself leading the frontend architecture and implementation.
About This Project
bebas.kompas.id is a free-to-read news platform by PT Kompas Media Nusantara, designed to complement the premium paywalled kompas.id. It serves as a user acquisition funnel, allowing readers to sample quality journalism before subscribing.
The architecture decisions made here—hybrid SSR/CSR, decoupled marketing systems, membership-reactive UI—reflect the constraints and opportunities of building for Indonesian media at scale.
Like what you see?
I'm open to freelance projects and full-time roles. If you need someone who obsesses over structure and ships clean code — let's talk.
:quality(70))