Article

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:

ecbatana.belen.systems/sarahandtobias

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-form for 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.

Artículo

Ecbatana: de un sitio web de boda a una pequeña plataforma multi-tenant

Ecbatana empezó como una idea simple: construir un sitio web para una boda.

Al principio, el requerimiento parecía chico. Necesitaba una landing, una experiencia linda de invitación, información sobre la boda y una forma para que los invitados confirmaran si iban a asistir.

Pero cuando empecé a construirlo, el proyecto se volvió más interesante.

Un sitio web de boda real no es solamente una página estática. Los invitados necesitan iniciar sesión, confirmar asistencia, elegir menú, pedir canciones, ver fotos privadas y, en algunos casos, solicitar acceso si todavía no tienen invitación. La pareja también necesita que cada una de esas acciones se guarde correctamente, separada de otras bodas y de una forma fácil de gestionar.

Así fue como Ecbatana pasó poco a poco de ser un sitio web para una sola boda a convertirse en una pequeña plataforma capaz de alojar muchas bodas desde una misma aplicación.

Se puede ver una demo acá:

ecbatana.belen.systems/sarahandtobias

La primera versión

La primera versión de Ecbatana fue construida como un sitio personalizado para una boda.

No empecé intentando construir una gran plataforma. Empecé construyendo la experiencia: la invitación, la landing, el flujo de invitados y los formularios básicos. En esa etapa, gran parte del contenido estaba hardcodeado porque era la forma más rápida de tener algo real funcionando.

El primer deployment fue en Vercel, con Neon como base de datos Postgres. Esa configuración fue muy útil al principio porque me permitió avanzar rápido. Podía desplegar la app, probar el flujo y enfocarme en el producto sin pelearme demasiado pronto con la infraestructura.

Pero a medida que el proyecto empezó a parecerse menos a un sitio puntual y más a algo que podía convertirse en un producto real, decidí moverlo a Cloudflare.

Moverlo a Cloudflare y mantener bajos los costos

Mover Ecbatana a Cloudflare fue una decisión de producto y también de infraestructura.

Parte de la razón fue el plan gratuito Hobby de Vercel. No quería construir un proyecto potencialmente comercial sobre un plan que entendía como limitado a uso no comercial. Podría haber pasado a un plan pago de Vercel, pero también vi la migración como una buena oportunidad para mantener el proyecto barato en su etapa inicial.

Cloudflare tenía sentido para eso.

Sus tiers gratuitos son bastante generosos para la escala actual de la app, y el patrón de tráfico de un sitio de boda no es constante. Los invitados suelen abrir el link desde WhatsApp, leer la invitación, confirmar asistencia, elegir menú, pedir una canción y quizás ver o subir fotos. Puede haber picos cortos de actividad, pero no tráfico pesado de forma continua.

Entonces el objetivo no era construir una infraestructura enorme. Era construir algo práctico que pudiera correr casi gratis mientras el producto todavía está en una etapa temprana.

La base de datos siguió en Neon, pero la app se movió a Cloudflare Pages y Workers. Ese cambio también incorporó otros servicios de Cloudflare a la arquitectura: R2 para fotos privadas, Cloudflare Images para optimización de imágenes y Workers como runtime de la aplicación.

La migración no fue solamente un cambio de hosting. Cambió el runtime, y eso obligó a tomar otras decisiones técnicas.

La versión original usaba Prisma, pero el query engine por defecto de Prisma no era una buena opción para Cloudflare Workers. Entonces migré la capa de base de datos a Drizzle ORM, que me dio una configuración más liviana y más compatible con el entorno de Workers.

El email también tuvo que cambiar. Workers no soporta sockets TCP crudos, así que usar un flujo normal por SMTP no era el camino correcto. Moví los emails transaccionales a un proveedor basado en HTTP. Hoy, los links OTP de login, las confirmaciones de RSVP y los emails de solicitud de acceso se envían a través de Resend, detrás de una pequeña interfaz de proveedor. Eso significa que cambiar de proveedor más adelante debería requerir modificar solo una parte chica del código.

