Camilo Páez

    InicioServiciosBlogLibrosProyectosExperiencias
    ServiciosValoración InmobiliariaSobre miMapa del sitioPolítica de privacidad

© 2024 Camilo Páez, todos los derechos reservados

← Proyectos

Musae

Bogotá, Colombia

Tienda en línea bilingüe (ES/EN) de joyería artesanal en vidrio soplado, con checkout en pesos colombianos vía Wompi y un panel de administración propio para gestionar productos, inventario, clientes y eventos. Construida en Next.js 15 y React 18 + Vite, ambos consumiendo el mismo proyecto Firebase sin una API intermedia.

Inicio: Junio 2024
En línea
Desarrollador full-stack
Auto-financiamiento
Visitar tienda

En números

224
Componentes web
213
Componentes admin
31
Servicios Firebase tipados
~80
Rutas públicas localizadas
ES / EN
Idiomas
2 → 1
Proyectos, un solo Firebase

Stack

  • web
  • Next.js 15 (App Router)
  • React 19
  • TypeScript 5 (strict)
  • Firebase (Firestore, Storage, Admin SDK)
  • Wompi (pasarela de pagos, webhooks)
  • Resend (email)
  • Twilio (SMS)
  • Vercel (hosting + Analytics + Speed Insights)
  • Vitest + Playwright
  • admin
  • React 18 + Vite 5
  • React Router 6
  • TanStack Query v5
  • Firebase (mismo proyecto que la web)
  • dnd-kit (reordenar galerías)
  • xlsx (import/export masivo de inventario)

Resumen

Musae es una marca colombiana de joyería artesanal en vidrio soplado. Cada pieza —aretes, collares, dijes— se sopla a mano, lo que significa que no hay dos exactamente iguales y que el inventario nunca es "infinito": cuando se acaba un color o una forma, se acabó hasta la próxima hornada. Antes de que yo entrara al proyecto, Musae vendía como vende casi cualquier marca de joyería pequeña en Colombia: por Instagram, por WhatsApp, tomando pedidos a mano y yendo a ferias artesanales con una caja de productos y una calculadora.

Eso funciona hasta que no funciona. Cuando empiezas a tener colecciones, presencia en ferias, códigos de descuento por evento, clientas recurrentes y necesitas reportar inventario sin perder la cabeza, la mezcla de Excel + capturas de pantalla + memoria se vuelve el cuello de botella del negocio, no una herramienta.

El problema real

No era "necesitamos una página bonita". Era: el negocio tenía tres procesos que no escalaban.

  1. Catálogo y ventas en línea. Sin un sitio propio, cada venta dependía de que alguien viera una historia de Instagram a tiempo y escribiera por DM. Sin checkout, sin pasarela de pagos local, sin manera de medir qué pieza se vendía más.
  2. Eventos presenciales. Musae participa en ferias artesanales (la feria EVA, por ejemplo) y necesitaba una forma de anunciarlas, dar códigos de descuento exclusivos para quienes asistieran al stand, y después medir si valió la pena ir.
  3. Operación interna. Sin un panel de administración, cualquier cambio de precio, alta de producto o registro de venta presencial requería abrir Firestore a mano o pedírmelo a mí. Eso no escala ni para mí ni para el negocio.

Y todo esto tenía que funcionar en español y en inglés, porque parte de la clientela de Musae —especialmente en redes— es angloparlante, y un negocio de joyería que vende internacionalmente no se puede dar el lujo de un sitio monolingüe.

La decisión de arquitectura: un Firebase, dos aplicaciones

La decisión más importante del proyecto la tomamos al principio: una sola base de datos Firebase, sin API intermedia, consumida por dos aplicaciones completamente distintas.

  • Musae Web (musae.com.co): el sitio público, construido en Next.js 15 con App Router. Server Components leyendo directo de Firestore, generando páginas estáticas para el catálogo, las colecciones, el blog y los eventos. Esto es lo que ve un comprador.
  • Musae Admin Panel: una SPA en React 18 + Vite, de acceso privado, donde se gestionan productos, pedidos, inventario, clientes, campañas de email/WhatsApp y eventos. Esto es lo que usa el equipo de Musae —en la práctica, Camilo (yo) y mi hermana— todos los días.

Ambas leen y escriben en el mismo proyecto Firebase. No hay un backend intermedio que orqueste reglas de negocio entre las dos. Eso significa que las reglas de seguridad de Firestore son, de facto, el contrato de API entre el sitio público y el panel. Si las reglas no son exactamente correctas, o el público puede escribir donde no debe, o el admin no puede leer lo que necesita.

