<?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: Jeason Li</title>
    <description>The latest articles on DEV Community by Jeason Li (@jeason_li_2a921781568e802).</description>
    <link>https://dev.to/jeason_li_2a921781568e802</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3729898%2Fd0071b33-3f11-4146-9627-0522fa8db02b.jpg</url>
      <title>DEV Community: Jeason Li</title>
      <link>https://dev.to/jeason_li_2a921781568e802</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jeason_li_2a921781568e802"/>
    <language>en</language>
    <item>
      <title>How I Built an AI Image Editor with Next.js 16 + OpenRouter (Gemini) + Supabase Auth</title>
      <dc:creator>Jeason Li</dc:creator>
      <pubDate>Sat, 24 Jan 2026 09:47:55 +0000</pubDate>
      <link>https://dev.to/jeason_li_2a921781568e802/how-i-built-an-ai-image-editor-with-nextjs-16-openrouter-gemini-supabase-auth-3jkg</link>
      <guid>https://dev.to/jeason_li_2a921781568e802/how-i-built-an-ai-image-editor-with-nextjs-16-openrouter-gemini-supabase-auth-3jkg</guid>
      <description>&lt;p&gt;I’ve been building &lt;strong&gt;Image Banana&lt;/strong&gt; — a web-based &lt;strong&gt;AI image editor&lt;/strong&gt; where you can upload a photo and describe edits in plain English (for example: “replace the background with a snowy mountain”, “keep the same character, change the outfit”, “make it golden hour”).&lt;/p&gt;

&lt;p&gt;site：&lt;a href="https://www.my-nano-banana.com/" rel="noopener noreferrer"&gt;https://www.my-nano-banana.com/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This post walks through the architecture and a few implementation details that made the product feel “real” quickly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Image-to-image editing&lt;/strong&gt; and &lt;strong&gt;text-to-image generation&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One-shot editing&lt;/strong&gt; (one prompt -&amp;gt; final output)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch processing&lt;/strong&gt; (multiple inputs) as a premium feature&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;guest trial&lt;/strong&gt;: 1 generation without signing up, then prompt Google sign-in&lt;/li&gt;
&lt;li&gt;A simple &lt;strong&gt;credits + subscription&lt;/strong&gt; model&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to try it live, replace the placeholders below with your domain and deploy it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Product Decisions That Shaped the Code
&lt;/h2&gt;

&lt;p&gt;Before writing code, I forced myself to answer:&lt;/p&gt;

&lt;p&gt;1) What does the “happy path” look like in under 30 seconds?&lt;br&gt;
2) What’s the minimum gating needed to prevent abuse, without killing the first-run experience?&lt;br&gt;
3) Which features should be paid (and why)?&lt;/p&gt;

&lt;p&gt;That led to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Guest trial&lt;/strong&gt;: let people generate once without login.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch mode&lt;/strong&gt;: premium because it’s costlier and easier to abuse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clear error UX&lt;/strong&gt;: show “why” (credits, subscription required, missing inputs) rather than “something went wrong”.&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;
  
  
  High-Level Architecture
&lt;/h2&gt;

&lt;p&gt;The app is a standard Next.js App Router stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;UI: &lt;code&gt;components/image-editor.tsx&lt;/code&gt; (client component)&lt;/li&gt;
&lt;li&gt;Generation API: &lt;code&gt;app/api/generate/route.ts&lt;/code&gt; (Node runtime)&lt;/li&gt;
&lt;li&gt;Auth:

