بلديتك في جيبك
Baladiytak connects Lebanese citizens with their municipality — announcements, services, appointments, and civic life, bilingual, offline-ready, and built from scratch.
The app, in detail
Screen 01
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.
Screen 02
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.
Screen 03
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.
Screen 04
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.
Screen 05
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.
Each module can be independently enabled or disabled per municipality. No bloat, no unused features. Citizens only see what their community has activated.
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.
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.
// 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.
-- 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.
'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.
// 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 decisions were made at the schema level, not patched in at the application level. Here's what that looks like in practice.
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.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.
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.
"Lebanese municipalities have been underserved by technology for decades. Baladiytak changes that — one village at a time."
— Design philosophy, Baladiytak v1.0
Baladiytak can be deployed for any Lebanese municipality. Get in touch to discuss onboarding your community.