Elegí este camino a propósito, no por pereza: para un negocio de este tamaño, montar y mantener una API REST/GraphQL intermedia es overhead que no se paga sola. El riesgo se desplaza a las reglas de seguridad y a la disciplina de tipos en ambos lados (TypeScript estricto, sin any, en los dos repos), y ese riesgo es manejable cuando uno mismo controla las dos puntas del sistema.

Cómo se resolvió el multilenguaje

El sitio usa rutas con segmento [lang] (/es/..., /en/...) y un middleware que decide, en cada request, qué versión servir. Lo interesante no es que existan dos idiomas —eso es estándar— sino cómo se resolvieron las rutas que no traducen literal: en español la sección de joyería vive en /es/joyeria y en inglés en /en/jewelry; los eventos son /es/eventos y /en/events. El middleware hace rewrite (no redirect) cuando alguien llega a una combinación cruzada —por ejemplo /en/joyeria— para que la URL visible sea siempre la correcta para SEO, sin perder al usuario que llegó por un enlace viejo o mal escrito.

Cada texto del sitio vive en diccionarios JSON (uno por idioma, miles de líneas cada uno) que se cargan en el servidor según el locale de la request. No hay textos sueltos en componentes: si un texto cambia, cambia en el diccionario, en los dos idiomas, o el build falla.

Eventos: la feature que conecta lo físico con lo digital

La parte que más disfruté construir fue el módulo de eventos, porque es donde el mundo físico (una feria artesanal con un stand) y el digital (el sitio, los códigos de descuento, el registro de asistentes) se tocan. Cada evento en Firestore tiene fecha, ubicación con coordenadas, productos destacados, preguntas frecuentes bilingües y, opcionalmente, un código de descuento vinculado solo a ese evento (originTag: 'evento-{id}'). Eso le permite al negocio saber, código en mano, si una feria fue rentable o no —algo que antes era pura intuición.

Cada página de evento genera su propio schema.org/Event con ubicación, fechas y FAQ en JSON-LD, lo que ayuda a que Google entienda de qué se trata la página sin que nadie tenga que pensar en SEO técnico evento por evento.

Resultados

  • Catálogo bilingüe en producción con checkout vía Wompi (pasarela colombiana, así que el pago se siente local, no como un checkout genérico en dólares).
  • Panel de administración que cubre productos, conjuntos/bundles, inventario, clientes, ventas, descuentos, comunicaciones (email, WhatsApp, campañas de cumpleaños) y eventos, sin que el día a día del negocio dependa de que yo escriba una query en Firestore.
  • Una arquitectura que sostiene ~224 componentes en el sitio público y ~213 en el panel, con 31 servicios de Firebase tipados de punta a punta en TypeScript estricto, y cerca de 80 rutas públicas localizadas indexadas en un sitemap dinámico con hreflang.

Lo que aprendí (la versión corta)

Compartir Firebase entre dos stacks distintos sin API intermedia es una decisión que funciona muy bien... hasta que las dos aplicaciones no están exactamente de acuerdo en la forma de los datos. Hubo más de una vez en que el admin escribía un campo (locationDetails) y la web esperaba otro (location), y la solución no fue "hacer que coincidan en el código un día" sino escribir una función de mapeo explícita (mapDocToEvent) que normaliza la discrepancia en el punto de lectura. Es un parche honesto, no una solución elegante, pero es la clase de parche que uno escribe cuando dos equipos (o dos versiones de uno mismo, en momentos distintos) tocan el mismo dato sin un contrato formal entre ellos.

La versión larga de las lecciones —incluyendo el susto de seguridad antes de un deploy— está en la sección de lecciones aprendidas de este caso de estudio.

Si quieres ver el resultado en vivo, la tienda está en musae.com.co.

Arquitectura técnica

Esta sección es para quien quiera ver código, no solo el resumen ejecutivo. Cubre tres decisiones técnicas concretas: cómo se comparte Firebase entre dos stacks sin una API intermedia, cómo funciona el ruteo bilingüe, y cómo está construido el módulo de eventos de punta a punta (Firestore → Server Component → JSON-LD).

1. Un Firebase, sin API intermedia

La inicialización es deliberadamente mínima. Un singleton para evitar reinicializar la app en cada render del servidor:

// src/lib/firebase/config.ts
import { getStorage } from "@firebase/storage";
import { initializeApp, getApps } from "firebase/app";
import { getFirestore } from "firebase/firestore";
import { getFunctions } from "firebase/functions";

