The OM Lounge, a bilingual spa booking site
A spa services and booking site running in Vietnamese and English: two sets of URLs, two sets of metadata, and a cart tied to an appointment slot.
- Next.js 16
- TypeScript
- Tailwind CSS
- next-intl
- Docker
A services and booking site for a spa and nail salon: customers browse the service menu, add items to a cart, pick a date and time, then confirm. The whole thing runs in Vietnamese and English on the Next.js App Router.
Bilingual starts at the URL, not at the strings#
The first thing I settled on was putting every route under src/app/[locale]/, with the
locale list declared exactly once in src/types/locale.ts via defineRouting. The proxy
file is three lines: it wraps createMiddleware(routing) and keeps api, _next and
static files out of the matcher.
That makes /vi/services and /en/services two real URLs, both prerendered by
generateStaticParams, and the header's language switch is just
<Link href={pathname} locale={otherLocale}> — whatever page you are on, switching
language keeps you there instead of dropping you on the home page.
Metadata needs two versions too#
This is the easiest part to fake: translate the interface, call it multilingual, and
leave one English title and description for both. I moved the whole SEO block into an
seo namespace in the two message files and read it on the server:
export async function generateMetadata({
params: { locale },
}: {
params: { locale: string };
}): Promise<Metadata> {
const t = await getTranslations({ locale, namespace: 'seo.services' });
const canonicalUrl = `${siteUrl}/${locale}/services`;
return {
title: t('title'),
description: t('description'),
keywords: t('keywords'),
alternates: {
/* canonical points at the right locale; languages declares the vi/en
pair so search engines do not read the two as duplicate content. */
canonical: canonicalUrl,
languages: {
vi: `${siteUrl}/vi/services`,
en: `${siteUrl}/en/services`,
},
},
openGraph: { url: canonicalUrl, locale: locale === 'vi' ? 'vi_VN' : 'en_US' },
};
}The two keyword sets are not translations of each other either: the Vietnamese one goes after local search terms like "nail quận 7" and "sơn gel", the English one after a different set entirely.
The line between interface strings and service data#
I split the text in two. Anything that belongs to the interface — navigation, button labels, cart messages — lives in the message files. The service menu itself, with names, prices, durations and the finish options that go with each item, lives in its own data module.
In the current version that menu is Vietnamese only, and I deliberately kept it out of the message files. A spa menu changes month to month and will eventually come from a backend; at that point each service should carry its own translations rather than an i18n key the frontend invented for it.
A spa cart is not a retail cart#
The cart lives in a Zustand store, and it differs from an ordinary e-commerce cart in two
ways. First, one service can be added several times with different finishes, so each line
is keyed by id-variant rather than id. Second, a cart only means anything once it is
attached to a slot that is still available:
export function getUpcomingDates(count: number) {
const dates: { label: string; day: string }[] = [];
const today = new Date();
const nowMinutes = today.getHours() * 60 + today.getMinutes();
const lastSlot = TIME_SLOTS[TIME_SLOTS.length - 1];
/* Past the last slot of the day, drop today and start counting from tomorrow. */
const startOffset =
nowMinutes >= slotToMinutes(lastSlot.time, lastSlot.period) ? 1 : 0;
for (let i = startOffset; i < count + startOffset; i++) {
const d = new Date(today);
d.setDate(today.getDate() + i);
dates.push({ label: DAY_NAMES[d.getDay()], day: d.toISOString().split('T')[0] });
}
return dates;
}Slots are fixed at half-hour steps. If the customer picks today, every slot already gone
is disabled; and when they change the date, the currently selected slot clears itself
if it has just become the past.
Packaging for deployment#
The Dockerfile is three stages — install dependencies, build, then run — and uses
output: 'standalone' so the final image carries only what it needs. The container runs
as a non-root user, and I set TZ=Asia/Ho_Chi_Minh in the runner stage because all of
the slot logic compares against the current time. A Makefile wraps docker buildx for
multi-arch builds covering both linux/arm64 and linux/amd64, and refuses to push
without a TAG.
Outcome#
- Two locales as two prerendered URLs, each with its own canonical and
alternates.languages - Switching language keeps you on the page you were reading
- A cart that distinguishes finishes and refuses slots that have already passed
- One multi-arch Docker image that runs on x86 machines and ARM boards alike