Ecbatana: From One Wedding Website to a Small Multi-Tenant Platform
Ecbatana started as a simple idea: build a wedding website.
At first, the requirement sounded small. I needed a landing page, a nice invitation experience, some information about the wedding, and a way for guests to confirm if they were coming.
But once I started building it, the project became more interesting.
A real wedding website is not only a static page. Guests need to log in, RSVP, choose a menu, request songs, view private photos, and sometimes ask for access if they do not have an invitation yet. The couple also needs each of those actions to be stored correctly, separated from other weddings, and easy to manage.
That is how Ecbatana slowly moved from being a single wedding website into a small platform that can host many weddings from the same application.
You can see the demo here:
The original version
The first version of Ecbatana was built as a custom website for one wedding.
I did not start by trying to build a big platform. I started by building the experience: the invitation, the landing page, the guest flow, and the basic forms. At that stage, a lot of the content was hardcoded because that was the fastest way to get something real working.
The first deployment was on Vercel, with Neon as the Postgres database. That setup was very useful at the beginning because it let me move quickly. I could deploy the app, test the flow, and focus on the product instead of fighting infrastructure too early.
But as the project started to look less like a one-off website and more like something that could become a real product, I decided to move it to Cloudflare.
Moving to Cloudflare and keeping costs low
Moving to Cloudflare was both a product decision and an infrastructure decision.
Part of the reason was Vercel's free Hobby plan. I did not want to build a potentially commercial project around a plan that I understood as being limited to non-commercial use. I could have moved to a paid Vercel plan, but I also saw the migration as a good opportunity to keep the project cheap in its early stage.
Cloudflare made sense for that.
Its free tiers are generous enough for the current scale of the app, and the wedding website traffic pattern is not constant. Guests usually open the link from WhatsApp, read the invitation, RSVP, choose a menu, request a song, and maybe view or upload photos. There may be short peaks of activity, but not continuous heavy traffic.
So the goal was not to build a large infrastructure. It was to build something practical that could run close to free while the product is still early.
The database stayed on Neon, but the app moved to Cloudflare Pages and Workers. That move also brought other Cloudflare services into the architecture: R2 for private photos, Cloudflare Images for optimization, and Workers for the application runtime.
The migration was not only a hosting change, though. The runtime changed, and that forced other technical decisions.
The original version used Prisma, but Prisma's default query engine was not a good fit for Cloudflare Workers. So I moved the database layer to Drizzle ORM, which gave me a lighter setup for the Workers environment.
Email also had to change. Workers do not support raw TCP sockets, so using a normal SMTP flow was not the right path. I moved transactional email to an HTTP-based provider instead. Today, OTP login links, RSVP confirmations, and access-request emails go through Resend, behind a small provider interface. That means changing providers later should only require changing one small part of the code.
Private photos also had to be handled differently. A guest gallery can become large quickly, and a Worker is not the place to buffer large files in memory. So private wedding photos live in Cloudflare R2 and are streamed through authenticated routes.
In the end, moving to Cloudflare helped with both goals: avoiding the non-commercial restriction of the original hosting setup, and keeping the platform inexpensive enough to run during its first stage.
Building the guest flows
Once the base app was working, the next part was turning the wedding website into a real guest experience.
Guests can log in with an email OTP, confirm attendance, choose menu options, request access, and interact with wedding-specific pages. Some pages can be public, while others stay behind authentication.
That was one of the reasons multi-tenancy became important.
Each wedding needs its own rules. A demo wedding may expose the invitation and photos publicly, while a real wedding may keep most of the guest area private. Some weddings may need menu selection. Others may only need RSVPs. Some may want a song board. Others may not.
To support that, every route moved under a wedding slug, like:
/:wedding/*
That slug resolves which wedding the guest is visiting, what content should be shown, and which pages should be accessible before or after login.
For the forms, I used react-hook-form and Zod. The idea was to keep the experience simple for guests, but still validate the data properly on both the client and the server. RSVP and menu data should be easy for a guest to submit, but predictable for the backend to store.
The frontend is also part of the tenant
One of the most interesting challenges is that, in this product, multi-tenancy is not only a backend problem.
In many applications, the frontend is mostly static, and the backend is what makes it dynamic. The frontend renders the same structure, and the backend decides what data appears inside it.
That is true for part of Ecbatana too. Wedding information, menu options, RSVPs, song requests, guests, sessions, and access rules can all come from the backend.
But a wedding page is different from a normal dashboard or internal tool.
The page is public. It represents the couple. It is not just showing data. It is telling a story.
That means the frontend has a much bigger role.
The colors, assets, layout, animations, sections, tone, and structure should feel connected to the couple. It is not enough to only change the names, date, and menu. If a couple met in a park, maybe the story section should have a park-inspired visual detail. If another couple wants something more elegant and formal, the whole page may need a different feeling. One couple may want a presentation-like invitation. Another may want a more traditional hero page. Another may want a custom animation.
This is where the problem becomes harder.
It is possible to put if statements everywhere across the frontend, but that does not feel like the right long-term solution. It is also possible to offer a few fixed templates, but templates alone are not enough for the kind of experience I want Ecbatana to support.
Templates can be a starting point. They can give examples, structure, and inspiration. But the final website should still feel like it belongs to that couple.
That is still an open engineering and product challenge in the project.
The backend can support multiple weddings, but the frontend also needs a model for customization that does not become impossible to maintain. Each wedding may need its own assets, content, sections, animations, and visual decisions, while still sharing enough structure to avoid rebuilding everything from scratch.
For me, this is probably one of the most important parts of the product. The challenge is not only to host many weddings. The challenge is to make each wedding feel personal.
Turning one wedding into many
Ecbatana did not start as a multi-tenant platform.
The first version was a single-couple prototype. The content was mostly hardcoded, and the app was built around one wedding. That was fine for the first version, but it was not enough once I wanted the project to support more couples.
The backend had to change first.
Tables like users, RSVPs, menu selections, song requests, sessions, and access requests needed to belong to a specific wedding. So they gained a weddingId, and the app started treating each wedding as its own tenant.
The frontend also changed. Instead of assuming one global wedding, the app now resolves the wedding from the URL slug and loads the correct configuration for that couple.
One decision I made on purpose was not to move all per-couple content into the database immediately.
It would be cleaner eventually, but doing that during the same migration would have meant rewriting too much at once. There were roughly two dozen files with references to wedding-specific content, and I did not want the multi-tenant migration to become a full CMS rewrite at the same time.
So I kept some content in static configuration files and treated the database migration as a separate step.
That was a useful lesson: sometimes the better engineering decision is not to make everything perfect in one pass. It is to make the next step possible without breaking the whole project.
The song-request board
One feature I wanted was a song-request board.
The idea was simple: guests should be able to search for songs they want to hear at the reception and add them to a shared list.
The challenge was doing that for free.
A paid music API would probably give better search, better metadata, and more predictable results. But for this project, that felt unnecessary. I did not need to build Spotify. I needed guests to find most songs they would probably search for at a wedding.
So I used two free sources: MusicBrainz and the iTunes Search API.
MusicBrainz gives open music metadata. The iTunes API gives better coverage for popular songs and often includes artwork. The app searches both, merges the results, and deduplicates them by normalized title and artist so the same song does not appear twice.
There were still some details to handle.
MusicBrainz has a strict rate limit, so calls to it go through a small in-memory queue that guarantees at least one second between requests. I also added short-lived caching in front of both providers, so repeated searches do not hit the APIs on every keystroke.
The result is not perfect, but it works well for the real use case. With those two free APIs, Ecbatana can find most of the songs guests are likely to request, while keeping the feature free to operate.
Learning to build a better frontend
One of the biggest challenges in Ecbatana was the frontend.
I consider myself a full-stack engineer, but with more focus on backend. So building something that felt polished, emotional, and visually intentional was not the most natural part of the project for me.
The envelope animation was the first big example.
The inspiration originally came from a Pinterest post that one of the couples shared with me. It showed a wedding invitation idea made with Canva and Canva assets. The goal was to have something with that same feeling: an envelope, a card, and visual elements that interacted with the page instead of looking like a normal static website.
At the beginning, I thought I could just ask Claude Code to build the envelope. Since Claude Code could not generate images at the time, it created an SVG envelope. The animation was decent, and it was useful as a first version, but visually it still felt like a placeholder.
Later, while trying to improve the landing page, I started using ChatGPT's image generation to create visual assets.
At first, I asked for a full background image. The concept looked good, but once I used it on the page, it felt low quality. It was too soft, especially when stretched across the layout.
That was when I understood something important: the page should not depend on one big generated image.
The better approach was to create separate assets.
So I started generating the envelope, botanical ink details, small decorative accents, bouquet elements, and the wax seal as individual images. Then I placed them across the page with CSS instead of using them as one fixed background.
That made the design much better.
The assets looked sharper, the page became more flexible, and the frontend started to feel like an actual composition instead of a pasted image. It also got closer to the original inspiration: assets that felt part of the invitation, not just decoration around it.
There was still manual work involved. Some generated assets had cream backgrounds that needed to be removed. Others needed to be resized, cleaned up, positioned, or combined. The wax seal, for example, needed careful compositing so it felt attached to the envelope instead of floating above it. In the end, I opted to regenerate the envelope with the new seal so it looked fully integrated.
I also learned some limitations of the image-generation workflow.
After several rounds of edits, ChatGPT often became worse at making precise changes to the same image. Sometimes it was easier to branch the chat or start a new one than to keep asking for small corrections. The "edit image with comments" flow also did not work very well for this kind of precise asset work.
Still, the process changed how I thought about frontend design. I stopped treating AI-generated images as final designs and started treating them as raw assets that still needed to be integrated into the product.
The envelope animations
There are two envelope moments in Ecbatana.
The first one is the public invitation.
The guest taps the envelope, the flap opens, and the invitation card appears. The first version of this idea was an SVG envelope. It was useful because it proved the concept: the interaction made sense, the animation worked, and it gave the invitation a more special feeling than a normal landing page.
But the SVG was only the first concept.
The final direction came later, after I started working with generated visual assets. Instead of keeping the envelope as a simple SVG shape, I generated an actual envelope asset that matched the rest of the visual language of the page. That made the interaction feel much closer to a real invitation: the envelope, the seal, the card, and the decorative elements all felt like they belonged to the same world.
That also changed how I thought about the animation. It was not just about opening an envelope. It was about making the first moment of the website feel like receiving and opening a real wedding invitation.
The flow still needed more work after the asset was created. At one point, the opening animation and the rest of the page felt disconnected, almost like two different experiences placed one after the other. The fix was to make the invitation card sticky and docked to one side while the rest of the content scrolls past it. Combined with scroll snapping, the page started to feel more like one continuous wedding invitation instead of an animation followed by a normal website.
There was also a small but annoying bug in that flow.
When the envelope was closed, I locked the scroll behind it. But when the envelope opened, the scrollbar appeared again and caused a visible layout jump. The fix was to reserve the scrollbar gutter space from the beginning.
The second envelope is used in the request-access flow.
That one is a CSS 3D animation. As the guest scrolls, the card rotates on its Y and Z axes, and the request form fades in on the back face. The animation is driven by scroll progress, not by a fixed duration, so the flip always matches how far down the page the guest has scrolled.
This part is still evolving, but it was one of the most interesting frontend pieces of the project.
Polishing for mobile
Another challenge came from testing the site on a real phone.
I was developing most of Ecbatana on my computer, but most guests will probably open the link from WhatsApp. That means the mobile experience is not secondary. It is probably the main experience.
Once I started opening the site on my phone, I noticed many things that were not obvious on desktop.
The spacing needed more work. Some decorative assets looked good on a large screen but felt distracting on a small one. Text needed to be bigger or more visible. Landscape mode created its own problems, especially when background assets and text competed for space.
One change that helped was scroll snapping.
It gave the page a presentation-like feeling. Each section lands more intentionally as the guest scrolls, both on desktop and on mobile. That made the invitation feel less like a long document and more like a sequence of moments.
The background assets were harder.
Because I was using separate generated assets instead of one big image, each piece had to be positioned carefully across screen sizes. Portrait and landscape needed different thinking. What looked balanced in one layout could feel completely wrong in another.
This is still a work in progress, but it was a good reminder that "responsive" is not enough. The site has to work in the way people will actually use it.
For Ecbatana, that means phones, WhatsApp links, portrait mode, quick scrolling, and guests who are not there to admire the engineering. They just want the invitation to feel clear and beautiful.
Private photo galleries
The private photo gallery was another feature that shaped the infrastructure.
Photos are large, and wedding galleries can grow quickly. I did not want to store photo files in Postgres, and I did not want a Worker buffering large media files in memory.
So the files live in Cloudflare R2.
The app serves them through authenticated routes, so guests can only access the photos for the wedding they belong to. The important part is that the files are streamed instead of loaded fully into the Worker.
This was one of those features where the implementation detail matters. It is not enough for private photos to "work." They need to work in a way that does not break the runtime when the gallery grows.
The stack
The current stack is:
- Next.js 16 with the App Router
- React 19
- TypeScript
- Tailwind CSS 4
- Drizzle ORM
- Postgres on Neon
- Cloudflare Workers / Pages through OpenNext
- Cloudflare R2 for private files
- Cloudflare Images for image optimization
- Resend for transactional email
- Zod and
react-hook-formfor forms
I did not choose the stack just to try new tools. Most of it came from the constraints of the project.
I wanted a modern full-stack React app, but I also wanted it to run on Cloudflare. I wanted Postgres, but without managing a server. I wanted private media storage, but without paying for infrastructure too early. I wanted email, but through an HTTP-based provider that worked well in Workers.
The stack is not perfect, but it fits the current stage of the product.
Running into Cloudflare limits
One of the most useful moments was hitting a real production limit.
At one point, a few rapid reloads of the guest homepage triggered Cloudflare's "exceeded resource limits" error.
That was surprising because the app was not under heavy traffic. It was just a small number of requests. So the problem was not scale in the usual sense. The problem was that the request path was doing too much.
There were two main issues.
The first was that the full per-wedding content was being rendered fresh on every request instead of being kept away from the hot path. The second was a database transaction pattern that could hold a Neon connection open longer than necessary.
I seriously considered moving the whole app back to Vercel. Vercel has a more forgiving runtime for this kind of thing, and it probably would have made the error disappear.
But the better fix was to clean up the architecture.
I moved heavy media handling to R2 streaming, kept expensive content away from authentication-sensitive routes, and made sure transactions opened a dedicated connection per request instead of holding connections in a risky way.
After that, Cloudflare's limits stopped being a major concern.
That was a useful lesson. Sometimes a bigger runtime hides the problem. A smaller runtime forces you to fix it.
Using AI tools beyond code generation
Another important learning was how useful AI-assisted development becomes when the agent can use the same tools as the developer.
This project was not only about asking Claude Code or Codex to write components.
I gave the tools access to the real workflow: Vercel CLI, Cloudflare's Wrangler CLI, Netlify CLI, and Neon CLI. I also tried Netlify during the migration process, so having the CLI available made it easier to compare deployment targets.
That changed what the agent could do.
It could inspect the project, run builds, read errors, update configuration, test migrations, deploy previews, check logs, and iterate from the result.
That is very different from an agent that only edits files.
The biggest learning for me was that AI tools become much more useful when they are connected to the feedback loop of the project. If the agent can run the app, deploy it, see what failed, and adjust based on real output, it becomes much closer to an engineering assistant.
Of course, it still needs supervision. I still needed to make the architecture decisions, protect secrets, review changes, and decide what was safe to deploy.
But for setup-heavy work like moving from Vercel to Cloudflare, testing Neon connections, debugging environment variables, and working through deployment errors, the combination of CLI access and AI assistance made the process much faster.
What is still in progress
Ecbatana is still evolving.
Some parts are already working well: the multi-tenant structure, guest login, RSVP flow, menu selection, song search, private galleries, and the main invitation experience.
Other parts still need polishing.
The mobile layout can be improved more, especially in landscape mode. The visual assets could be organized better. Some per-couple content should eventually move from static files into a database or admin interface. The envelope animations can still be refined. The song search could be improved later with a paid provider if the product needs better coverage.
But the biggest open challenge is the couple-specific frontend experience.
The backend can already separate weddings, guests, RSVPs, songs, menus, and sessions. The harder question is how to let each couple have a site that feels truly theirs without turning every new wedding into a completely custom frontend project.
That balance is still something I am working through.
Final thoughts
The most interesting part of Ecbatana was not one specific feature.
It was the process of turning something very personal into something reusable without making it feel generic.
A wedding website has to feel simple and emotional for guests. But behind that, there are real engineering problems: authentication, multi-tenancy, private media, form validation, database design, email delivery, external APIs, caching, mobile UX, animations, generated assets, and deployment limits.
For me, Ecbatana was a project that challenged me in a good way.
It pushed me outside of the areas where I usually feel more comfortable, especially backend and infrastructure, and forced me to care much more about frontend, design, animation, mobile UX, and visual details.
But more than that, it reminded me that meaningful software is not only about solving a technical problem. In this case, the goal is to build something that feels personal for each couple and helps them share one of the most important moments of their lives.