One codebase, three platforms: what we learned building Boxhouse
The decision to build Boxhouse as a Turborepo monorepo with Expo, Next.js, and Tamagui seemed obvious until we were actually in it. One codebase. Three platforms. Shared components, shared logic, shared everything. It works. Here is what working looked like.
The structure
The repository contains two apps — expo and next — and a packages directory with the shared code they both consume. packages/app holds the feature components: auth screens, home, profile, live fight, replay, score fight. packages/ui holds the design system. Custom font packages and an icon library wrapper live in their own packages so they can be versioned independently.
Sharing components between Expo and Next.js requires every component to handle two rendering targets. Tamagui handles most of this with platform-specific file conventions — a component can have a .tsx for native and a .web.tsx for web, and the bundler picks the right one. When this works it is very clean.
The icon migration
Three weeks in, the icon library renamed every export. Home became IconHome. Menu became IconMenu. Plus became IconPlus. This affected 1,684 files.
// before
import { Home, Menu, Plus, User } from '@tamagui/lucide-icons'
// after
import { IconHome, IconMenu, IconPlus, IconUser } from '@tamagui-icons/icon-lucide'
We renamed them. It took a day. The lesson is not to avoid icon libraries — it is to wrap their exports behind your own component layer so that when this happens again, the change is in one file.
Auth
Auth runs through Supabase. The sign-up schema collects email, password, full name, country, and a short self-description, all validated with Zod.
const SignUpSchema = z.object({
email: formFields.text.email({ message: 'Please type a valid email address' })
.describe(' // Email Address'),
password: formFields.text.min(6).describe(' // Password'),
fullName: formFields.text.min(2).describe(' // Full Name'),
country: formFields.text.min(2, { message: 'Please select your country' })
.describe(' // Country'),
describe: formFields.text.min(2, { message: 'Please describe yourself as a boxing fan' })
.describe(' // Describe'),
})
The invalid:border-destructive utility class handles validation error state on the input border without JavaScript. If you can express a UI state in a CSS class, express it in a CSS class.
Dependency management
Managing dependencies across two apps and six packages is a part-time job. We pinned versions explicitly after the first week of unexplained build failures. Every package.json has exact version numbers. The yarn.lock is committed and treated as a source of truth.
When a build fails in CI and passes locally, the answer is almost always the lock file.
Dark mode
The app is dark mode only. This is a product decision, not a technical one, and it simplified the component work considerably — every colour token is a single value rather than a light and dark pair. If you have the option to make this call early, make it early.