Built for Lebanese municipalities

Your municipality,
in your pocket.

بلديتك في جيبك

Baladiytak connects Lebanese citizens with their municipality — announcements, services, appointments, and civic life, bilingual, offline-ready, and built from scratch.

Explore the app See the code quality →
16
Feature modules
2
Languages · EN / AR
24
DB tables, all RLS
iOS
Android
Native apps
Switch language: تغيير اللغة:

The app, in detail

Five screens.
One complete civic platform.

9:41 ●●● ب Aamchit · عمشيت Jbeil District, North Lebanon 🔔 3 EN / AR 🚨 Police Emergency · 112 Tap to call your local police station Call Quick Actions 📢 News 📅 Appt. 📄 Docs 💳 Payments Announcements See all → 📌 PINNED Summer Festival 2026 Jul 4–6 · Aamchit Waterfront · Free Entry 2d ago Water Network Maintenance Wed May 6 · 8am–2pm · Main Street 4d ago Church Rd & Old Quarter affected Now Open Monday–Friday · 08:00 – 15:00 Hours → 🏠 Home 🏛️ Municipality ⚙️ Services 🔍 Discover 👥 Community

Screen 01

Home Dashboard

The first thing citizens see every time they open the app. The municipality's cover photo and logo set an immediate sense of local identity. Below it: a police emergency card, quick-action shortcuts, the latest announcements sorted by pins, and a live open/closed status for the municipal office.

Bilingual EN / AR Live open/closed status Pinned announcements Push notification badge
9:41 Services الخدمات البلدية 📅 Appointments Book now → 💬 Complaints Submit → 📋 Documents Request → 💳 Service Fees View rates → 🕐 Office Hours ● Open Now 💡 Suggestions Coming Soon 🏠 Home 🏛️ Municipality ⚙️ Services 🔍 Discover 👥 Community

Screen 02

Municipal Services

Every citizen service in a clean 2-column grid. Each card is independently enabled or disabled by the municipality — if a service isn't available, it's either hidden or shown as 'Coming Soon' with a tap that explains why, instead of doing nothing. No dead ends.

Appointments Complaints & suggestions Document requests Service fees Module-gated per municipality
9:41 Discover Events & Places in Aamchit All This Week This Month Free FREE Aamchit Summer Festival 2026 🗓 Jul 4–6 · 🌊 Waterfront Corniche 3 nights of music, food & fireworks · Free admission 🏛️ Aamchit Heritage Walk May 23 · 9:00 AM · Old Quarter Guided tour · 2 hours · All ages FREE Details → Inter-Village Football Jun 13–14 · Aamchit Sports Field 4 village teams · Free admission FREE Details → 🎵 Poetry & Oud Night Aug 15 · 8:00 PM · Cultural House Limited seating · Register in advance $5 entry Details → 🏠 Home 🏛️ Municipality ⚙️ Services 🔍 Discover 👥 Community

Screen 03

Discover Events & Places

A filterable feed of upcoming events and local places — cultural sites, religious landmarks, entertainment venues, and activities. Events have date filters (this week, this month, free only), a hero card for the biggest occasion, and individual detail pages with location, description, and entry fee.

Filter by week / month / free Cultural & religious sites Real events, real dates
9:41 ← Back Book Appointment 1 Date 2 Time 3 Reason 4 Confirm Select a Date May 2026 Mon Tue Wed Thu Fri Sat Sun 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 25 26 27 28 29 30 Selected: Wednesday, May 13 Available Times 8:00 9:00 10:00 11:00 Booked Next: Choose a Time → 🏛 Aamchit Municipality · +961 9 540 000 🏠 Home 🏛️ Municipality ⚙️ Services 🔍 Discover 👥 Community

Screen 04

Appointment Booking

Citizens book appointments without calling the municipality. A 4-step guided flow: pick a date from a rolling 14-day calendar, choose a time slot (fully booked slots are shown but disabled), write a reason, and confirm. The municipality staff manage all slots and bookings from the admin dashboard.

4-step guided flow Live slot availability Staff-configurable slots No phone call needed
9:41 Skip Aamchit · بلدية 📢 Water Maintenance May 6 · 8am – 2pm 📌 Summer Festival ✅ Open · 08:00–15:00 🗓 Heritage Walk New Announcement Aamchit Municipality Just now · tap to open Road works update 🚧 Eastern Entrance ب Stay Informed ابقَ على اطلاع Official announcements, news & updates from your municipality — instantly. Next →

Screen 05

First-Launch Onboarding

