← Home
Antyodaya Rehabilitation Multipurpose Foundation · Gadchiroli

Features & technical
specification

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.

v1.1.2 — launched Astro 7 React 19 islands Tailwind 4 Supabase Cloudflare Workers TypeScript
15
Public routes
23
Admin screens
39
Postgres tables
2
Languages, one URL
0 KB
JS on 11 of 12 pages
The public surface

Fifteen routes

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.

Reads hero · announcements · impact_stats · programs · helplines
/about

Mission and story, core values, milestone timeline, registration details.

Reads about · core_values · milestones · registrations
/programs

Every published programme as a card, with its service count.

Reads programs · program_services
/programs/[slug]

One programme in full: description, the services it offers, and its own FAQ set.

Reads programs · program_services · program_faqs
/contact

Office locations and a five-field enquiry form. Requires an email or a phone number.

Writes contact_submissions
/donate

Suggested amounts, bank and UPI details, and a toggleable 80G note.

Reads donation_config · donation_options
/volunteer

Open roles and opportunities, plus an application form.

Writes volunteer_submissions
/gallery

Albums of photographs, each image carrying its own alt text.

Reads gallery_albums · gallery_media
/resources

Downloadable documents and links — awareness material, forms, reports.

Reads resources
/faq

Site-wide frequently asked questions, ordered and editable.

Reads faqs
/team

People, grouped into categories — trustees, staff, volunteers.

Reads team_categories · team_members
/blog

Published posts, newest first, filterable by category.

Reads blog_posts · blog_categories
/blog/[slug]

One post with its byline, dates, and Article structured data. 404s on an unpublished slug.

Reads blog_posts → team_members (author)
/events

Upcoming and past events with dates and locations.

Reads events
/events/[slug]

One event, with a registration form when registration is open.

Writes event_registrations
Site chrome is data too. The header navigation, footer links, social links, helpline number and announcement banner all come from the database, so the foundation can change them without a deploy. Base.astro runs eight chrome queries per request.
The admin surface

Twenty-three screens

React islands mounted inside Astro pages, all behind a server-side gate in middleware. Nothing about the admin ships to the public site.

RouteScreenWhat it edits
/adminDashboardLanding page — links into every editor
/admin/loginSign inSupabase email + password; the only ungated admin route
/admin/settingsSite settingsOrg name, logo, contact details, registration numbers
/admin/heroHome heroHeadline, sub-copy, image, call-to-action buttons
/admin/aboutAbout pageMission text, core values, milestones
/admin/impactOur reachImpact counters — null renders “Coming soon”, never 0
/admin/programsProgrammesList, reorder, publish
/admin/programs/[id]Programme editorOne programme plus its nested services and FAQs
/admin/blogBlogPost list with draft/published state
/admin/blog/[id]Post editorRich text, author, category, SEO fields, both languages
/admin/eventsEventsEvent list
/admin/events/[id]Event editorDates, location, registration window
/admin/teamTeamMembers, categories, author flag, photos
/admin/testimonialsTestimonialsQuotes and attribution
/admin/galleryGalleryAlbums and images, with alt text
/admin/mediaMedia libraryEvery uploaded file, with size and alt text
/admin/resourcesResourcesDocuments and links
/admin/faqsFAQsSite-wide question set
/admin/donateDonationsAmounts, bank details, 80G note toggle
/admin/submissionsContact inboxRead-only — enquiries from /contact
/admin/volunteersVolunteer inboxRead-only — applications
/admin/registrationsRegistrations inboxRead-only — event sign-ups
/admin/auditAudit logRead-only — every write, recorded by trigger

Shared editor components

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.

Media picker, everywhere

Any screen that takes an image opens the same MediaPicker against the shared library — no per-screen upload widgets, no duplicate files.

Three roles

admin, editor, viewer in profiles. The role decides which buttons appear; it is not what enforces permission — RLS does that on every statement.

Salient features

What the site actually does

