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.
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.
This was never "we need a pretty website." It was three operational processes that didn't scale.
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 biggest architectural decision happened early: one Firebase project, no intermediary API, consumed directly by two completely separate applications.
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.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.
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.
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.
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.
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).
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.
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.
// 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.
// 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.
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.
// 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.
// 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.
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.
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.
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.
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."
If I started Musae today, knowing what I know now:
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.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.