Camilo Páez

    AccueilServicesBlogLivresProjetsExpériences
    ServicesÉvaluation ImmobilièreÀ propos de moiPlan du sitePolitique de confidentialité

© 2024 Camilo Páez, tous droits réservés

← Projets

Musae

Bogotá, Colombie

Boutique en ligne bilingue (ES/EN) de bijoux artisanaux en verre soufflé, avec paiement en pesos colombiens via Wompi et un panneau d'administration sur mesure pour gérer produits, stocks, clients et événements. Construit avec Next.js 15 et React 18 + Vite, les deux consommant le même projet Firebase sans API intermédiaire.

Début: Juin 2024
En ligne
Développeur full-stack
Autofinancement
Visiter la boutique

En chiffres

224
Web components
213
Admin components
31
Typed Firebase services
~80
Localized public routes
ES / EN
Languages
2 → 1
Projects, one Firebase backend

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)

Aperçu

Musae is a Colombian hand-blown glass jewelry brand. Every piece — earrings, necklaces, pendants — is blown by hand, which means no two pieces are exactly alike, and "inventory" is never an abstract number: when a color or shape runs out, it's gone until the next batch comes out of the kiln. Before I joined the project, Musae sold the way most small jewelry brands in Colombia sell: through Instagram, through WhatsApp, taking orders by hand, and showing up at artisan fairs with a box of products and a calculator.

That works until it doesn't. Once you have collections, a fair circuit, event-specific discount codes, repeat customers, and a need to report inventory without losing your mind, the spreadsheet-plus-screenshots-plus-memory stack stops being a tool and starts being the bottleneck of the business.

The actual problem

This was never "we need a pretty website." It was three operational processes that didn't scale.

  1. Catalog and online sales. Without an actual site, every sale depended on someone catching an Instagram story in time and DMing about it. No checkout, no local payment gateway, no way to tell which piece was actually selling.
  2. In-person events. Musae shows up at artisan fairs (the EVA fair, for example) and needed a way to announce them, hand out booth-exclusive discount codes, and measure afterward whether the trip was worth it.
  3. Internal operations. Without an admin panel, any price change, new product, or in-person sale meant opening Firestore by hand or asking me to do it. That doesn't scale — not for the business, and not for me.

And all of this had to work in Spanish and English, because part of Musae's audience — especially on social — is English-speaking, and a jewelry business selling internationally can't afford a Spanish-only site.

The architecture call: one Firebase, two applications

The biggest architectural decision happened early: one Firebase project, no intermediary API, consumed directly by two completely separate applications.

  • Musae Web (musae.com.co): the public storefront, built in Next.js 15 with the App Router. Server Components read straight from Firestore and generate the catalog, collections, blog, and events pages. This is what a buyer sees.
  • Musae Admin Panel: a private React 18 + Vite SPA where products, orders, inventory, customers, email/WhatsApp campaigns, and events get managed. This is what the Musae team — in practice, Camilo (me) and my sister — uses every day.

Both apps read and write to the same Firebase project. There's no backend layer arbitrating business rules between them. That means Firestore's security rules are, de facto, the API contract between the public site and the admin panel. If the rules aren't exactly right, either the public can write where it shouldn't, or the admin can't read what it needs to.

I chose this path deliberately, not out of laziness: for a business this size, standing up and maintaining a separate REST/GraphQL API is overhead that doesn't pay for itself. The risk shifts to the security rules and to type discipline on both sides (strict TypeScript, no any, in both repos) — and that risk is manageable when you control both ends of the system yourself.

How the bilingual setup actually works

The site uses [lang]-segmented routes (/es/..., /en/...) and a middleware that decides, on every request, which version to serve. The interesting part isn't that there are two languages — that's table stakes — it's how cross-language routes that don't translate literally got handled: in Spanish, jewelry lives at /es/joyeria; in English it's /en/jewelry. Events are /es/eventos and /en/events. The middleware rewrites (not redirects) whenever someone lands on a cross-locale combination — say, /en/joyeria — so the visible URL is always the correct one for SEO, without losing a visitor who arrived via an old or mistyped link.

Every piece of copy lives in JSON dictionaries (one per language, thousands of lines each) loaded server-side based on the request's locale. There's no loose copy sitting in components — if a string changes, it changes in the dictionary, in both languages, or the build fails.

Events: where the physical and digital sides actually meet

The part I most enjoyed building was the events module, because it's where the physical world (a booth at an artisan fair) and the digital one (the site, the discount codes, attendee registration) touch. Every event in Firestore carries a date, a location with coordinates, featured products, bilingual FAQs, and, optionally, a discount code scoped only to that event (originTag: 'evento-{id}'). That lets the business know, code in hand, whether a given fair was actually worth attending — something that used to be pure gut feeling.