01 Bilingual by construction, not by translation layer

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.

  • Language resolves from the ay_lang cookie, then Accept-Language, then Marathi.
  • The toggle is a plain <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.
  • Interface labels live in a ui_strings table (149 rows), not in the code.

02 Three public forms, no JavaScript

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.

  • Honeypot field: a bot that fills it sees success and nothing is written.
  • Astro's CSRF origin check rejects a POST with no Origin header — 403.
  • Validation errors render inline and preserve what the visitor typed.
  • Inserts run under an anonymous RLS policy; the anon key cannot read any of these tables back.

03 A centralised media library

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.

  • Images are resized in the browser before upload — longest edge 1600 px, encoded as WebP.
  • A quality ladder steps down until the file fits a hard 250 KB cap; if it still doesn't, the editor is told to crop it rather than being silently served a huge file.
  • Files already under 150 KB and within bounds pass through untouched.
  • Alt text is captured next to the file, so it follows the image to every place it is used.
  • The 250 KB figure is derived from the free tier's 5 GB monthly egress — the binding quota, not the 1 GB of storage.

04 Donations and 80G

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.

05 A content model with real depth

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.

06 Built for phones on patchy rural networks

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.

07 Editors see their drafts on the live site

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.

Architecture

How it is built

Four layers, and one rule that shapes all of them: the runtime is workerd, not Node.

Edge runtime

Cloudflare Workers via @astrojs/cloudflare. output: 'server' — every page renders on demand.

Why not static: every page reads content from Supabase, so prerendering would bake build-time content and make the CMS look broken.

Application

Astro 7 with React 19 islands, Tailwind 4 (CSS-first — no config file, tokens live in @theme).

Island policy: JavaScript only where interaction earns it. Public pages get none; the admin gets all of it.

Data

Supabase Postgres over PostgREST, accessed with @supabase/ssr. One client per request, bound to that request's cookies.

Typed: types.ts generated from the live schema by the Supabase CLI.

Presentation

Self-hosted variable fonts — Source Serif 4, Plus Jakarta Sans, Noto Sans Devanagari — unicode-range gated, font-display: swap.

Script-aware: :lang(mr) renders Devanagari at 18px/1.75 against Latin's 16px/1.7.

Request lifecycle

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.

Query helpers

Twelve modules under src/lib/queries/, one per content area. They share three conventions:

  • Order by sort_order everywhere.
  • Return null or [] on error — one missing row degrades to an empty section, never a 500 on the whole page.
  • Never filter on published. That is RLS's job; a redundant filter would hide a broken policy.
The runtime distinction is not pedantry. 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.
Data model

Thirty-nine tables

Thirty-eight in the base schema, plus media_assets from the media library migration. Grouped by what they serve.

Site chrome

site_settings · navigation_items · social_links · announcements · ui_strings · helplines · office_locations · partners

Organisation

about · hero · milestones · core_values · registrations · impact_stats · testimonials

Programmes

programs · program_services · program_faqs

People

profiles · team_categories · team_members · volunteer_roles · volunteer_opportunities

Content

blog_posts · blog_categories · events · gallery_albums · gallery_media · resources · faqs · media_assets

Giving

donation_config · donation_options · donations

Inbound (anon insert, editor read)

contact_submissions · volunteer_submissions · event_registrations · newsletter_subscribers

System

audit_log — written by trigger on every editor write, readable only by editors.

Storage buckets

media — public, everything editors upload.
receipts — private, signed URLs only, unreachable from the public URL helper.

Migrations are numbered and never edited. 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.
Security model

The database is the access control

Not the query code, not the UI, not the router. Four layers, in the order a request meets them.

LayerMechanismWhat 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.

Verified at the database layer

  • Simulated admin JWT: is_admin() / is_editor() true; writes succeed; audit_log readable.
  • Simulated non-editor JWT: both predicates false; an update to programs affects 0 rows; audit_log returns nothing.
  • Anon key: contact_submissions → [], donations → [], event_registrations → [].
  • Audit triggers confirmed firing. Both runs rolled back.