Las fotos privadas también tuvieron que manejarse de otra forma. Una galería de boda puede crecer rápido, y un Worker no es el lugar adecuado para cargar archivos grandes en memoria. Por eso, las fotos privadas viven en Cloudflare R2 y se sirven a través de rutas autenticadas.

Al final, moverlo a Cloudflare ayudó con los dos objetivos: evitar la restricción no comercial del hosting original y mantener la plataforma lo suficientemente barata para correr durante su primera etapa.

Construir los flujos de invitados

Una vez que la base de la app estaba funcionando, el siguiente paso fue convertir el sitio de boda en una experiencia real para invitados.

Los invitados pueden iniciar sesión con un OTP por email, confirmar asistencia, elegir opciones de menú, solicitar acceso e interactuar con páginas específicas de la boda. Algunas páginas pueden ser públicas, mientras que otras quedan detrás de autenticación.

Esa fue una de las razones por las que el multi-tenancy se volvió importante.

Cada boda necesita sus propias reglas. Una demo puede mostrar públicamente la invitación y las fotos, mientras que una boda real puede mantener casi toda el área de invitados privada. Algunas bodas pueden necesitar selección de menú. Otras quizás solo necesiten RSVP. Algunas pueden querer un tablero de canciones. Otras no.

Para soportar eso, todas las rutas se movieron bajo un slug de boda, como:

