A bilingual, Marathi-first public website with a 23-screen content management system behind it — server-rendered at the edge on Cloudflare Workers, with Postgres row-level security as the only thing standing between a visitor and staff content.
Twelve fixed routes and three dynamic ones. Every one of them reads its content from the database — nothing on the public site is hard-coded copy.
Home. Hero panel, announcement strip, impact counters, programme cards, helpline.
Mission and story, core values, milestone timeline, registration details.
Every published programme as a card, with its service count.
One programme in full: description, the services it offers, and its own FAQ set.
Office locations and a five-field enquiry form. Requires an email or a phone number.
Suggested amounts, bank and UPI details, and a toggleable 80G note.
Open roles and opportunities, plus an application form.
Albums of photographs, each image carrying its own alt text.
Downloadable documents and links — awareness material, forms, reports.
Site-wide frequently asked questions, ordered and editable.
People, grouped into categories — trustees, staff, volunteers.
Published posts, newest first, filterable by category.
One post with its byline, dates, and Article structured data. 404s on an unpublished slug.
Upcoming and past events with dates and locations.
One event, with a registration form when registration is open.
Base.astro runs eight chrome queries per request.
React islands mounted inside Astro pages, all behind a server-side gate in middleware. Nothing about the admin ships to the public site.
| Route | Screen | What it edits |
|---|---|---|
| /admin | Dashboard | Landing page — links into every editor |
| /admin/login | Sign in | Supabase email + password; the only ungated admin route |
| /admin/settings | Site settings | Org name, logo, contact details, registration numbers |
| /admin/hero | Home hero | Headline, sub-copy, image, call-to-action buttons |
| /admin/about | About page | Mission text, core values, milestones |
| /admin/impact | Our reach | Impact counters — null renders “Coming soon”, never 0 |
| /admin/programs | Programmes | List, reorder, publish |
| /admin/programs/[id] | Programme editor | One programme plus its nested services and FAQs |
| /admin/blog | Blog | Post list with draft/published state |
| /admin/blog/[id] | Post editor | Rich text, author, category, SEO fields, both languages |
| /admin/events | Events | Event list |
| /admin/events/[id] | Event editor | Dates, location, registration window |
| /admin/team | Team | Members, categories, author flag, photos |
| /admin/testimonials | Testimonials | Quotes and attribution |
| /admin/gallery | Gallery | Albums and images, with alt text |
| /admin/media | Media library | Every uploaded file, with size and alt text |
| /admin/resources | Resources | Documents and links |
| /admin/faqs | FAQs | Site-wide question set |
| /admin/donate | Donations | Amounts, bank details, 80G note toggle |
| /admin/submissions | Contact inbox | Read-only — enquiries from /contact |
| /admin/volunteers | Volunteer inbox | Read-only — applications |
| /admin/registrations | Registrations inbox | Read-only — event sign-ups |
| /admin/audit | Audit log | Read-only — every write, recorded by trigger |
BilingualField renders Marathi and English side by side for
every translatable value. RichTextField handles post bodies.
SaveBar gives every editor the same save and status behaviour.
Any screen that takes an image opens the same
MediaPicker against the shared library — no per-screen upload widgets, no
duplicate files.
admin, editor,
viewer in profiles. The role decides which buttons appear;
it is not what enforces permission — RLS does that on every statement.
Every translatable column is JSONB {"mr": …, "en": …} — one row holds both
languages, so a missing translation is a data state rather than a missing record. All reads go
through t(field, lang), which keeps the fallback in exactly one place, and
tWithFallback() where the UI should show a quiet (English) marker.
Marathi is the default; English is the second language, not the source.
ay_lang cookie, then Accept-Language, then Marathi.<a> — middleware handles ?lang=, sets the cookie, and redirects to a clean URL. It works with JavaScript off.lang is bound to the element containing the text, so screen readers and the Devanagari font rules follow the text's actual language.ui_strings table (149 rows), not in the code.Contact, volunteer application and event registration are plain HTML forms posting to Astro endpoints. An earlier React version of the contact form cost 181 KB on the only public page that had any JavaScript — for five fields. It was rewritten as markup.
Origin header — 403.One media bucket and one media_assets table replaced seven
per-entity buckets and a scattering of *_path columns. Every upload is registered
once and can be reused anywhere.
Suggested amounts, bank and UPI details and the 80G tax-exemption note are all editable.
The receipts bucket is private and deliberately absent from the storage URL
helper's bucket union — an 80G receipt needs a signed URL and must never be publicly linkable.
donations is editor-only with no public read policy.
Programmes carry nested services and their own FAQ sets. Blog posts carry an author drawn from the team table, a category, and per-post SEO fields. Events carry a date range and a registration window. Team members are grouped into categories and can be flagged as authors.
The audience is in Gadchiroli district, largely on mobile. Every interactive control is at least 44 px tall — that is a constraint, not a target, and layouts are not allowed to shrink controls to fit. A persistent “Call Now” bar puts the helpline one tap away.
Because the Supabase client is bound to the request's cookies, a signed-in editor's session
satisfies the editor all policy and unpublished rows render on public pages. This
is kept as a feature — free draft preview — with one consequence worth stating plainly:
“it looks fine to me” is not evidence about what visitors see. Check in a private window.
Four layers, and one rule that shapes all of them: the runtime is workerd, not Node.
Cloudflare Workers via @astrojs/cloudflare.
output: 'server' — every page renders on demand.
Astro 7 with React 19 islands, Tailwind 4
(CSS-first — no config file, tokens live in @theme).
Supabase Postgres over PostgREST, accessed with
@supabase/ssr. One client per request, bound to that request's cookies.
types.ts generated from the live schema by the
Supabase CLI.Self-hosted variable fonts — Source Serif 4, Plus Jakarta Sans,
Noto Sans Devanagari — unicode-range gated, font-display: swap.
:lang(mr) renders Devanagari at 18px/1.75
against Latin's 16px/1.7.Middleware runs first on every request: it creates the Supabase
client into locals, resolves the language, and — for any /admin
path other than login — calls auth.getUser(), which revalidates against
Supabase rather than trusting a cookie the client could have forged.
The auth check is wrapped so it fails closed: if Supabase is unreachable, the visitor is redirected to login, not shown a 500. A 500 on an auth check is the kind of thing that gets "fixed" by removing the check.
Twelve modules under src/lib/queries/, one per
content area. They share three conventions:
sort_order everywhere.null or [] on error — one missing row degrades to an empty section, never a 500 on the whole page.published. That is RLS's job; a redundant filter would hide a broken policy.npm run dev
runs on Node; production runs on workerd. The first real bug in this project was
@supabase/ssr throwing cookies.getAll is not a function — which
astro dev never surfaced. Anything touching middleware, auth, cookies or a server
endpoint is not considered verified until it has run under wrangler dev.
Thirty-eight in the base schema, plus media_assets from the media
library migration. Grouped by what they serve.
site_settings · navigation_items ·
social_links · announcements · ui_strings ·
helplines · office_locations · partners
about · hero ·
milestones · core_values · registrations ·
impact_stats · testimonials
programs · program_services ·
program_faqs
profiles · team_categories ·
team_members · volunteer_roles ·
volunteer_opportunities
blog_posts · blog_categories ·
events · gallery_albums · gallery_media ·
resources · faqs · media_assets
donation_config ·
donation_options · donations
contact_submissions ·
volunteer_submissions · event_registrations ·
newsletter_subscribers
audit_log — written by trigger on every
editor write, readable only by editors.
media — public, everything editors upload.
receipts — private, signed URLs only, unreachable from the public URL helper.
0001_schema_v2.sql applies once
to a fresh project — its create policy statements throw on a re-run — so a
correction is always a new numbered file, never a change to an old one. Migrations carry
Devanagari seed data and must stay UTF-8 without a BOM; PowerShell's default encoding
mangled every Marathi string the first time this was done.
Not the query code, not the UI, not the router. Four layers, in the order a request meets them.
| Layer | Mechanism | What it stops |
|---|---|---|
| Row-level security | Policies on every table. Public reads use the anon key and see only
published = true rows because the policy says so. |
A leaked anon key reads nothing it shouldn't. Insert into programs as
anon → 401. |
| Server-side admin gate | Astro middleware, before any HTML is produced. auth.getUser()
revalidates with Supabase. |
A forged session cookie. All 22 gated admin routes redirect to
/admin/login?next=…. |
| Service-role key boundary | Declared in astro.config.mjs as
context: 'server', access: 'secret'. |
Importing it from a client component is a build error, not a lint rule someone has to remember. Nothing in the codebase uses it. |
| CI secret scan | Greps dist/ for a leaked key in both the legacy JWT and current
sb_secret_ formats. |
A key reaching the deployed bundle by any route the first three miss. |
is_admin() / is_editor() true; writes succeed; audit_log readable.programs affects 0 rows; audit_log returns nothing.contact_submissions → [], donations → [], event_registrations → [].noindex on every /admin/* page including login.robots.txt disallows /admin and /api/.JSONB {"mr": …, "en": …} on every
translatable column. Never indexed directly — always through t(), so the
missing-translation fallback lives in one place.
ay_lang cookie → Accept-Language
header → Marathi. Middleware consumes ?lang=, sets the cookie, and 303s to a
clean URL so the choice persists and the address bar stays tidy.
Deliberate. Splitting every route into
/mr and /en would double the URL surface and split inbound links
for a weak signal. x-default names the canonical URL.
:lang(mr) sets 18px/1.75 for Devanagari
against Latin's 16px/1.7. The 121 KB Devanagari face is unicode-range gated and
font-display: swap.
149 rows in ui_strings. An audit across
125 literal call sites found zero missing keys. An unresolved key renders as the key
itself — a designed fallback, visible rather than silent.
tWithFallback() shows a quiet
(English) marker where a Marathi translation is missing, rather than pretending the
English text is Marathi.
Three of the six findings in the accessibility pass were invisible to both typechecking and code review. They only appeared when someone computed the numbers in a real browser.
robots.txt and the sitemap all derive from SITE_URL.NGO on every page, Article on posts with author and both dates, Event gated on a start date.og:type=article on posts./programs went h1 → h3.alt, 0 unnamed links, 0 unlabelled inputs.:focus-visible, with a white variant on dark panels.| Measure | Result | Note |
|---|---|---|
| Executable JavaScript, public pages | 0 bytes on 11 of 12 | The homepage carries one 602-byte inlined counter animation. Each page also has one <script> of JSON-LD, which is data. |
| Page weight, English home | 213 KB over 5 requests | 3.8 KB HTML, 10.4 KB CSS, 199 KB fonts. Every face is font-display: swap, so none blocks first paint. |
| Admin island sizes | 1–5 KB each | The React runtime loads only on admin and login — behind the gate. |
| Image cap | 250 KB, longest edge 1600 px | Enforced in the browser at upload, as WebP. |
| TTFB, measured locally | ~1.1 s | Base.astro runs 8 chrome queries per request before the page's own. This is the largest remaining latency lever. |
npm run dev — Astro dev server, on Node. Fast, but not the deploy runtime.npm run build && npm run preview — wrangler dev. This is the real runtime.npm run check — astro check; the project holds at 0 errors, 0 warnings.npm run deploy — build then wrangler deploy.npm run types — regenerate types.ts from the live schema.npm ci → typecheck → build → secret scan. Green.
CI deliberately does not deploy — the project uses
dashboard-driven Cloudflare Workers Builds, which trigger on push to main.
wrangler dev holds a lock on
dist/. Stop preview before rebuilding or the build fails with
EPERM.Runtime: astro ·
@astrojs/cloudflare · @astrojs/react · react ·
react-dom · @supabase/supabase-js · @supabase/ssr
Build: typescript · tailwindcss ·
@tailwindcss/vite · @astrojs/check · wrangler ·
three @fontsource-variable packages
Version 1.1.2. Phases 0–6 complete; Phase 7 is the post-launch backlog.
| Phase | Scope | Status |
|---|---|---|
| 0 | Foundations & environment | Complete |
| 1 | Public site MVP | Complete |
| 2 | Admin core | Complete |
| 3 | Engagement — volunteer, donate, gallery, resources, FAQ | Complete |
| 4 | Content — blog, team, impact, testimonials, events | Complete |
| 5 | SEO, i18n completeness, accessibility, performance | Complete |
| 6 | Launch & handover | Complete |
| 7 | Backlog — payments, 80G receipts, newsletter, analytics | Current |
0007–0010 are written but not yet applied. Until they run, Donations → Save and Our reach → Save fail, because PostgREST rejects an entire update when a column is missing.0010 (media library) must go last, and must be applied before the media-library code reaches production. It aborts on its own if any legacy bucket still holds a file.published = false
until the organisation signs off, and social links other than WhatsApp are seeded unpublished
with empty URLs: a guessed profile link would be a dead link in a real charity's footer.