Every event page also emits its own schema.org/Event structured data with location, dates, and FAQ as JSON-LD, so Google understands what the page is about without anyone having to think through technical SEO event by event.

Results

  • A bilingual catalog in production with Wompi-based checkout (a Colombian payment gateway, so paying feels local rather than like a generic USD checkout).
  • An admin panel covering products, bundles, inventory, customers, sales, discounts, communications (email, WhatsApp, birthday campaigns), and events — without the day-to-day of the business depending on me writing a Firestore query.
  • An architecture carrying roughly 224 components on the public site and 213 in the admin panel, with 31 fully-typed Firebase service modules in strict TypeScript, and close to 80 localized public routes indexed in a dynamic sitemap with hreflang.

What I actually learned (short version)

Sharing one Firebase project across two separate stacks with no intermediary API works really well — until the two apps don't quite agree on the shape of the data. More than once, the admin would write one field (locationDetails) while the web expected another (location), and the fix wasn't "make them match in code someday," it was writing an explicit mapping function (mapDocToEvent) that normalizes the mismatch right at the read boundary. It's an honest patch, not an elegant one — but it's the kind of patch you end up writing when two surfaces (or two versions of the same person, at different points in time) touch the same data without a formal contract between them.

The longer version of these lessons — including a pre-deploy security scare — lives in this case study's lessons learned section.

If you want to see the result live, the store is at musae.com.co.

Approfondissement technique

This section is for readers who want code, not just the executive summary. It covers three concrete technical decisions: how Firebase gets shared between two stacks with no intermediary API, how the bilingual routing actually works, and how the events module is built end to end (Firestore → Server Component → JSON-LD).

1. One Firebase, no intermediary API

Initialization is deliberately minimal — a singleton so the app doesn't get re-initialized on every server render:

// 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);

Both the public site (Next.js, Firebase SDK 11.6) and the admin panel (Vite, Firebase SDK 11.0) point at the same projectId. There's no intermediary service validating business rules between them — Firestore Security Rules are the only shared control point. That forces every schema change to be framed as "who's allowed to read/write this field?", not just "what type is this field?"

The practical consequence is defensive mapping at the read boundary, because the admin and the web don't always agree on a field's exact name:

// 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);
  // Admin writes location as `locationDetails`; the web expects `location`.
  if (data.locationDetails) {
    (serialized as unknown as Record<string, unknown>).location = serializeTimestamps(data.locationDetails);
  }
  return serialized;
}

It's not elegant, but it's honest: instead of forcing a retroactive data migration, the normalization lives at the consumption point, with a comment explaining why it's there.

2. i18n: middleware + dictionaries, not a routing library

The locale gets resolved in src/middleware.ts before the request ever reaches a page. Three separate responsibilities: detect the language, rewrite cross-locale combinations, and redirect legacy paths.

// src/middleware.ts (excerpt)
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, not redirect: the visible URL doesn't change
  }
  return null;
}

// inside 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;
}

The rewrite vs. redirect distinction is deliberate: a rewrite keeps the correct canonical URL for SEO without penalizing a user who landed via a cross-locale link; a 301 redirect is reserved for genuinely legacy paths (old WordPress URLs, renamed routes) where we do want the browser's address bar to update.

Copy lives in JSON dictionaries, lazily loaded per 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);
};

Each dictionary runs past 8,000 lines. There's no loose copy sitting in components — if a key is missing in one language, TypeScript flags it at build time, not in production.

3. The events module, end to end

The domain type

// 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;
  // ...
}

Every text field is bilingual right in the type ({ es: string; en: string }), not in a separate translation table. For this domain — where Musae's own team creates the content from the admin panel — keeping both languages on the same document is simpler than normalizing into separate collections.

Reading with React's cache

// 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]);
  }
);

React's cache() deduplicates the query if the same (language, slug) pair gets requested more than once during the same render — for instance, once from generateMetadata and once from the page component itself.

Discount codes scoped to an event

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) => ({ /* ... */ }));
  }
);

The originTag field, formatted as evento-{id}, is the only relationship between a discount code and an event. It's not a typed Firestore DocumentReference — it's a string convention. The upside is it's queryable without an extra composite index; the downside is the convention only lives in code, not in the schema. It's a deliberate simplicity-over-robustness call for a data volume that, in practice, is dozens of events, not thousands.

JSON-LD generated per component

// 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,
  },
};

Every event emits three JSON-LD blocks (Event, BreadcrumbList, and FAQPage when applicable) directly from the component that renders the page, rather than from a centralized SEO service. That means the schema and the HTML it describes never drift apart — when the data changes, both change together because they come from the same source.