New users land on a 3-slide onboarding carousel before seeing the login screen. Each slide explains one core value: staying informed, accessing services, and connecting with the community. Shown only once, persisted in SecureStore so it never reappears.

3-slide carousel First-launch only Skip at any time Persisted in SecureStore
What's inside

16 modules. One app.
Every municipality is different.

Each module can be independently enabled or disabled per municipality. No bloat, no unused features. Citizens only see what their community has activated.

📢
Core
Announcements
Pinned and unpinned posts with bilingual titles, rich body, images, and scheduled publishing. Realtime delivery via Supabase subscriptions.
📅
Services
Appointment Booking
Citizens pick a date from a 14-day rolling calendar, choose from live time slots (with booking count enforcement), and write a reason. Staff get a full management dashboard.
💬
Services
Anonymous Complaints & Suggestions
Citizens submit feedback with zero identity stored. No profile_id column — ever. Rate-limited at the database trigger level, not just the client.
📋
Services
Document Requests
Citizens request municipal documents (birth certificates, property records, etc.). Full status tracking: pending → processing → ready → delivered, with push notifications at each step.
🔍
Discover
Events & Places
Upcoming events with date filters (this week, this month, free only). Cultural, religious, entertainment, and activity venues with maps, phone, and website links.
🕯️
Culture
Nawe (Condolence Board)
A culturally essential feature for Lebanese villages. Families post obituaries; the community leaves condolences. Dark-themed, solemn, and purpose-built for this context.
🏛️
Core
Municipality Profile
Council members with photos and roles, contact details, opening hours with live open/closed status, and a cover photo that defines each app's visual identity.
🔔
Infrastructure
Push Notifications
Expo push tokens, chunked SecureStore for JWT storage, per-module opt-in preferences, and a server-side Edge Function that batches delivery in chunks of 100.
🌐
Core
Full RTL Bilingual Support
Every string, layout direction, chevron orientation, date format, and number is independently switched between English and Arabic. Not an afterthought — designed in from day one.
Built right

Architecture that scales with Lebanon.

One Supabase instance serves every municipality as a multi-tenant system. Each town gets a branded build with its own slug, color, modules, and data — fully isolated.

  • Municipality data is isolated at the RLS policy layer — not application code — so bypassing the app never bypasses the rules.
  • The mobile app is a thin client. All business logic lives server-side or in verified Edge Functions with JWT authentication.
  • Zero service role key exposure in the mobile app. The anon key + RLS is the security model. Server-side admin operations use the service role only in Next.js Server Actions.
  • Per-municipality theming: primary color, secondary color, logo, cover photo, and police number are all runtime values — no rebuilds needed to add a new town.
Expo / React Native
iOS · Android · TypeScript · NativeWind
Zustand Stores
auth · municipality · notifications
Supabase Client
SecureStore JWT · anon key · RLS enforced
PostgreSQL + RLS
24 tables · 40+ policies · row-level isolation
Deno Edge Functions
push notifications · JWT verified · staff-gated
Next.js 14 Admin
Server Actions · httpOnly cookies · service role
Next.js Super-Admin
municipality provisioning · subscription mgmt
Code quality

Handwritten. Type-safe.
No generated boilerplate.

Every line of Baladiytak was written by hand. TypeScript throughout, custom hooks for every data layer, and server actions that re-verify permissions on every call.

baladiytak-mobile/stores/auth.ts
// Supabase tokens exceed SecureStore's 2 048-byte limit.
// We chunk them transparently so storage never silently truncates.
class LargeSecureStore {
  private chunkKey(key: string, index: number) {
    return `${key}_chunk_${index}`;
  }

  async getItem(key: string): Promise<string | null> {
    const meta = await SecureStore.getItemAsync(this.metaKey(key));
    if (!meta) return SecureStore.getItemAsync(key);
    const { count } = JSON.parse(meta) as { count: number };
    const parts: string[] = [];
    for (let i = 0; i < count; i++) {
      const part = await SecureStore.getItemAsync(this.chunkKey(key, i));
      if (part === null) return null;
      parts.push(part);
    }
    return parts.join('');
  }
}

// Auth subscription is returned and cleaned up in _layout.tsx
// to prevent listener accumulation across hot reloads.
initialize: async () => {
  const { data: { subscription } } =
    supabase.auth.onAuthStateChange(async (event, session) => {
      if (event === 'SIGNED_IN' && session) {
        const profile = await fetchProfile(session.user.id);
        set({ session, profile, isGuest: false });
      }
    });
  return subscription; // caller calls .unsubscribe() on unmount
},