Other hardening

  • noindex on every /admin/* page including login.
  • robots.txt disallows /admin and /api/.
  • The sitemap is generated from the database, not from the route table — it publishes 18 content URLs and zero admin paths.
  • CSRF origin check on every POST.
If you think you need the service-role key, you almost certainly need a policy instead. That rule has held for the whole project: there is still no service-role code path.
Bilingual

Marathi first, English second

Storage

JSONB {"mr": …, "en": …} on every translatable column. Never indexed directly — always through t(), so the missing-translation fallback lives in one place.

Resolution

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.

One URL, two languages

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.

Typography follows the script

: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.

Interface strings are data

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.

Honest about gaps

tWithFallback() shows a quiet (English) marker where a Marathi translation is missing, rather than pretending the English text is Marathi.

Discoverability & accessibility

Measured, not assumed

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.

SEO

  • Canonical tags, robots.txt and the sitemap all derive from SITE_URL.
  • Sitemap queries the database — dynamic programme, post and event URLs included; admin paths excluded.
  • JSON-LD: NGO on every page, Article on posts with author and both dates, Event gated on a start date.
  • Per-post SEO title and description fields in the editor.
  • Open Graph tags, with og:type=article on posts.

Accessibility — audit results

  • 0 colour-contrast failures across body, muted and subtle text on all three surfaces. Was 6 — two design tokens failed AA, so every page inherited it.
  • 0 controls under 44 px. Was 2.
  • 0 skipped heading levels across twelve pages. /programs went h1 → h3.
  • 0 missing alt, 0 unnamed links, 0 unlabelled inputs.
  • Keyboard-only focus ring via :focus-visible, with a white variant on dark panels.
  • Skip link to main content, in both languages.
Performance

What the visitor actually downloads

MeasureResultNote
Executable JavaScript, public pages0 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 home213 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 sizes1–5 KB each The React runtime loads only on admin and login — behind the gate.
Image cap250 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.
Known and accepted. Rendering on demand means every request hits Supabase with no static caching — deliberate, because prerendering would make the CMS look broken. The 121 KB Devanagari font loads on English pages too, because the language toggle renders मराठी in its own script; that convention was chosen on purpose and the font blocks nothing. List pages still serve full-size images into small boxes — a second ~400 px variant at upload time is the next real win, worth roughly 80% of list-page egress.
Build & deploy

Toolchain

Scripts

  • 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.

Continuous integration

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.

Windows note: wrangler dev holds a lock on dist/. Stop preview before rebuilding or the build fails with EPERM.

Dependencies — the whole list

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

Seven runtime dependencies. No UI library, no state manager, no form library, no CSS-in-JS, no analytics.
Current state

Where the project stands

Version 1.1.2. Phases 0–6 complete; Phase 7 is the post-launch backlog.

PhaseScopeStatus
0Foundations & environmentComplete
1Public site MVPComplete
2Admin coreComplete
3Engagement — volunteer, donate, gallery, resources, FAQComplete
4Content — blog, team, impact, testimonials, eventsComplete
5SEO, i18n completeness, accessibility, performanceComplete
6Launch & handoverComplete
7Backlog — payments, 80G receipts, newsletter, analyticsCurrent

Open before the next deploy

  • Migrations 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.
  • Event registration has never been submitted end to end — the last unexercised code path. Everything it depends on is verified; the round trip is not.

On the backlog

  • Analytics — a privacy-respecting counter. Cheapest item, highest information gain.
  • Newsletter — the table and its policies already exist; no form is wired to it.
  • Razorpay + 80G receipt PDFs — the largest item, with real-world obligations attached.
  • Thumbnails — a second ~400 px variant for cards and gallery tiles.
  • Content revisions — an audit log exists; a revision history does not.
Content rule, still in force. The design mockup contained invented placeholder copy — a 2014–2025 milestone timeline, a testimonial attributed to a beneficiary, blog headlines. None of it came from the foundation. Anything in that category stays 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.