const firebaseConfig = {
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_AUTH_DOMAIN,
  projectId: process.env.NEXT_PUBLIC_PROJECT_ID,
  storageBucket: process.env.NEXT_PUBLIC_STORAGE_BUCKET,
  messagingSenderId: process.env.NEXT_PUBLIC_MESSAGING_SENDER_ID,
  appId: process.env.NEXT_PUBLIC_APP_ID,
};

export const app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];
export const db = getFirestore(app);
export const storage = getStorage(app);
export const functions = getFunctions(app);

Tanto la web (Next.js, Firebase SDK 11.6) como el panel de administración (Vite, Firebase SDK 11.0) apuntan al mismo projectId. No hay un servicio intermedio que valide reglas de negocio entre los dos: las Firestore Security Rules son el único punto de control compartido. Esto obliga a que cualquier cambio de esquema se piense en términos de "¿quién puede leer/escribir este campo?", no solo "¿qué tipo tiene este campo?".

La consecuencia práctica es un mapeo defensivo en el punto de lectura, porque el admin y la web no siempre coinciden en el nombre exacto de un campo:

// src/lib/firebase/events.ts
function mapDocToEvent(doc: { id: string; data: () => Record<string, unknown> }): MusaeEvent {
  const data = doc.data();
  const serialized = serializeTimestamps({ id: doc.id, ...data } as MusaeEvent);
  // El admin escribe location en `locationDetails`; la web espera `location`.
  if (data.locationDetails) {
    (serialized as unknown as Record<string, unknown>).location = serializeTimestamps(data.locationDetails);
  }
  return serialized;
}

No es elegante, pero es honesto: en vez de forzar una migración de datos retroactiva, la normalización vive en el punto de consumo, documentada con un comentario que explica por qué existe.

2. i18n: middleware + diccionarios, no librerías de routing

El locale se resuelve en src/middleware.ts antes de que la petición llegue a cualquier página. Tres responsabilidades separadas: detectar idioma, reescribir combinaciones cruzadas, y redirigir rutas legadas.

// src/middleware.ts (extracto)
function rewriteOrNull(request: NextRequest, fromPrefix: string, toPrefix: string): NextResponse | null {
  if (request.nextUrl.pathname.startsWith(fromPrefix)) {
    const slug = request.nextUrl.pathname.slice(fromPrefix.length);
    const url = new URL(`${toPrefix}${slug}`, request.url);
    url.search = request.nextUrl.search;
    return NextResponse.rewrite(url); // rewrite, no redirect: la URL visible no cambia
  }
  return null;
}

// dentro de middleware(request):
if (lang === 'en') {
  rewritten = rewriteOrNull(request, '/en/joyeria', '/en/jewelry');
  if (rewritten) return rewritten;
}
if (lang === 'es') {
  rewritten = rewriteOrNull(request, '/es/jewelry', '/es/joyeria');
  if (rewritten) return rewritten;
}

La distinción rewrite vs redirect es deliberada: un rewrite mantiene la URL canónica correcta para SEO sin penalizar al usuario que llegó por un enlace cruzado; un redirect 301 se reserva para URLs legadas (WordPress, rutas renombradas) donde sí queremos que el navegador actualice la URL.

Las traducciones viven en diccionarios JSON cargados de forma perezosa por locale:

// src/lib/dictionaries.ts
const dictionaries = {
  en: async (collections?: Collection[]) => {
    const dict = await import('../dictionaries/en.json');
    return { ...dict.default, /* ... */ } as Dictionary;
  },
  es: async (collections?: Collection[]) => {
    const dict = await import('../dictionaries/es.json');
    return { ...dict.default, /* ... */ } as Dictionary;
  }
};

export const getDictionary = async (locale: Locale, collections?: Collection[]) => {
  if (!(locale in dictionaries)) return dictionaries.en(collections);
  return dictionaries[locale as keyof typeof dictionaries](collections);
};

Cada diccionario tiene más de 8,000 líneas. No hay strings sueltos en componentes: si falta una clave en un idioma, TypeScript lo marca en build, no en producción.

3. El módulo de eventos, de punta a punta

Tipo de dominio

// src/types/events.ts
export interface MusaeEvent {
  id: string;
  slug?: { es: string; en: string };
  title?: { es: string; en: string };
  status?: EventStatus; // 'draft' | 'published' | 'scheduled' | 'archived'
  startDate: Timestamp;
  endDate?: Timestamp;
  location?: EventLocation;
  registration?: EventRegistrationConfig;
  faq?: { question: { es: string; en: string }; answer: { es: string; en: string } }[];
  linkedDiscountCodeIds?: string[];
  featured?: boolean;
  // ...
}