Session tokens are chunked across SecureStore keys. Auth listeners are properly torn down. No memory leaks.

supabase/setup.sql — Row Level Security
-- Every table has RLS enabled. No exceptions.
-- Citizens read their municipality only. Staff read/write their own.
-- Super admins go through a SECURITY DEFINER function.

ALTER TABLE announcements ENABLE ROW LEVEL SECURITY;
ALTER TABLE appointments  ENABLE ROW LEVEL SECURITY;
ALTER TABLE complaints     ENABLE ROW LEVEL SECURITY;
-- ... 21 more tables, all locked down

-- Citizens: read published content from their municipality only
CREATE POLICY "announcements: public read published"
  ON announcements FOR SELECT
  USING (is_published = true
    AND municipality_id = app_municipality_id());

-- Staff: verified against the staff table on every request
CREATE POLICY "announcements: staff write"
  ON announcements FOR ALL
  USING (
    EXISTS (
      SELECT 1 FROM staff s
      WHERE s.profile_id = (SELECT auth.uid())
        AND s.municipality_id = announcements.municipality_id
        AND s.is_active = true
        AND (s.role IN ('owner', 'admin')
          OR 'manage_announcements' = ANY(s.permissions))
    )
  );

-- Complaints: anyone can submit, nobody can read except staff
-- No citizen identity stored. Ever. Anonymous by design.
CREATE POLICY "complaints: anyone can submit"
  ON complaints FOR INSERT
  WITH CHECK (true);

RLS policies run inside PostgreSQL — not application code. Bypassing the app never bypasses the rules.

baladiytak-web/apps/admin/app/(dashboard)/announcements/actions.ts
'use server';

import { assertAuth } from '@/lib/auth-guard';
import { createAdminClient } from '@/lib/supabase/admin';

// Every server action:
// 1. Verifies the JWT against Supabase (not just cookies)
// 2. Fetches the staff record fresh from the DB
// 3. Checks the specific permission required
// 4. Uses the server-verified municipalityId — never a client value

export async function createAnnouncement(
  _clientMunicipalityId: string, // intentionally ignored
  data: AnnouncementFormData
) {
  const guard = await assertAuth('manage_announcements');
  if ('error' in guard) return guard; // 403 if not staff
  const { municipalityId } = guard; // from DB, not client

  const supabase = createAdminClient();
  const { error } = await supabase
    .from('announcements')
    .insert({ ...data, municipality_id: municipalityId });

  if (error) return { error: error.message };
  revalidatePath('/dashboard/announcements');
  return {};
}

The municipalityId supplied by the client is intentionally discarded. Only the server-verified value is used.

baladiytak-mobile/hooks/useHomeData.ts
// 4 parallel queries fire simultaneously with Promise.all().
// Skeleton screens show immediately while data loads.
// Error state distinguishes offline from server error.

export function useHomeData(): UseHomeDataReturn {
  const load = useCallback(async (isRefresh = false) => {
    const [ann, nws, evt, hrs] = await Promise.all([
      supabase.from('announcements')
        .select('id, title_en, title_ar, image_url, is_pinned, published_at')
        .eq('municipality_id', mId)
        .eq('is_published', true)
        .order('is_pinned', { ascending: false })
        .limit(5),
      // ... news, events, opening_hours in parallel
    ]);

    setData({
      announcements: (ann.data as Announcement[]) ?? [],
      news:          (nws.data as News[]) ?? [],
      events:        (evt.data as Event[]) ?? [],
      openingHours:  (hrs.data as OpeningHour[]) ?? [],
    });
  }, [municipality?.id]);

  return { data, loading, refreshing, error,
    refetch: () => load(true) };
}

Only the columns each screen actually renders are fetched. No select('*') in production data paths.

Security model

Defense in depth.
Not security theater.

Security decisions were made at the schema level, not patched in at the application level. Here's what that looks like in practice.

