How we built ShulMaker
By Eliran Yihye, Founder and Lead Engineer at Cuanto Labs ·
ShulMaker gives a congregation a fast synagogue website with Shabbat times, a Jewish calendar, events and donations, and lets it sign up and launch without talking to anyone. Under the hood it is one multi-tenant Payload CMS and Next.js application on Postgres, a weekly job that keeps every congregation calendar current, and a signup path that turns a Stripe checkout into a working site. This post walks through how those pieces fit, which files own them, and three trade-offs we made on purpose. Facts here are read from the repository as of October 2026.
The problem
A synagogue website has an unusual job. Most visitors arrive on a phone, usually on a Friday, and they want one thing: the candle lighting time for their own town. That number changes every week and depends on the exact location of the congregation, so a hand typed page goes stale the first time nobody has the time to update it.
The second problem is who runs the site. A shul rarely has a developer or a web committee with spare hours. If a site needs a technician to launch, edit or renew, it quietly stops being maintained. So the product has two jobs: keep the times correct without any human action, and make launching a site something a gabbai can do alone in a few minutes.
Architecture
ShulMaker is one Next.js 16 app on Payload CMS 4 with the multi-tenant plugin, backed by Postgres. Every congregation is a row in the tenants collection with its own domain. A request first goes through src/proxy.ts, which resolves the host to a tenant using a mapping held in Vercel Edge Config, then rewrites the request to an internal /t/[tenantId]/ route. Direct requests to the /t/ prefix are blocked, so the internal route is not part of the public surface.
The tenant pages declare force-static with revalidate = false, which means a rendered page is cached indefinitely and only changes when the code asks for it. That is what makes the sites fast, and it is also what makes freshness a design problem: if nothing ever expires the cache, new Shabbat times never appear. The diagram shows how the weekly job solves that.
The calendar pipeline
Calendar data lives in the calendar-data collection, defined in src/collections/CalendarData.ts. Each synagogue has a configured geonameid, the numeric place identifier that selects the location for candle lighting. The service in src/utilities/services/zmanimService.ts calls the Zmanym times service with that id and a start and end date, and CALENDAR_FETCH_MONTHS is set to 12, so each fetch covers a rolling year from today.
The response is stored as one row per tenant, not one row per month. saveCalendarDataByDateRange finds the tenant newest row, updates it in place, and marks any older duplicates inactive. Along with the raw response it saves two derived shapes: the organized Shabbat events, and a viewData object keyed by YYYY-MM-DD, so the calendar page can look up a day without scanning a list. Events a congregation added by hand are merged back into viewData on every refresh, so a refetch never erases their own entries.
A weekly Vercel cron declared in vercel.json calls /api/cron/shabbat-revalidate. The route requires a bearer secret, lists every active tenant whose business type is synagogue, and processes them in batches of 10 in parallel instead of one at a time. When a batch finishes it calls revalidateTag('calendar-data') to drop the shared cached blob, and revalidatePath for each successful tenant home page, so the next visitor gets a freshly rendered page with the new times.
Self-serve signup
The signup flow starts at /get-started. Pricing lives in one file, src/components/marketing/plans.ts: Starter at $129 a month and Community at $269 a month, with yearly prices of $1,290 and $2,690, and a 14 day trial. The checkout route in src/app/api/stripe/public-checkout/route.ts creates a Stripe Checkout session with noCardTrial: true, so a congregation can try the product without entering a card, and it sits behind a per-IP rate limiter to blunt subdomain probing and session spam.
The interesting work happens in the webhook. On checkout.session.completed, the shared Stripe handler calls provisionSignup in src/utilities/billing/onSignup.ts. It validates the shul name, subdomain and email, creates the tenant with a starter site and an admin user holding a random throwaway password, attaches the Stripe customer, registers the subdomain on the hosting project, and sends a welcome email with a set-password link. The admin never sees a temporary password, only a link to choose their own.
Trade-offs we made
Three decisions shaped the code, and each one costs something.
- One rolling row per tenant. Storing the whole year as a single row with JSON columns keeps reads to one lookup and stops the table growing every week. The cost is that individual days are not queryable as rows, and an update rewrites the whole blob. For a calendar that is read far more often than it is written, we chose the cheap read. The reasoning is in the doc comment above
saveCalendarDataByDateRange. - Weekly refresh with on-demand revalidation. We could have fetched times during each request, or expired pages on a timer. Instead, pages stay cached until the job explicitly invalidates them. Visitors never wait on an upstream service, but a correction made upstream mid-week only shows up after the next run or a manual fetch from the admin button.
- Write the Stripe customer id last. In
onSignup.tsthestripeIDis saved only after the tenant, site and admin exist. Its presence marks the signup as complete, so a delivery that fails halfway is resumed on the next attempt instead of being skipped, and a subdomain already owned by a different customer is a hard error. Attaching the domain to the hosting project and sending the welcome email are deliberately non-fatal: if either fails, ops gets an alert and the tenant still exists.
What is next
There is no published roadmap, so this section sticks to what the code shows today. The fetch in zmanimService.ts makes a single attempt and returns null on a failure, which means a congregation whose upstream call fails keeps its previous rolling row until the next weekly run. The rolling window is long enough that stale data stays correct for months, but a retry with backoff is the obvious next hardening step. When a signup provisioning attempt fails, the hook alerts ops and rethrows, and the fix is to re-send the event from the Stripe dashboard because the hook is idempotent.
Frequently asked questions
- How does ShulMaker keep Shabbat times accurate?
- Each synagogue has a configured location. A weekly job fetches a rolling 12 month window of times for that location, stores it in the calendar-data collection, and then revalidates the cached pages so the new times appear without a redeploy.
- What stack does ShulMaker run on?
- One multi-tenant Next.js 16 application on Payload CMS 4 with Postgres. Tenant domains are resolved by a proxy, and tenant pages are statically cached and revalidated on demand.
- How does signup create a website automatically?
- A Stripe checkout with a 14 day trial and no card up front triggers a webhook. An idempotent job creates the tenant, a starter site and an admin account, registers the subdomain, and emails a set-password link.
- Who builds and runs ShulMaker?
- ShulMaker is built and run by Cuanto Labs, a software studio in Fort Lauderdale, Florida. Eliran Yihye is the founder and lead engineer.