Cada campo de texto es bilingüe en el propio tipo ({ es: string; en: string }), no en una tabla de traducciones separada. Para este dominio, donde el contenido lo crea el equipo de Musae desde el admin, mantener ambos idiomas en el mismo documento es más simple que normalizar en colecciones separadas.

Lectura con cache de React

// src/lib/firebase/events.ts
export const getEventBySlug = cache(
  async (language: 'es' | 'en', slug: string): Promise<MusaeEvent | null> => {
    const ref = collection(db, 'events');
    const slugField = `slug.${language}`;
    const q = query(
      ref,
      where(slugField, '==', slug),
      where('status', 'in', ['published', 'scheduled', 'archived'])
    );
    const snapshot = await getDocs(q);
    if (snapshot.empty) return null;
    return mapDocToEvent(snapshot.docs[0]);
  }
);

cache() de React deduplica la consulta si el mismo (language, slug) se solicita más de una vez durante el mismo render — por ejemplo, una vez desde generateMetadata y otra desde el componente de página.

Códigos de descuento atados a un evento

export const getEventDiscountCodes = cache(
  async (eventId: string): Promise<EventPublicDiscountCode[]> => {
    const ref = collection(db, 'discountCodes');
    const q = query(ref, where('originTag', '==', `evento-${eventId}`));
    const snap = await getDocs(q);
    return snap.docs
      .map((d) => ({ id: d.id, ...d.data() } as Record<string, unknown>))
      .filter((d) => d.codeType === 'multiUse')
      .map((d) => ({ /* ... */ }));
  }
);

El campo originTag con formato evento-{id} es la única relación entre un código de descuento y un evento. No es una referencia tipada de Firestore (DocumentReference) — es un string con convención. La ventaja es que se puede consultar sin un índice compuesto adicional; la desventaja es que la convención vive solo en el código, no en el esquema. Es una decisión consciente de simplicidad sobre robustez para un volumen de datos que, en la práctica, es de decenas de eventos, no de miles.

JSON-LD generado por componente

// src/components/events/EventSchema/index.tsx
const eventSchema: Record<string, unknown> = {
  '@context': 'https://schema.org',
  '@type': 'Event',
  '@id': canonicalUrl,
  name: title,
  startDate,
  endDate,
  inLanguage: lang === 'es' ? 'es-CO' : 'en-US',
  eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode',
  location: event.location && {
    '@type': 'Place',
    address: { '@type': 'PostalAddress', addressLocality: event.location.city, /* ... */ },
  },
  offers: {
    '@type': 'Offer',
    priceCurrency: 'COP',
    price: event.freeEntry ? '0' : undefined,
  },
};

Cada evento emite tres bloques JSON-LD (Event, BreadcrumbList, y FAQPage cuando aplica) directamente desde el componente que renderiza la página, no desde un servicio de SEO centralizado. Eso significa que el schema y el HTML que describe nunca se desincronizan — si cambia el dato, cambian los dos a la vez porque vienen de la misma fuente.

4. Patrón Server Component → Client Component

// app/[lang]/events/[slug]/page.tsx (patrón)
export default async function EventPage({ params }: Props) {
  const { lang, slug } = await params;
  const event = await getEventBySlug(lang, slug);          // fetch en servidor
  const discountCodes = await getEventDiscountCodes(event!.id);

  return (
    <EventDetailLayout                                      // server component
      event={event}
      lang={lang}
      discountCodes={discountCodes}
    />
    // EventDetailLayout renderiza EventRegistrationForm como
    // client component solo donde se necesita interactividad real
  );
}

El listado y el detalle de eventos no envían JavaScript de catálogo al cliente: solo el formulario de registro y el mapa interactivo son client components. Todo lo demás —imágenes, texto, FAQ con <details> nativo— es HTML estático generado en servidor.

Lo que tomaría en cuenta si lo reconstruyera

El patrón de un Firebase compartido entre dos apps sin API intermedia escala bien mientras una sola persona (o un equipo muy pequeño y coordinado) controle el esquema en ambos lados. Si Musae creciera a un equipo de desarrollo más grande, el primer cambio que haría sería introducir un esquema compartido formal (por ejemplo, tipos generados desde un único origen, o Cloud Functions que validen escritura) en vez de confiar en que dos repos de TypeScript independientes mantengan el mismo contrato a mano.

Lecciones aprendidas

Esta es la parte honesta del caso de estudio. No todo salió bien a la primera, y algunas decisiones que parecían razonables en el momento generaron deuda que tuve que pagar después.