🔐
JWT verified on every request
The admin web app uses supabase.auth.getUser() — not getSession() — in every middleware pass. The difference: getUser() hits the Supabase server to validate the token. Expired or revoked sessions are rejected instantly.
middleware.ts
🏢
Tenant isolation at the DB layer
Cross-municipality data leakage is impossible because RLS policies include a municipality_id check enforced by PostgreSQL — not the application. Even a compromised API client can only see its own town's data.
setup.sql · RLS
🕵️
Anonymous complaints by design
The complaints table has no profile_id column, no email field, no IP address. Citizens can report issues without any identity record being created. Anonymity is architectural, not configurable.
complaints table
📵
No service role key on mobile
The mobile app only has the anon key. The service role key — which bypasses all RLS — is confined to Next.js Server Actions and Edge Functions running in trusted server environments, never in client bundles.
lib/supabase/admin.ts
🛡️
DB-level rate limiting
Complaint submissions are rate-limited by a PostgreSQL trigger — not client-side code. An attacker hitting the Supabase REST API directly hits the same limit. Maximum 5 complaints per municipality per minute, enforced in the database.
BEFORE INSERT trigger
🔑
Permission-scoped staff roles
Staff have three tiers: owner, admin, moderator. Moderators hold explicit permission keys (manage_announcements, manage_events, etc.). Every server action checks the specific permission required. No permission = 403, not just a UI hide.
assertAuth() · staff.permissions[]
Admin dashboard

Full control.
From any device, anywhere.

Municipality staff log in from any browser — laptop, tablet, or phone — and get a complete management dashboard tailored to their role. No app installation needed. Everything updates in real time.

📢
Content Management
Create, edit, pin, and schedule announcements, news articles, and events — all with bilingual Arabic and English fields. Set a future publish date and it goes live automatically.
📅
Appointment Management
Configure which days and times are open for bookings, set maximum bookings per slot, and view the full citizen booking list. Confirm, cancel, or add admin notes to any appointment.
💬
Complaints & Suggestions
View all incoming citizen complaints and recommendations. Update status (new → in review → resolved → dismissed), write an internal admin response, and filter by type and status.
📋
Document Requests
Track every citizen document request through its full lifecycle: pending → processing → ready → delivered. Upload the completed document directly, and a push notification is sent to the citizen automatically.
👥
Staff & Permissions
Add staff members with three role tiers: owner, admin, and moderator. Moderators get only the specific permissions you assign — manage_announcements, manage_complaints, manage_events, and so on. Change permissions any time, they take effect on the next request.
🔔
Push Notifications
Send bilingual push notifications to all opted-in citizens for any module. Notifications are batched in groups of 100 and delivered via Expo's push service. A full send log is kept per municipality.
🗺️
Places & Events
Add local places (cultural, religious, entertainment, activity) and upcoming events with photos, coordinates, descriptions in both languages, entry fees, and dates. Citizens see them instantly on Discover.
⚙️
Module Toggle
Enable or disable any of the 16 modules for your municipality with a single toggle. If a module is off, it disappears from the citizen app entirely — no dead buttons, no "coming soon" confusion.
🎨
Municipality Branding
Upload a logo, cover photo, and set primary and secondary colors. Every citizen's app updates to show the correct branding automatically — no app update or rebuild required.
Access from anywhere

Browser-based.
No installation.

Staff access the admin dashboard from any modern browser — Chrome, Safari, Firefox. Works on a laptop in the office, a tablet in the field, or a phone on the go. Nothing to install, nothing to update.

💻 Desktop 📱 Mobile browser 🖥 Tablet No app needed
🔐
JWT verified on every page load
Sessions expire automatically. Revoked access takes effect within seconds.
👁️
Role-scoped views
Moderators only see what they're permitted to manage. No accidental exposure of other sections.
Changes are live instantly
Publish an announcement and citizens see it in seconds via Supabase Realtime.
🏗️
Super Admin · Operator Portal
Spin up a new municipality in under 5 minutes.
A separate operator portal lets you provision new municipalities through a guided 5-step wizard: enter municipality details and branding, choose which modules to enable, set the subscription plan and dates, and create the owner account. The municipality goes live immediately — the owner receives a setup link and the citizen app is ready to deploy.
Built with Expo SDK 51 React Native TypeScript Zustand NativeWind i18next · EN/AR Supabase PostgreSQL Deno Edge Functions Next.js 14 Expo Push SecureStore

"Lebanese municipalities have been underserved by technology for decades. Baladiytak changes that — one village at a time."

— Design philosophy, Baladiytak v1.0

16
Feature modules, all toggleable
Every municipality activates only what it needs. No unused features, no confusing UI.
24
Database tables, all RLS-protected
Not a single table without row-level security. No data leaks between municipalities.
2
Languages, full RTL support
Every layout, string, date format, and chevron direction is independently localized.
3
Applications in one codebase
iOS/Android app · Municipality admin · Super admin — monorepo, shared types.

Ready to bring your municipality
into the 21st century?

Baladiytak can be deployed for any Lebanese municipality. Get in touch to discuss onboarding your community.

Get in touch View screenshots →