Pair Programming with Google Antigravity: Shipping a Bilingual Editorial Blog from Scratch
In software engineering, we talk a lot about "shipping." We measure cycle times, setup deployment pipelines, and optimize compilation speeds. But when you sit down to build your own digital backyard, the hardest bottleneck is often the gap between the vision in your head and the friction of execution.
For the release of localhostlife.io, I wanted a clean, light-mode, text-first editorial layout—highly legible typography, zero clutter, and an architecture that handles bilingual writings (English and Farsi) without breaking layout flows.
To build it, I paired up with Google Antigravity, an autonomous agentic AI coding assistant. This isn't a story about auto-completing code snippets. It is a deep dive into the mechanics of multi-modal debugging, building distributed state machines, database transaction bypasses, and the technicalities of shipping software alongside an agent.
Level 1: Visual Telemetry & The Bilingual Typography Problem
One of the most fascinating aspects of pair programming with an agent is how you communicate visual intent. In frontend work, describing a spacing bug in text is highly inefficient. Instead, we used multimodal telemetry: feeding high-resolution screenshots of the browser directly to the agent.
The agent reads the visual rendering of the DOM, cross-references it with the Tailwind classes in the workspace, and diagnoses layout errors. We used this loop to tune margins, eliminate vertical padding gaps, and format our content container widths to a clean reading layout.
But the biggest layout challenge was the bilingual typography stack.
On a bilingual blog, some headers are in English, and some are in Farsi. Standard CSS font fallbacks behave weirdly here. If you declare a global serif font (like Lora):
- The browser renders English letters using Lora's elegant serifs.
- For Persian characters, it fallbacks to whatever sans-serif Persian font the browser has.
- If a title mixes languages (e.g. صبحهای زمستانی و Multi-Threading زندگی), you get a chaotic mixture of serif English and sans-serif Persian inside the same sentence. It looks amateurish.
To solve this, we defined a custom Vazirmatn font family stack in tailwind.config.ts and created a dynamic title component:
// Dynamically adjusting typography based on the post metadata
const isRtl = post.rtl === true;
return (
<h1 className={`text-3xl font-bold leading-tight ${
isRtl ? "font-vazir text-right" : "font-sans text-left"
}`}>
{post.title}
</h1>
);By toggling the title element to a unified sans-serif Arabic stack (font-vazir) when a post is marked as rtl: true, we forced the entire heading—including the English words—to render under a single font family.
Level 2: Bypassing RLS & Building an Atomic Likes Counter
Every blog needs a reaction system. I wanted a simple "Like" button under each article, backed by Supabase. But database writes from Next.js serverless functions often hit security walls.
The RLS Write Block
By default, Supabase tables are protected by Row Level Security (RLS). When a client calls an API to write to the database, RLS policies block anonymous writes unless they are explicitly authorized. To solve this securely without exposing database tables to the public, we implemented a serverless API endpoint in app/api/likes/route.ts that initializes a private client using the SUPABASE_SERVICE_ROLE_KEY:
// Bypassing RLS safely on the backend server
function getSupabaseClient() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY; // Service key bypasses RLS
return createClient(url, key);
}Race Conditions & Atomic Increments
If two users click the like button at the exact same millisecond, a standard read-then-write database transaction faces a race condition (both read N likes, both write N+1, resulting in one lost vote).
To prevent this, we wrote a Postgres function (RPC) to handle the increment atomically directly inside the database engine:
CREATE OR REPLACE FUNCTION increment_like(post_slug TEXT)
RETURNS INT AS $$
DECLARE
current_likes INT;
BEGIN
INSERT INTO post_likes (slug, likes_count)
VALUES (post_slug, 1)
ON CONFLICT (slug)
DO UPDATE SET likes_count = post_likes.likes_count + 1, updated_at = now()
RETURNING likes_count INTO current_likes;
RETURN current_likes;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;We then called this function from the Next.js API using supabase.rpc("increment_like", { post_slug: slug }).
The Transaction Fallback Loop
What if the user hasn't run the database migration yet? To make the system resilient, we wrote a double-fallback loop. If the RPC function fails (e.g. database not migrated), the server automatically catches the error and executes a manual read-and-upsert. If the table itself is completely missing, the API catches error 42P01 (relation does not exist) and gracefully returns 0 likes to the client instead of throwing a 500 error.
On the client side, we implemented an Optimistic UI rollback: the button count updates instantly when clicked, but if the API returns an error, the state manager catches the failure, rolls the counter back, and clears the local storage flag.
Level 3: The Persistent Popup State Machine
To build the newsletter subscription popup, I wanted a timing flow that wouldn't irritate visitors. It needed to be patient, remember its history, and respect user decisions.
The logic:
- Wait 15 seconds after a visitor loads their first page.
- If they close it, wait 3 minutes before showing it again.
- If they close it a second time, wait 7 minutes before showing it again.
- If they close it a third time, disable it permanently.
- If they subscribe, disable it immediately and forever.
If you implement this using standard React state, the timer resets every time the user clicks a link and loads a new page. To make it persistent across page navigations, we built a state machine backed by localStorage tracking two variables: newsletter_popup_stage and newsletter_last_closed_time.
Here is the core scheduler:
useEffect(() => {
const isSubscribed = localStorage.getItem("newsletter_subscribed") === "true";
if (isSubscribed) return;
const stage = parseInt(localStorage.getItem("newsletter_popup_stage") || "0", 10);
if (stage >= 3) return; // Opted out permanently
const lastClosed = parseInt(localStorage.getItem("newsletter_last_closed_time") || "0", 10);
let timer: NodeJS.Timeout;
if (stage === 0) {
timer = setTimeout(() => setIsOpen(true), 15000); // 15s initial
} else {
const targetDelay = stage === 1 ? 180000 : 420000; // 3 min or 7 min
const elapsed = Date.now() - lastClosed;
if (elapsed >= targetDelay) {
setIsOpen(true);
} else {
timer = setTimeout(() => setIsOpen(true), targetDelay - elapsed);
}
}
return () => clearTimeout(timer);
}, [pathname, isOpen]);This math-based scheduler calculates the remaining delay dynamically on every page load. If they change pages 10 seconds into a 3-minute waiting window, the component unmounts, remounts, and resumes the countdown precisely from the remaining 2 minutes and 50 seconds.
Level 4: Git & Shell Surgery
Pair programming with an AI agent also exposes interesting environment quirks. For instance, when we were merging conflict resolutions on Next.js dynamic routing paths like app/blog/[slug]/page.tsx, the zsh terminal shell kept crashing:
git add app/blog/[slug]/page.tsx
zsh: no matches found: app/blog/[slug]/page.tsxThe shell interpreted the square brackets [slug] as a pattern matching wildcard, searching for files matching single characters 's', 'l', 'u', 'g'. The fix was a quick reminder on shell escaping: quoting the path literal "app/blog/[slug]/page.tsx" to bypass zsh's globbing engine.
Reflections on Orchestrative Coding
Pairing with an agent shifts the role of the software engineer from writer to orchestrator.
You spend less time typing boilerplate layout styles or looking up API parameters, and more time designing database flows, planning state machine transitions, analyzing logs, and reviewing code diffs. It allows you to ship complex features—like atomic likes, automated newsletter action triggers, and multi-threaded layouts—in a matter of hours rather than days.
The backyard is open. Welcome to localhostlife.io.