/:wedding/*

Ese slug resuelve qué boda está visitando el invitado, qué contenido debe mostrarse y qué páginas son accesibles antes o después del login.

Para los formularios usé react-hook-form y Zod. La idea era mantener una experiencia simple para los invitados, pero validar correctamente los datos tanto en el cliente como en el servidor. La información de RSVP y menú tiene que ser fácil de enviar para un invitado, pero predecible para guardar en el backend.

El frontend también es parte del tenant

Uno de los desafíos más interesantes es que, en este producto, el multi-tenancy no es solo un problema de backend.

En muchas aplicaciones, el frontend es mayormente estático, y el backend es lo que lo vuelve dinámico. El frontend renderiza la misma estructura, y el backend decide qué datos aparecen adentro.

Eso también es cierto para una parte de Ecbatana. La información de la boda, las opciones de menú, los RSVPs, las canciones, los invitados, las sesiones y las reglas de acceso pueden venir del backend.

Pero un sitio de boda es distinto a un dashboard o a una herramienta interna.

La página es pública. Representa a la pareja. No solo muestra datos. Cuenta una historia.

Eso hace que el frontend tenga un rol mucho más importante.

Los colores, assets, layout, animaciones, secciones, tono y estructura deberían sentirse conectados con la pareja. No alcanza con cambiar los nombres, la fecha y el menú. Si una pareja se conoció en un parque, quizás la sección de historia debería tener un detalle visual inspirado en ese lugar. Si otra pareja quiere algo más elegante y formal, toda la página puede necesitar una sensación distinta. Una pareja puede querer una invitación tipo presentación. Otra puede preferir una hero page más tradicional. Otra puede querer una animación personalizada.

Ahí es donde el problema se vuelve más difícil.

Es posible llenar el frontend de if statements, pero no parece la solución correcta a largo plazo. También es posible ofrecer algunas plantillas fijas, pero las plantillas por sí solas no alcanzan para el tipo de experiencia que quiero que Ecbatana soporte.

Las plantillas pueden ser un punto de partida. Pueden dar ejemplos, estructura e inspiración. Pero el sitio final debería seguir sintiéndose propio de esa pareja.

Ese todavía es un desafío abierto de ingeniería y producto dentro del proyecto.

El backend puede soportar múltiples bodas, pero el frontend también necesita un modelo de personalización que no se vuelva imposible de mantener. Cada boda puede necesitar sus propios assets, contenido, secciones, animaciones y decisiones visuales, pero al mismo tiempo debería compartir suficiente estructura para no reconstruir todo desde cero cada vez.

Para mí, esta es probablemente una de las partes más importantes del producto. El desafío no es solamente alojar muchas bodas. El desafío es hacer que cada boda se sienta personal.

Convertir una boda en muchas

Ecbatana no empezó como una plataforma multi-tenant.

La primera versión fue un prototipo para una sola pareja. El contenido estaba mayormente hardcodeado, y la app estaba construida alrededor de una boda. Eso estaba bien para la primera versión, pero no era suficiente si quería que el proyecto soportara más parejas.

El backend tuvo que cambiar primero.

Tablas como usuarios, RSVPs, selecciones de menú, pedidos de canciones, sesiones y solicitudes de acceso necesitaban pertenecer a una boda específica. Entonces agregaron un weddingId, y la app empezó a tratar cada boda como su propio tenant.

El frontend también cambió. En lugar de asumir una boda global, ahora la app resuelve la boda desde el slug de la URL y carga la configuración correcta para esa pareja.

Una decisión que tomé a propósito fue no mover todo el contenido por pareja a la base de datos inmediatamente.

Eventualmente sería más limpio, pero hacerlo durante la misma migración habría significado reescribir demasiado de una sola vez. Había alrededor de dos docenas de archivos con referencias a contenido específico de la boda, y no quería que la migración multi-tenant se convirtiera al mismo tiempo en una reescritura completa hacia un CMS.

Entonces mantuve parte del contenido en archivos estáticos de configuración y traté la migración de base de datos como un paso separado.

Esa fue una buena lección: a veces la mejor decisión de ingeniería no es hacer todo perfecto en una sola pasada. Es hacer posible el siguiente paso sin romper todo el proyecto.

El tablero de canciones

Una feature que quería construir era un tablero de canciones.

La idea era simple: los invitados deberían poder buscar canciones que quieren escuchar en la fiesta y agregarlas a una lista compartida.

El desafío era hacerlo gratis.

Una API de música paga probablemente daría mejor búsqueda, mejores metadatos y resultados más predecibles. Pero para este proyecto, eso se sentía innecesario. No necesitaba construir Spotify. Necesitaba que los invitados pudieran encontrar la mayoría de las canciones que probablemente iban a buscar para una boda.

Entonces usé dos fuentes gratuitas: MusicBrainz y la iTunes Search API.

MusicBrainz ofrece metadatos abiertos de música. La API de iTunes da mejor cobertura para canciones populares y muchas veces incluye artwork. La app busca en ambas, combina los resultados y los deduplica por título y artista normalizados para que la misma canción no aparezca dos veces.

Todavía hubo algunos detalles para resolver.

MusicBrainz tiene un rate limit estricto, así que las llamadas pasan por una pequeña cola en memoria que garantiza al menos un segundo entre requests. También agregué caché de corta duración delante de ambos proveedores, para que búsquedas repetidas no golpeen las APIs externas con cada tecla.

El resultado no es perfecto, pero funciona bien para el caso de uso real. Con esas dos APIs gratuitas, Ecbatana puede encontrar la mayoría de las canciones que los invitados probablemente van a pedir, mientras mantiene la feature gratis de operar.

Aprender a construir un mejor frontend

Uno de los mayores desafíos de Ecbatana fue el frontend.

Me considero un ingeniero full-stack, pero con más foco en backend. Entonces construir algo que se sintiera pulido, emocional y visualmente intencional no era la parte más natural del proyecto para mí.

La animación del sobre fue el primer gran ejemplo.

La inspiración original vino de un post de Pinterest que una de las parejas me compartió. Mostraba una idea de invitación de boda hecha con Canva y assets de Canva. El objetivo era lograr una sensación parecida: un sobre, una tarjeta y elementos visuales que interactuaran con la página en lugar de verse como un sitio estático normal.

Al principio pensé que podía simplemente pedirle a Claude Code que construyera el sobre. Como Claude Code no podía generar imágenes en ese momento, creó un sobre en SVG. La animación era decente y sirvió como primera versión, pero visualmente todavía se sentía como un placeholder.

Más adelante, mientras intentaba mejorar la landing, empecé a usar la generación de imágenes de ChatGPT para crear assets visuales.

Al principio pedí una imagen completa de fondo. El concepto se veía bien, pero cuando lo usé en la página se sentía de baja calidad. Se veía demasiado suave, especialmente al estirarlo dentro del layout.

Ahí entendí algo importante: la página no debía depender de una sola imagen grande generada.

El mejor enfoque era crear assets separados.

Entonces empecé a generar el sobre, detalles botánicos en tinta, pequeños acentos decorativos, elementos de bouquet y el sello de cera como imágenes individuales. Después los ubiqué en la página con CSS en lugar de usarlos como un fondo fijo.

Eso mejoró mucho el diseño.

Los assets se veían más nítidos, la página se volvió más flexible y el frontend empezó a sentirse como una composición real en lugar de una imagen pegada. También se acercó más a la inspiración original: assets que se sentían parte de la invitación, no solo decoración alrededor.

Todavía hubo bastante trabajo manual. Algunos assets generados tenían fondos color crema que había que remover. Otros necesitaban ser redimensionados, limpiados, posicionados o combinados. El sello de cera, por ejemplo, necesitaba composición cuidadosa para sentirse adherido al sobre en lugar de flotar encima. Al final, opté por regenerar el sobre con el sello nuevo para que se viera completamente integrado.

También aprendí algunas limitaciones del flujo de generación de imágenes.

Después de varias rondas de edición, ChatGPT muchas veces se volvía peor haciendo cambios precisos sobre la misma imagen. A veces era más fácil ramificar el chat o empezar uno nuevo que seguir pidiendo pequeñas correcciones. El flujo de "editar imagen con comentarios" tampoco funcionó muy bien para este tipo de trabajo preciso con assets.

Aun así, el proceso cambió mi forma de pensar el diseño frontend. Dejé de tratar las imágenes generadas con IA como diseños finales y empecé a tratarlas como assets en bruto que todavía necesitaban ser integrados al producto.

Las animaciones del sobre

Hay dos momentos con sobres en Ecbatana.

El primero es la invitación pública.

El invitado toca el sobre, la solapa se abre y aparece la tarjeta de invitación. La primera versión de esta idea fue un sobre en SVG. Fue útil porque probó el concepto: la interacción tenía sentido, la animación funcionaba y le daba a la invitación una sensación más especial que una landing normal.

Pero el SVG fue solo el primer concepto.

La dirección final vino después, cuando empecé a trabajar con assets visuales generados. En lugar de mantener el sobre como una figura simple en SVG, generé un asset real de sobre que coincidiera con el lenguaje visual del resto de la página. Eso hizo que la interacción se sintiera mucho más cercana a una invitación real: el sobre, el sello, la tarjeta y los elementos decorativos parecían pertenecer al mismo mundo.

Eso también cambió mi forma de pensar la animación. Ya no se trataba solo de abrir un sobre. Se trataba de hacer que el primer momento del sitio se sintiera como recibir y abrir una invitación de boda real.

El flujo todavía necesitó más trabajo después de crear el asset. En un momento, la animación de apertura y el resto de la página se sentían desconectados, casi como dos experiencias distintas puestas una después de la otra. La solución fue hacer que la tarjeta de invitación quedara sticky y docked hacia un lado mientras el resto del contenido pasaba al scrollear. Combinado con scroll snapping, la página empezó a sentirse más como una invitación continua que como una animación seguida de un sitio web normal.

También hubo un bug chico pero molesto en ese flujo.

Cuando el sobre estaba cerrado, bloqueaba el scroll detrás. Pero cuando el sobre se abría, la scrollbar aparecía de nuevo y causaba un salto visible en el layout. La solución fue reservar el espacio de la scrollbar desde el principio.

El segundo sobre se usa en el flujo de solicitud de acceso.

Ese es una animación CSS 3D. A medida que el invitado scrollea, la tarjeta rota sobre los ejes Y y Z, y el formulario de solicitud aparece en la cara trasera. La animación está controlada por el progreso del scroll, no por una duración fija, así que el giro siempre coincide con qué tan abajo llegó el invitado en la página.

Esta parte todavía está evolucionando, pero fue una de las piezas frontend más interesantes del proyecto.

Pulir la experiencia mobile

Otro desafío apareció al probar el sitio en un teléfono real.

Yo desarrollaba la mayor parte de Ecbatana desde mi computadora, pero la mayoría de los invitados probablemente va a abrir el link desde WhatsApp. Eso significa que la experiencia mobile no es secundaria. Probablemente es la experiencia principal.

Cuando empecé a abrir el sitio desde mi teléfono, noté muchas cosas que no eran obvias en desktop.

El espaciado necesitaba más trabajo. Algunos assets decorativos se veían bien en una pantalla grande, pero resultaban distractores en una chica. El texto tenía que ser más grande o más visible. El modo landscape creó sus propios problemas, especialmente cuando los assets del fondo y el texto competían por espacio.

Un cambio que ayudó fue el scroll snapping.

Le dio a la página una sensación más parecida a una presentación. Cada sección cae de forma más intencional mientras el invitado scrollea, tanto en desktop como en mobile. Eso hizo que la invitación se sintiera menos como un documento largo y más como una secuencia de momentos.

Los assets del fondo fueron más difíciles.

Como estaba usando assets generados por separado en lugar de una sola imagen grande, cada pieza tenía que posicionarse cuidadosamente en distintos tamaños de pantalla. Portrait y landscape necesitaban pensarse de forma distinta. Lo que se veía balanceado en un layout podía sentirse completamente mal en otro.

Esto todavía es un trabajo en progreso, pero fue un buen recordatorio de que "responsive" no alcanza. El sitio tiene que funcionar en la forma en que la gente realmente lo va a usar.

Para Ecbatana, eso significa teléfonos, links de WhatsApp, modo portrait, scroll rápido e invitados que no están ahí para admirar la ingeniería. Solo quieren que la invitación se sienta clara y linda.

Galerías privadas de fotos

La galería privada de fotos fue otra feature que terminó moldeando la infraestructura.

Las fotos son pesadas, y una galería de boda puede crecer rápido. No quería guardar archivos de fotos en Postgres, y tampoco quería que un Worker cargara archivos grandes en memoria.

Entonces los archivos viven en Cloudflare R2.

La app los sirve a través de rutas autenticadas, para que los invitados solo puedan acceder a las fotos de la boda a la que pertenecen. Lo importante es que los archivos se streamean en lugar de cargarse completamente dentro del Worker.

Esta fue una de esas features donde el detalle de implementación importa. No alcanza con que las fotos privadas "funcionen". Tienen que funcionar de una forma que no rompa el runtime cuando la galería crece.

El stack

El stack actual es:

  • Next.js 16 con App Router
  • React 19
  • TypeScript
  • Tailwind CSS 4
  • Drizzle ORM
  • Postgres en Neon
  • Cloudflare Workers / Pages a través de OpenNext
  • Cloudflare R2 para archivos privados
  • Cloudflare Images para optimización de imágenes
  • Resend para emails transaccionales
  • Zod y react-hook-form para formularios

No elegí el stack solo por probar herramientas nuevas. La mayoría de las decisiones vinieron de las restricciones del proyecto.

Quería una app full-stack moderna con React, pero también quería que corriera en Cloudflare. Quería Postgres, pero sin administrar un servidor. Quería almacenamiento privado de media, pero sin pagar infraestructura demasiado pronto. Quería email, pero a través de un proveedor HTTP que funcionara bien en Workers.

El stack no es perfecto, pero encaja con la etapa actual del producto.

Encontrarme con los límites de Cloudflare

Uno de los momentos más útiles fue encontrarme con un límite real de producción.

En un momento, unas pocas recargas rápidas de la homepage de invitados dispararon el error de Cloudflare "exceeded resource limits".

Eso me sorprendió porque la app no estaba bajo tráfico pesado. Eran pocos requests. Entonces el problema no era escala en el sentido habitual. El problema era que el request path estaba haciendo demasiado.

Había dos problemas principales.

El primero era que todo el contenido por boda se estaba renderizando fresco en cada request en lugar de mantenerse fuera del camino crítico. El segundo era un patrón de transacciones de base de datos que podía mantener una conexión de Neon abierta más tiempo del necesario.

Consideré seriamente mover toda la app de vuelta a Vercel. Vercel tiene un runtime más flexible para este tipo de cosas, y probablemente habría hecho desaparecer el error.

Pero la mejor solución era limpiar la arquitectura.

Moví el manejo pesado de media a streaming desde R2, mantuve el contenido costoso lejos de rutas sensibles de autenticación y me aseguré de que las transacciones abrieran una conexión dedicada por request en lugar de mantener conexiones de forma riesgosa.

Después de eso, los límites de Cloudflare dejaron de ser una preocupación importante.

Esa fue una buena lección. A veces un runtime más grande esconde el problema. Un runtime más chico te obliga a arreglarlo.

Usar herramientas de IA más allá de generar código

Otro aprendizaje importante fue ver lo útiles que se vuelven las herramientas de desarrollo asistidas por IA cuando el agente puede usar las mismas herramientas que el desarrollador.

Este proyecto no fue solamente pedirle a Claude Code o Codex que escribieran componentes.

Les di acceso al flujo real de trabajo: Vercel CLI, Wrangler de Cloudflare, Netlify CLI y Neon CLI. También probé Netlify durante el proceso de migración, así que tener la CLI disponible hizo más fácil comparar distintos targets de deployment.

Eso cambió lo que el agente podía hacer.

Podía inspeccionar el proyecto, correr builds, leer errores, actualizar configuración, probar migraciones, desplegar previews, revisar logs e iterar a partir del resultado.

Eso es muy distinto a un agente que solo edita archivos.

El aprendizaje más grande para mí fue que las herramientas de IA se vuelven mucho más útiles cuando están conectadas al feedback loop real del proyecto. Si el agente puede correr la app, desplegarla, ver qué falló y ajustar en base a output real, empieza a sentirse mucho más cerca de un asistente de ingeniería.

Obviamente, todavía necesita supervisión. Yo seguía teniendo que tomar las decisiones de arquitectura, proteger secretos, revisar cambios y decidir qué era seguro desplegar.

Pero para trabajo pesado de configuración, como mover la app de Vercel a Cloudflare, probar conexiones con Neon, debuggear variables de entorno y resolver errores de deployment, la combinación de acceso a CLI y asistencia de IA hizo que el proceso fuera mucho más rápido.

Lo que todavía está en progreso

Ecbatana todavía está evolucionando.

Algunas partes ya funcionan bien: la estructura multi-tenant, el login de invitados, el flujo de RSVP, la selección de menú, la búsqueda de canciones, las galerías privadas y la experiencia principal de invitación.

Otras partes todavía necesitan más pulido.

El layout mobile puede mejorar más, especialmente en landscape. Los assets visuales podrían organizarse mejor. Parte del contenido por pareja eventualmente debería moverse desde archivos estáticos hacia una base de datos o una interfaz de administración. Las animaciones del sobre todavía pueden refinarse. La búsqueda de canciones podría mejorar más adelante con un proveedor pago si el producto necesita mejor cobertura.

Pero el desafío abierto más grande es la experiencia frontend específica para cada pareja.

El backend ya puede separar bodas, invitados, RSVPs, canciones, menús y sesiones. La pregunta más difícil es cómo permitir que cada pareja tenga un sitio que realmente se sienta suyo sin convertir cada nueva boda en un proyecto frontend completamente custom.

Ese balance todavía es algo que estoy resolviendo.

Reflexiones finales

Lo más interesante de Ecbatana no fue una feature en particular.

Fue el proceso de convertir algo muy personal en algo reutilizable sin hacerlo sentir genérico.

Un sitio de boda tiene que sentirse simple y emocional para los invitados. Pero detrás de eso hay problemas reales de ingeniería: autenticación, multi-tenancy, media privada, validación de formularios, diseño de base de datos, envío de emails, APIs externas, caching, UX mobile, animaciones, assets generados y límites de deployment.

Para mí, Ecbatana fue un proyecto que me desafió de una buena manera.

Me empujó fuera de las áreas donde normalmente me siento más cómodo, especialmente backend e infraestructura, y me obligó a preocuparme mucho más por frontend, diseño, animación, UX mobile y detalles visuales.

Pero más que eso, me recordó que el software significativo no se trata solamente de resolver un problema técnico. En este caso, el objetivo es construir algo que se sienta personal para cada pareja y que las ayude a compartir uno de los momentos más importantes de sus vidas.