4. The Server Component → Client Component pattern

// app/[lang]/events/[slug]/page.tsx (pattern)
export default async function EventPage({ params }: Props) {
  const { lang, slug } = await params;
  const event = await getEventBySlug(lang, slug);          // server-side fetch
  const discountCodes = await getEventDiscountCodes(event!.id);

  return (
    <EventDetailLayout                                      // server component
      event={event}
      lang={lang}
      discountCodes={discountCodes}
    />
    // EventDetailLayout renders EventRegistrationForm as a
    // client component only where real interactivity is needed
  );
}

The events list and detail pages ship zero catalog JavaScript to the client — only the registration form and the interactive map are client components. Everything else — images, copy, FAQs via native <details> — is static HTML generated on the server.

What I'd change if I rebuilt it

The shared-Firebase-no-API pattern scales well as long as one person (or a very small, tightly coordinated team) controls the schema on both sides. If Musae's dev team ever grew, the first change I'd make is introducing a formal shared schema — generated types from a single source of truth, or Cloud Functions that validate writes — instead of trusting two independent TypeScript repos to keep the same contract by hand.

Leçons apprises

This is the honest part of the case study. Not everything went right on the first try, and a few decisions that seemed reasonable at the time generated debt I had to pay down later.

What worked

Sharing Firebase with no intermediary API was the right call for this team size. I doubted it at first — it feels "wrong" not to have an API layer between the client and the database — but for a two-person operation running the day-to-day, standing up and maintaining an intermediary backend would have been pure infrastructure work with no direct payoff for the business. The cost shifted to Firestore's security rules and to type discipline, and on both sides I spent the time that would otherwise have gone into maintaining an API.

The bilingual rewrite middleware turned out to be easier to maintain than I expected. Centralizing all the language logic in one file (middleware.ts) instead of spreading it across components means that when there's a routing bug, I know exactly where to look. There hasn't been a single production case of a URL showing content in the wrong language.

React.cache() for deduplicating Firestore reads paid for itself almost immediately. Before using it, it was common for generateMetadata and the page component to independently run the same Firestore query. With cache(), that duplication disappears without having to manually thread data between the two functions.

What broke (or almost did)

A pre-deploy security scare. At some point during development, I added new environment variables (a payment webhook secret, an internal API secret, and an allowed-origin setting for the admin panel) that the code already assumed were available but that weren't configured in production. The code was committed and ready, but deploying it without those variables would have silently broken payment confirmation, admin SMS notifications, and review links. The lesson isn't "don't make mistakes like this" — that's naive — it's: write an explicit deploy checklist whenever you add new secrets, and check it before every deploy, not after something fails in production. Since then, any change touching new environment variables ships with an explicit note about what needs to be configured before going live.

Field-name mismatches between the admin and the web. I already mentioned this in the technical section: the admin wrote locationDetails, the web expected location. It wasn't a catastrophic bug, but it was a signal that sharing a data schema across two independent TypeScript repos, with no single source of truth for the types, has a coordination cost that grows over time. I fixed it with a normalization function at the read boundary, which works, but it's documented technical debt, not a permanent solution.

Out-of-sync Firebase and React versions between the two repos. The web runs React 19 and Firebase SDK 11.6; the admin runs React 18 and Firebase SDK 11.0. It's not a functional problem today, but it's the kind of gap that gets more expensive to close the longer it goes unaddressed. I should have set a shared versioning policy from day one, even something as simple as "update both repos in the same work session."

What I'd do differently

If I started Musae today, knowing what I know now:

  1. I'd define shared domain types (Event, Product, Order) in a single package, instead of duplicating them — with small drift — across both repos. Not necessarily a full monorepo, but at least a deliberately shared or published types package, not something rewritten from memory each time.
  2. I'd write the Firestore Security Rules before the first CRUD operation, not after. With no intermediary API, the security rules are the system's actual schema, and treating them as a "I'll tighten this up later" task underestimates how much both the web and the admin depend on them to not step on each other.
  3. I'd document the originTag convention and any other implicit string-based relationship (instead of a typed Firestore relationship) in one visible place, not just in the code that uses it. These are reasonable simplicity trade-offs — but only if someone else, or me in six months, can find them without reading the entire source code.

The advice I'd give someone building something similar

If you're building a small e-commerce business on Firebase without a dedicated backend team, "one database, two applications, no intermediary API" is a legitimate architecture, not a low-quality shortcut — but only if you accept, from day one, that Firestore's security rules are your API, and treat them with the same care you'd give a versioned REST contract. The mistake isn't choosing this architecture; the mistake would be treating it as if it had no contract at all, just because there's no openapi.yaml file making that contract visible.

Visiter le site — musae.com.co ↗