I'm For Hire

June 11, 2026

I Built a SaaS for Managing Torah Readings

I Built a SaaS for Managing Torah Readings

Most of my professional work lives in the WordPress ecosystem. That’s what pays the bills, and I’ve been successful at it. But every once in a while I take on a project that has nothing to do with WordPress, and those projects are usually how I stay sharp on the parts of the stack that don’t get much exercise otherwise.

This one came from a real problem. Synagogue administrators, the people responsible for coordinating who reads Torah on any given Shabbat, are largely doing it manually. Spreadsheets, emails, phone calls, and a prayer that someone doesn’t forget. I wanted to build something that handled the whole workflow automatically, and I wanted to build it properly.

The result is TorahScheduler: a multi-tenant SaaS where each synagogue gets its own subdomain, its own admin team, and automated email and SMS reminders for their readers.

The Stack

Next.js 15 App Router with React 19, Supabase for the database and auth, Resend for email, Telnyx for SMS, and Vercel for deployment including cron jobs. Tailwind CSS v4 (the new CSS-first config with @theme, no config file). TypeScript throughout with a hard no-any rule.

Nothing exotic. But how it all fits together is where it gets interesting.

Multi-Tenancy via Subdomains

Each congregation gets its own subdomain: {slug}.torahscheduler.com. The Next.js middleware (src/proxy.ts, which is what middleware files are called in Next 16) intercepts every request, pulls the slug from the host, and writes it into an x-synagogue-slug HTTP header. Every Server Component and Server Action downstream reads that header to know which tenant they’re serving.

Tenant isolation is enforced at three layers. First, that middleware header. Second, Row-Level Security in Supabase: every table that holds tenant data has RLS policies backed by a SECURITY DEFINER function called get_my_synagogue_id(), which looks up the current user’s synagogue from the admin_users table. Third, the admin layout does an explicit check to confirm the authenticated user has a row in admin_users for this specific synagogue, not just any synagogue in the system.

The practical result is that SELECT queries don’t need a WHERE synagogue_id = X clause anywhere in application code. The database just won’t return rows that don’t belong to you. That removes an entire category of potential data-leak bugs, which I appreciated more the further into the project I got.

The Hebrew Calendar Problem

The Jewish calendar is lunisolar: months track the moon, years track the sun, with periodic leap months to keep the holidays in the right seasons. Implementing that from scratch would be a real undertaking.

Instead I used @hebcal/core, which handles the full calculation: parasha assignment per week, holiday detection, the diaspora vs. Israel schedule difference (which shifts certain readings), and special Shabbatot.

The 54 canonical Torah portions live in a parashot table that is seeded once and never touched by application code. All per-congregation customizations (custom transliteration, haftarah overrides, Brit Hadashah selections) live in a separate synagogue_parasha_settings table. When an admin clicks “Generate Services,” it scaffolds every Shabbat and holiday for the year, pulls any overrides in a single bulk query, and creates all the assignment rows with those overrides baked in.

Keeping the global data immutable and the tenant data in a separate table was one of the better architectural calls I made. It means a global data fix never touches tenant rows, and tenant customizations never corrupt the base calendar.

Notifications

Two automated cron jobs run on Vercel.

The Thursday cron goes out at 9am UTC and sends reminder emails to everyone assigned to the upcoming Shabbat. Template resolution works in layers: per-slot template in the email_templates table, then the synagogue default, then a hardcoded fallback. If one synagogue fails, the run keeps going; errors are caught per-tenant and included in the JSON response.

The Sunday cron is more flexible. Each synagogue sets a reminder_days array, any set of integers like [7, 14, 28]. Every Sunday the cron computes which service_date falls exactly that many days out, checks an email_logs table for deduplication (so retries don’t double-send), renders the template with merge fields, and logs the result. Merge fields use {{token}} syntax: {{reader_name}}, {{parasha_name}}, {{verse_range}}, and so on. Unknown tokens stay verbatim in the output rather than silently disappearing, which makes misconfigured templates immediately obvious.

SMS is a one-tap send from the service detail page via the Telnyx REST API. I didn’t use their SDK, just a plain fetch call in src/lib/telnyx.ts. Phone numbers are stored in E.164 format and normalized on both input and send. The button only renders when Telnyx credentials are configured for the synagogue and the reader has a phone number on file.

A Few Things I’d Highlight

The Server Components default is worth mentioning. "use client" only goes on components that genuinely need browser APIs or event handlers. Everything else is a Server Component. The client bundle stays small and the default rendering path is fast.

The Supabase client split is something I got right from the start and was glad I did: src/lib/supabase/client.ts for browser use and src/lib/supabase/server.ts for everything server-side. They use different auth strategies and should never be mixed. Having that line drawn early saved me from a category of auth bugs that comes from blurring those contexts.

The cron jobs use the service-role Supabase client because they need to read across all tenants to send their notifications, and that’s the one case where RLS gets bypassed intentionally. They’re secured by a CRON_SECRET checked in the Authorization: Bearer header.

Where It Stands

The core product works: multi-tenant auth, Hebrew calendar generation, assignment management, automated email and SMS reminders, a WYSIWYG email template editor, 7-tab settings page, and a 7-step onboarding wizard that handles both email/password and Google OAuth sign-up.

The roadmap has deeper export functionality, a reader-facing portal where readers can confirm or decline assignments, and Stripe billing. But honestly, getting this far was the point. It’s a real full-stack app with real complexity, and it was a good reminder that there’s a lot of fun stuff outside the WordPress world.

Subscribe To My YouTube Channel