Lo que funcionó

Compartir Firebase sin API intermedia, para este tamaño de equipo, fue la decisión correcta. Lo dudé al principio —se siente "incorrecto" no tener una capa de API entre el cliente y la base de datos— pero para un negocio de dos personas en el día a día, montar y mantener un backend intermedio habría sido trabajo puro de infraestructura sin beneficio directo para el negocio. El costo se trasladó a las reglas de seguridad de Firestore y a la disciplina de tipos, y en ambos lados invertí el tiempo que normalmente habría ido a mantener una API.

El middleware de rewrite bilingüe resultó más simple de mantener de lo que esperaba. Centralizar toda la lógica de idioma en un solo archivo (middleware.ts) en vez de repartirla entre componentes significa que cuando hay un bug de ruteo, sé exactamente dónde mirar. No ha habido ni un caso en producción de una URL que muestre contenido del idioma equivocado.

React.cache() para deduplicar lecturas de Firestore se pagó solo casi de inmediato. Antes de usarlo, era común que generateMetadata y el componente de página hicieran la misma consulta a Firestore por separado. Con cache(), esa duplicación desaparece sin tener que pasar datos manualmente entre las dos funciones.

Lo que rompí (o casi)

El susto de seguridad antes de un deploy. En algún punto del desarrollo, agregué variables de entorno nuevas (secretos de webhook de pagos, un secreto de API interna y el origen permitido para el panel de administración) que el código ya asumía como disponibles, pero que no estaban configuradas en el entorno de producción. El código estaba commiteado y listo, pero deployarlo sin esas variables habría roto silenciosamente la confirmación de pagos, los SMS administrativos y los links de reseña. La lección no es "no cometas errores así" —eso es ingenuo— sino: escribe un checklist de deploy explícito cuando agregas secretos nuevos, y revísalo antes de cada deploy, no después de que algo falle en producción. Desde entonces, cualquier cambio que toque variables de entorno nuevas va acompañado de una nota explícita de qué hay que configurar antes de publicar.

Discrepancia de nombres de campo entre admin y web. Ya lo mencioné en la sección técnica: el admin escribía locationDetails, la web esperaba location. No fue un error catastrófico, pero sí fue una señal de que compartir un esquema de datos entre dos repos de TypeScript independientes, sin un punto único de verdad para los tipos, tiene un costo de coordinación que crece con el tiempo. Lo resolví con una función de normalización en el punto de lectura, que funciona, pero que es deuda técnica documentada, no una solución definitiva.

Versiones de Firebase y React desincronizadas entre los dos repos. La web corre React 19 y Firebase SDK 11.6; el admin corre React 18 y Firebase SDK 11.0. No es un problema funcional hoy, pero es el tipo de brecha que se vuelve más cara de cerrar cuanto más tiempo pasa sin actualizarla. Debí fijar una política de versiones compartida desde el principio, aunque fuera tan simple como "actualizar ambos repos en la misma sesión de trabajo".

Lo que haría distinto

Si empezara Musae hoy, con lo que sé ahora:

  1. Definiría los tipos de dominio compartidos (Event, Product, Order) en un solo paquete, no duplicados —con pequeñas diferencias— en los dos repos. No necesariamente un monorepo completo, pero sí un paquete de tipos publicado o copiado deliberadamente, no reescrito de memoria cada vez.
  2. Escribiría las Firestore Security Rules antes del primer CRUD, no después. Cuando no hay una API intermedia, las reglas de seguridad son el verdadero esquema del sistema, y tratarlas como una tarea de "después lo afino" es subestimar cuánto dependen de ellas la web y el admin para no pisarse.
  3. Documentaría la convención de originTag y cualquier otra relación implícita por string (en vez de relación tipada de Firestore) en un solo lugar visible, no solo en el código que la usa. Son decisiones razonables de simplicidad, pero solo si alguien más —o yo mismo en seis meses— puede encontrarlas sin tener que leer el código fuente completo.

El consejo que le daría a alguien construyendo algo similar

Si estás construyendo un e-commerce pequeño con Firebase y sin equipo de backend dedicado, la combinación de "una sola base de datos, dos aplicaciones, sin API intermedia" es legítima y no es un atajo de mala calidad — pero solo si aceptas, desde el día uno, que las reglas de seguridad de Firestore son tu API, y las tratas con el mismo cuidado con el que tratarías un contrato REST versionado. El error no es elegir esta arquitectura; el error sería tratarla como si no tuviera un contrato, solo porque no hay un archivo openapi.yaml que lo haga visible.

Visitar sitio — musae.com.co ↗