<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Nikhil Mukundan’s blog</title>
    <link>https://nikhil.ahlan.im/blog</link>
    <atom:link href="https://nikhil.ahlan.im/rss.xml" rel="self" type="application/rss+xml" />
    <description>Notes on building web software, by Nikhil Mukundan.</description>
    <language>en</language>
    <lastBuildDate>Tue, 20 Oct 2026 04:05:00 GMT</lastBuildDate>
    <item>
      <title>What actually makes a school website fast</title>
      <link>https://nikhil.ahlan.im/blog/fast-school-websites</link>
      <guid isPermaLink="true">https://nikhil.ahlan.im/blog/fast-school-websites</guid>
      <pubDate>Fri, 16 Oct 2026 06:00:00 GMT</pubDate>
      <description>Lessons from rebuilding school websites in Next.js: static pages, almost no JavaScript, lighter images and technical SEO for parents searching locally.</description>
      <category>Performance</category>
      <category>SEO</category>
      <category>Next.js</category>
      <content:encoded><![CDATA[<p>Parents look up schools on their phones, often on mobile data, often while doing something else. A school website has a few seconds to show them the right campus, the curriculum and a way to get in touch. Most school sites I have seen are slow for the same handful of reasons, and none of them are hard to fix.</p>
<p>These are the things that made the biggest difference on the sites I rebuild.</p>
<h2 id="make-every-page-a-plain-html-file">Make every page a plain HTML file</h2>
<p>Almost nothing on a school website changes between visitors. Fees, curriculum, staff and admissions steps are the same for everyone. So every page is generated once at build time and served as static HTML.</p>
<p>With Next.js that is the default for any page that doesn't fetch per request. The result is a page that is ready the moment it arrives, with nothing to wait for.</p>
<h2 id="ship-almost-no-javascript">Ship almost no JavaScript</h2>
<p>On the newest builds the only JavaScript in the browser is the mobile menu, the enquiry forms and a click-to-play video. Everything else is HTML and CSS.</p>
<p>That rules some things out:</p>
<ul><li>No CSS framework. The whole design system is one stylesheet of custom properties.</li><li>No third-party scripts by default. Every chat widget and tracker has to earn its place, because each one is code the page has to download and run before it settles.</li><li>No video that loads on page open. A poster image shows until someone presses play.</li></ul>
<h2 id="fix-the-images-first">Fix the images first</h2>
<p>The single heaviest file on one of the old sites was a 5 MB PNG on the home page. Converting heavy images to WebP, sizing them for where they actually appear, and lazy-loading anything below the fold usually does more than any other change.</p>
<p>A quick way to find them: open the network tab, sort by size, and look at the top five.</p>
<h2 id="technical-seo-for-local-search">Technical SEO for local search</h2>
<p>Parents search for things like &quot;British school in Ajman&quot; or &quot;CBSE school in Al Qusais&quot;. To show up, a page needs to say clearly what and where the school is:</p>
<ul><li>A unique title and description for every page, written for a parent, not for a keyword list.</li><li>Structured data that marks the site as a school, with its address and curriculum.</li><li>A sitemap generated from the same data as the pages, so it is never out of date.</li><li>Real text on the page. A curriculum described in an image is invisible to search.</li></ul>
<h2 id="keep-content-out-of-the-components">Keep content out of the components</h2>
<p>Every fact on the site lives in a data file: fees, staff, the curriculum, policies, contact details. The components only arrange it.</p>
<p>That means updating next year's fees is an edit to one file, and the sitemap, the structured data and the page all change together.</p>
<h2 id="the-checklist">The checklist</h2>
<p>If I had to fit this on a sticky note:</p>
<ol><li>Static HTML for every page.</li><li>JavaScript only where something is interactive.</li><li>Images in WebP, sized correctly, lazy below the fold.</li><li>A real title, description and structured data on every page.</li><li>Content in data, not in components.</li></ol>
<p>None of this needs a new framework. It mostly needs saying no to things.</p>]]></content:encoded>
    </item>
    <item>
      <title>Building Sandglass: prayer times without a server</title>
      <link>https://nikhil.ahlan.im/blog/prayer-times-on-device-sandglass</link>
      <guid isPermaLink="true">https://nikhil.ahlan.im/blog/prayer-times-on-device-sandglass</guid>
      <pubDate>Fri, 09 Oct 2026 06:00:00 GMT</pubDate>
      <description>How Sandglass calculates prayer times on the device, keeps its timer honest in background tabs, and works properly in Arabic.</description>
      <category>Next.js</category>
      <category>TypeScript</category>
      <category>Product</category>
      <content:encoded><![CDATA[<p>Most focus timers assume your day is one long uninterrupted block. Here it isn't. If you work or study in the UAE, prayer breaks the day into natural sections, and a timer that ignores them is a timer you end up fighting.</p>
<p>So I am building Sandglass, a focus app made for the Gulf. These are the parts that were more interesting to build than I expected.</p>
<h2 id="calculating-prayer-times-on-the-device">Calculating prayer times on the device</h2>
<p>Prayer times depend on where you are and on the calculation method, so the obvious approach is to call an API. I didn't want to. An API means a network round trip, a failure mode, and a reason the app can't work offline.</p>
<p>Instead Sandglass uses <a href="https://github.com/batoulapps/adhan-js" target="_blank" rel="noopener noreferrer">adhan</a>, a small library that does the astronomy in JavaScript. You give it coordinates, a date and a method, and it gives you times.</p>
<pre data-lang="ts"><code class="language-ts">import { Coordinates, CalculationMethod, PrayerTimes } from 'adhan';

const dubai = new Coordinates(25.2048, 55.2708);
const params = CalculationMethod.Dubai();
const times = new PrayerTimes(dubai, new Date(), params);

times.nextPrayer(); // 'asr'</code></pre>
<p>Each emirate has its own coordinates in the app, so someone in Fujairah gets Fujairah's times, not Dubai's. No location permission needed: you pick your city once.</p>
<h2 id="a-timer-that-survives-background-tabs">A timer that survives background tabs</h2>
<p>Browsers slow down timers in background tabs. If you count down by subtracting one every second with <code>setInterval</code>, a 25-minute session in a background tab can quietly take 30.</p>
<p>The fix is to never count. Sandglass stores when a session started and how long it should last, and works out the remaining time from the clock every time it draws:</p>
<pre data-lang="ts"><code class="language-ts">const remaining = Math.max(0, session.endsAt - Date.now());</code></pre>
<p>The interval only decides how often the screen updates, not how much time has passed. Close the laptop, come back, and the timer is right.</p>
<h2 id="arabic-is-a-layout-not-a-translation">Arabic is a layout, not a translation</h2>
<p>Translating the strings was the easy part. Arabic reads right to left, so the whole layout has to mirror: the timer controls, the task list, the progress bar, even which side a panel slides in from.</p>
<p>Setting <code>dir=&quot;rtl&quot;</code> on the page does most of the work, as long as the CSS uses logical properties like <code>margin-inline-start</code> instead of <code>margin-left</code>. Anything written with left and right had to be rewritten. It is worth doing from the first line of CSS.</p>
<h2 id="no-account-needed">No account needed</h2>
<p>Sandglass works fully as a guest. Everything is saved in the browser. If you sign in, your data syncs through Supabase, with row-level security on every table so no one can read anyone else's sessions.</p>
<p>Most people trying a focus app will not make an account first. Asking them to is the fastest way to lose them.</p>
<h2 id="small-things-that-make-it-feel-finished">Small things that make it feel finished</h2>
<ul><li>Share cards for a focus streak are drawn on a Canvas at 1080×1920, the size Instagram stories expect, so they look sharp when posted.</li><li>The ambient sounds are generated with the Web Audio API instead of shipped as audio files. The app stays small and the sound never loops audibly.</li><li>It installs as a PWA, so it sits on your home screen like any other app.</li></ul>
<p>Sandglass is still in development. If you work or study in the UAE and want to try it early, <a href="mailto:xnikhilmukundan@gmail.com">email me</a>.</p>]]></content:encoded>
    </item>
    <item>
      <title>Locking down a CRM with Postgres row-level security</title>
      <link>https://nikhil.ahlan.im/blog/row-level-security-admissions-crm</link>
      <guid isPermaLink="true">https://nikhil.ahlan.im/blog/row-level-security-admissions-crm</guid>
      <pubDate>Fri, 02 Oct 2026 06:00:00 GMT</pubDate>
      <description>How Postgres row-level security keeps each school's leads private in a Supabase admissions CRM, and lets an agency import leads without reading any.</description>
      <category>Supabase</category>
      <category>PostgreSQL</category>
      <category>Security</category>
      <content:encoded><![CDATA[<p>The admissions system I built for a group of schools has one rule that matters more than any feature: a registrar at one campus must never see a parent's phone number from another campus. Not in the UI, not in an API response, not by editing a request in the browser's dev tools.</p>
<p>You can try to enforce that in the front end. It works right up until someone opens the network tab. So the rule lives in the database instead.</p>
<h2 id="why-the-database-not-the-app">Why the database, not the app</h2>
<p>The app is Next.js talking to Supabase, which is Postgres underneath. Supabase gives every signed-in user a token, and Postgres can read who that user is on every query. Row-level security (RLS) lets you attach a condition to a table that Postgres checks on every row, for every query, no matter where the query comes from.</p>
<p>That means the app code can ask for &quot;all leads&quot; and still only get the right ones. There is no way to forget the filter, because the filter is not in the app.</p>
<h2 id="the-shape-of-it">The shape of it</h2>
<p>Every staff login has a profile with a role and a campus. A lead belongs to a campus. The policy for reading leads says: you can see a row if it belongs to your campus, or if you are a super admin.</p>
<pre data-lang="sql"><code class="language-sql">alter table leads enable row level security;

create policy &quot;Read own campus leads&quot; on leads
  for select using (
    exists (
      select 1 from profiles p
      where p.id = auth.uid()
        and (p.role = 'super_admin' or p.school_id = leads.school_id)
    )
  );</code></pre>
<p>Notes, the activity log and appointments follow the same pattern, so a lead's history is exactly as private as the lead.</p>
<p>One rule I learned the hard way: keep the policy on <code>profiles</code> itself simple. If a policy on <code>profiles</code> reads <code>profiles</code> again, Postgres can end up recursing, and the error you get is not obvious. Read your own row by <code>auth.uid()</code> and stop there.</p>
<h2 id="a-role-that-can-write-but-not-read">A role that can write but not read</h2>
<p>Leads also arrive from Facebook and Instagram ads. An outside social media agency exports them from Meta Ads Manager as a CSV, and someone has to get that file into the system.</p>
<p>I did not want that agency to be able to browse leads, phone numbers or notes. So their login gets a <code>socials</code> role with no <code>select</code> policy on <code>leads</code> at all. Without a policy, Postgres returns nothing.</p>
<p>Importing goes through one function instead of a direct insert:</p>
<pre data-lang="sql"><code class="language-sql">create or replace function import_social_leads(target_school uuid, rows jsonb)
returns integer
language plpgsql
security definer
set search_path = public
as $$
begin
  -- check the caller really has the socials role, skip duplicates,
  -- tag each lead as facebook or instagram, then insert
  ...
end;
$$;</code></pre>
<p><code>security definer</code> means the function runs with its owner's rights, so it can insert into a table the caller cannot read. The caller gets back a count of rows imported and nothing else. They can do the one job they need to do, and that is all.</p>
<h2 id="migrations-you-can-run-twice">Migrations you can run twice</h2>
<p>Every change to the schema is a plain SQL file, and every statement in it is safe to run again: <code>create table if not exists</code>, <code>create or replace function</code>, and <code>drop policy if exists</code> before each <code>create policy</code>.</p>
<p>That sounds fussy, but it means anyone on the team can paste a migration into the Supabase SQL editor without worrying about whether it already ran. In a small team without a release process, that matters more than any tooling.</p>
<h2 id="what-i-would-tell-myself-at-the-start">What I would tell myself at the start</h2>
<ul><li>Put access rules in the database from day one. Retrofitting them is much harder than writing them first.</li><li>Test policies by signing in as each role and trying to break them, not by reading the SQL.</li><li>When a role needs to write data it should not see, give it a function, not a table.</li></ul>]]></content:encoded>
    </item>
  </channel>
</rss>