&lt;ul&gt;
&lt;li&gt;Start OAuth: &lt;code&gt;app/auth/login/route.ts&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Callback: &lt;code&gt;app/auth/callback/route.ts&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Billing primitives: &lt;code&gt;lib/billing/*&lt;/code&gt; (supports Supabase-backed billing, with a local JSON fallback for dev)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The core request flow:&lt;/p&gt;

&lt;p&gt;1) Browser collects prompt + optional uploaded image(s)&lt;br&gt;
2) POST to &lt;code&gt;/api/generate&lt;/code&gt;&lt;br&gt;
3) Server calls OpenRouter (Gemini image models) and extracts returned image URLs&lt;br&gt;
4) UI renders returned image(s) and offers download / “edit again”&lt;/p&gt;


&lt;h2&gt;
  
  
  Image Generation via OpenRouter (Gemini)
&lt;/h2&gt;

&lt;p&gt;At a high level, the server makes a &lt;code&gt;chat/completions&lt;/code&gt; request with modalities &lt;code&gt;["image","text"]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Implementation notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Timeouts&lt;/strong&gt; matter (don’t let requests hang forever).&lt;/li&gt;
&lt;li&gt;Be strict with payload validation: missing prompt, missing reference image in image-to-image mode, etc.&lt;/li&gt;
&lt;li&gt;When the model returns images, you typically get hosted URLs (not base64). Your UI needs to treat them as remote URLs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Minimal (simplified) request shape:&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://openrouter.ai/api/v1/chat/completions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OPENROUTER_API_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HTTP-Referer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OPENROUTER_HTTP_REFERER&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http://localhost:3000&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-Title&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OPENROUTER_X_TITLE&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image-banana&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;google/gemini-2.5-flash-image&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;modalities&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
          &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
          &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;imageUrl&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image_url&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;image_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;imageUrl&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]),&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Guest Trial: “One Free Generation” Without Login
&lt;/h2&gt;

&lt;p&gt;The UX goal: let users try the product &lt;em&gt;immediately&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The abuse-prevention goal: don’t give unlimited free generations.&lt;/p&gt;

&lt;p&gt;I implemented guest trial as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Client-side: a localStorage flag for fast UI gating (&lt;code&gt;image-banana-guest-trial-used&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Server-side: an HTTP cookie (&lt;code&gt;ib_guest_trial_generate_v1&lt;/code&gt;) so the API is the source of truth&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This way, even if someone clears localStorage, the API still enforces the “one free generation” rule.&lt;/p&gt;

&lt;p&gt;On the server (&lt;code&gt;/api/generate&lt;/code&gt;), the logic looks like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If not logged in:

&lt;ul&gt;
&lt;li&gt;if batch image-to-image -&amp;gt; &lt;code&gt;401&lt;/code&gt; (auth required)&lt;/li&gt;
&lt;li&gt;if trial cookie already set -&amp;gt; &lt;code&gt;401&lt;/code&gt; (trial exhausted)&lt;/li&gt;
&lt;li&gt;else allow exactly one request, then set the trial cookie&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;And the client reacts to &lt;code&gt;401&lt;/code&gt; by opening a sign-in dialog or redirecting to &lt;code&gt;/auth/login&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Batch Processing as a Premium Feature
&lt;/h2&gt;

&lt;p&gt;Batch mode is great UX, but it’s also a cost multiplier.&lt;/p&gt;

&lt;p&gt;I made it premium by checking entitlements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Batch requires an active subscription&lt;/li&gt;
&lt;li&gt;Single requests consume credits&lt;/li&gt;
&lt;li&gt;In a batch, if some items fail, refund the credits for failed items (so you only pay for successes)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This “refund failed items” detail is small, but it prevents a lot of frustration.&lt;/p&gt;




&lt;h2&gt;
  
  
  Google Sign-In with Supabase Auth (Avoiding Open Redirects)
&lt;/h2&gt;

&lt;p&gt;OAuth flows love to break in subtle ways. One place I’m careful: the &lt;code&gt;next&lt;/code&gt; parameter.&lt;/p&gt;

&lt;p&gt;If you accept an arbitrary &lt;code&gt;next&lt;/code&gt; URL, attackers can create “open redirect” links.&lt;/p&gt;

&lt;p&gt;So the auth routes sanitize &lt;code&gt;next&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;must start with &lt;code&gt;/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;must not start with &lt;code&gt;//&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;if invalid -&amp;gt; fall back to &lt;code&gt;/&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then the login route starts the Supabase Google flow and redirects back to:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/auth/callback?next=&amp;lt;safe_next&amp;gt;&lt;/code&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Billing: Credits + Subscriptions (with a Local Dev Fallback)
&lt;/h2&gt;

&lt;p&gt;I wanted billing logic I could run locally without setting up every external dependency immediately.&lt;/p&gt;

&lt;p&gt;So the project supports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Supabase-backed billing&lt;/strong&gt; when &lt;code&gt;SUPABASE_SERVICE_ROLE_KEY&lt;/code&gt; is present&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local billing&lt;/strong&gt; otherwise (&lt;code&gt;.local/billing.json&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That gives you a nice dev experience:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;run the UI&lt;/li&gt;
&lt;li&gt;test credits/subscriptions&lt;/li&gt;
&lt;li&gt;only wire webhooks/payments when you’re ready&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  SEO Cold Start Checklist (So You Don’t Launch Invisible)
&lt;/h2&gt;

&lt;p&gt;If you’re shipping a product site, SEO isn’t “later” — it’s launch-critical. I added:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;robots.txt&lt;/code&gt; with sensible disallows and a sitemap pointer&lt;/li&gt;
&lt;li&gt;a single sitemap source (&lt;code&gt;/sitemap.xml&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;page-specific title/description on key pages&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;hreflang&lt;/code&gt; alternates for locales&lt;/li&gt;
&lt;li&gt;default &lt;code&gt;noindex&lt;/code&gt; for mirrored locales (until translations are real)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even a basic setup helps you avoid the most common cold-start traps.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I’d Improve Next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Real translations for &lt;code&gt;/zh&lt;/code&gt; and &lt;code&gt;/ko&lt;/code&gt; (or remove them until ready)&lt;/li&gt;
&lt;li&gt;Better prompt presets per use case (product shots, portraits, UGC, etc.)&lt;/li&gt;
&lt;li&gt;A job queue for long-running generations + webhooks&lt;/li&gt;
&lt;li&gt;Real background removal (current clone has a mocked tool page)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  If You Want to Build Your Own Version
&lt;/h2&gt;

&lt;p&gt;If you want to replicate the core stack, you’ll need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Node.js &lt;code&gt;&amp;gt;= 20.9&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;OpenRouter API key&lt;/li&gt;
&lt;li&gt;Supabase project + Google OAuth enabled&lt;/li&gt;
&lt;li&gt;A payment provider (this project uses Creem)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And then start from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/generator&lt;/code&gt; (the main product surface)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/api/generate&lt;/code&gt; (the core API)&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;If you’re building something similar, I’d love to hear:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How you gate free usage without ruining first-run UX&lt;/li&gt;
&lt;li&gt;Whether you prefer “credits” vs “subscription-only” for image tools&lt;/li&gt;
&lt;li&gt;What you consider “must-have” editing features beyond background replacement&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>ai</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Building a Minimal Flip Clock in the Browser (Astro + React)</title>
      <dc:creator>Jeason Li</dc:creator>
      <pubDate>Sat, 24 Jan 2026 09:43:39 +0000</pubDate>
      <link>https://dev.to/jeason_li_2a921781568e802/building-a-minimal-flip-clock-in-the-browser-astro-react-lh6</link>
      <guid>https://dev.to/jeason_li_2a921781568e802/building-a-minimal-flip-clock-in-the-browser-astro-react-lh6</guid>
      <description>&lt;p&gt;I wanted a &lt;strong&gt;big, clean clock&lt;/strong&gt; I could keep on a second screen during meetings (and occasionally full-screen on a TV). Most clock sites are either too busy or not quite the style I wanted, so I built my own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Live site: &lt;a href="https://www.uni-datetime.site/" rel="noopener noreferrer"&gt;https://www.uni-datetime.site/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This post is a quick walkthrough of the technical approach and a few lessons learned while getting the flip animation to feel "right".&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;Uni Datetime is a minimal flip clock web page:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Large flip digits (HH:MM:SS)&lt;/li&gt;
&lt;li&gt;Date above time&lt;/li&gt;
&lt;li&gt;Light/Dark mode&lt;/li&gt;
&lt;li&gt;12/24-hour toggle&lt;/li&gt;
&lt;li&gt;Fullscreen toggle (ESC exits fullscreen)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Time and timezone are derived from the &lt;strong&gt;browser/system timezone&lt;/strong&gt;, so if you change your browser timezone (or simulate one in DevTools), the displayed time changes accordingly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stack
&lt;/h2&gt;

&lt;p&gt;I kept it simple and static:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Astro&lt;/strong&gt; for a fast, static build&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;React&lt;/strong&gt; for the interactive clock "island"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Luxon&lt;/strong&gt; for date/time formatting&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Astro gives me a tiny HTML shell and bundles a React component for the clock UI and controls.&lt;/p&gt;

&lt;h2&gt;
  
  
  The flip digit: two halves + two animated layers
&lt;/h2&gt;

&lt;p&gt;The flip effect comes from rendering four layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;upper&lt;/code&gt; (static top half)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;lower&lt;/code&gt; (static bottom half)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;flip-upper&lt;/code&gt; (animated top half that flips down)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;flip-lower&lt;/code&gt; (animated bottom half that flips in)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Visually, the key is: &lt;strong&gt;the flip uses the old value until the midpoint, then reveals the new value&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In React, each digit keeps &lt;code&gt;current&lt;/code&gt; and &lt;code&gt;next&lt;/code&gt; values, plus a &lt;code&gt;play&lt;/code&gt; flag for the CSS animation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FLIP_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;FlipDigit&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;play&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setPlay&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setCurrent&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setNext&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;useLayoutEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setNext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;setPlay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Start flip from current -&amp;gt; value&lt;/span&gt;
    &lt;span class="nf"&gt;setNext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;setPlay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&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;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setCurrent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;setPlay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;FLIP_MS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`flip-digit&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;play&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; play&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"upper"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;play&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"lower"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flip-upper"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flip-lower"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the CSS animation is split into two phases (top flips first, then bottom):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.flip-digit&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;perspective&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1000px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.flip-upper&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform-origin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bottom&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.flip-lower&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform-origin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;top&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rotateX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;90deg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.flip-digit.play&lt;/span&gt; &lt;span class="nc"&gt;.flip-upper&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flipUpper&lt;/span&gt; &lt;span class="m"&gt;0.3s&lt;/span&gt; &lt;span class="n"&gt;ease-in&lt;/span&gt; &lt;span class="n"&gt;forwards&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.flip-digit.play&lt;/span&gt; &lt;span class="nc"&gt;.flip-lower&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flipLower&lt;/span&gt; &lt;span class="m"&gt;0.3s&lt;/span&gt; &lt;span class="n"&gt;ease-out&lt;/span&gt; &lt;span class="m"&gt;0.3s&lt;/span&gt; &lt;span class="n"&gt;forwards&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="n"&gt;flipUpper&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nt"&gt;to&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rotateX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;-90deg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="n"&gt;flipLower&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nt"&gt;to&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rotateX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0deg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Keeping it smooth (and avoiding "new value flips first")
&lt;/h2&gt;

&lt;p&gt;The most common bug I hit: the digit briefly shows the &lt;strong&gt;new&lt;/strong&gt; number on the top half before the flip starts, which ruins the illusion.&lt;/p&gt;

&lt;p&gt;The fix was to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Treat the currently-rendered value (&lt;code&gt;current&lt;/code&gt;) as authoritative for the static lower half&lt;/li&gt;
&lt;li&gt;Only update &lt;code&gt;current&lt;/code&gt; &lt;strong&gt;after&lt;/strong&gt; the flip finishes&lt;/li&gt;
&lt;li&gt;Use a layout effect (&lt;code&gt;useLayoutEffect&lt;/code&gt;) so DOM updates and animation class toggles happen without visible flicker&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Preferences: theme + 12/24 format
&lt;/h2&gt;

&lt;p&gt;Preferences are simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Store &lt;code&gt;theme&lt;/code&gt; and &lt;code&gt;format&lt;/code&gt; in &lt;code&gt;localStorage&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Apply theme via &lt;code&gt;document.documentElement.dataset.theme&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Format time via Luxon:

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;HHmmss&lt;/code&gt; for 24-hour&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;hhmmss&lt;/code&gt; + &lt;code&gt;a&lt;/code&gt; for 12-hour + AM/PM&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;I also used tabular numbers (&lt;code&gt;font-variant-numeric: tabular-nums&lt;/code&gt;) to prevent digit width jitter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fullscreen toggle
&lt;/h2&gt;

&lt;p&gt;Fullscreen is handled with the standard Fullscreen API (&lt;code&gt;requestFullscreen&lt;/code&gt; / &lt;code&gt;exitFullscreen&lt;/code&gt;). ESC exits fullscreen by browser default; the app just listens for &lt;code&gt;fullscreenchange&lt;/code&gt; to keep the button label in sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  Accessibility and motion
&lt;/h2&gt;

&lt;p&gt;Flip animations look great, but not everyone wants them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If &lt;code&gt;prefers-reduced-motion: reduce&lt;/code&gt; is set, the animated layers are hidden and the clock becomes a static digit update.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Small SEO baseline (for a single-page tool)
&lt;/h2&gt;

&lt;p&gt;Even for a simple tool, it's worth adding:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A good &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt; and meta description&lt;/li&gt;
&lt;li&gt;A canonical URL&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;sitemap.xml&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next on my list: OpenGraph/Twitter cards and JSON-LD (structured data) so links look better when shared.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it / feedback
&lt;/h2&gt;

&lt;p&gt;If you have thoughts on the animation timing, typography, or anything you'd like to see added, I'd love feedback:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.uni-datetime.site/" rel="noopener noreferrer"&gt;https://www.uni-datetime.site/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>ai</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
