Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,9 @@ dist/
.next/
tsconfig.tsbuildinfo
.turbo
.swc
.swc
/examples/example-expo/.claude
/examples/example-expo-monorepo/apps/mobile/.expo
/examples/example-expo-monorepo/apps/mobile/android
/examples/example-expo-monorepo/apps/mobile/ios
/examples/example-expo/ios
52 changes: 52 additions & 0 deletions examples/example-expo-monorepo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# example-expo-monorepo

Two apps + one shared UI package + one shared message catalog:

- `apps/mobile` — Expo + `expo-intl`
- `apps/web` — Next.js + `next-intl`
- `packages/ui` — shared React component library that uses `useExtracted`
- `messages/{en,de}.po` — single source of truth for every translation

The shared package imports `_useExtracted as useExtracted` from `use-intl/react`. The SWC plugin recognizes that import source, so calls inside `packages/ui` get the same compile-time rewrite as calls inside the apps themselves.

```text
examples/example-expo-monorepo/
├── messages/{en,de}.po # everything lives here
├── scripts/extract.mjs # workspace-wide extraction
├── apps/
│ ├── mobile/ # no messages dir of its own
│ └── web/ # no messages dir of its own
└── packages/
└── ui/ # <Greeting/>, <LocaleSwitcher/>
```

## How catalogs are shared

Every translation lives in exactly one file: `messages/{en,de}.po` at the workspace root. There are no per-app or per-package catalogs.

- Each app's bundler plugin is configured with `messages.path: '../../messages'` and `srcPath: ['./src', '../<sibling>/src', '../../packages/ui/src']`. Because each app's `srcPath` is the union of every source location, both apps produce the same catalog when they extract.
- Each app's runtime imports the single workspace catalog (no merge step).
- `pnpm extract` at the root re-extracts the full set without booting either dev server.

That means there's exactly one place to translate any string — `messages/de.po` — regardless of which app renders it.

## Try it

From the next-intl repo root:

```bash
pnpm install

# One-shot: re-extract everything in the workspace
pnpm -F example-expo-monorepo extract

# Or rely on the per-app dev watchers (either works; they extract the same set)
pnpm -F mobile-app start # expo start
pnpm -F web-app dev # next dev
```

The same `<Greeting name="Hugo" unreadCount={3} />` from `packages/ui/src/greeting.tsx` renders translated copy in both apps, driven by the workspace-level catalog.

## ICU features and React Native

The demo deliberately uses only placeholders (`{name}`) and rich-text tags (`<strong>...</strong>`) because they require no `Intl.*` runtime support. If you want to use plural / select / number / date formatting (`{count, plural, ...}`, `{value, number}`, etc.) in your own components, the bundled Hermes engine may need polyfills depending on the platform and SDK version. Install the relevant `@formatjs/intl-*` packages (`intl-pluralrules`, `intl-numberformat`, `intl-datetimeformat`, ...) and import them at the top of your Expo entry file before any component calls `useExtracted`.
6 changes: 6 additions & 0 deletions examples/example-expo-monorepo/apps/mobile/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@

# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
# The following patterns were generated by expo-cli

expo-env.d.ts
# @end expo-cli
23 changes: 23 additions & 0 deletions examples/example-expo-monorepo/apps/mobile/app.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"expo": {
"name": "mobile-app",
"slug": "example-expo-monorepo-mobile",
"version": "1.0.0",
"scheme": "mobileapp",
"orientation": "portrait",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true,
"bundleIdentifier": "dev.amann.example.expo.monorepo"
},
"android": {
"edgeToEdgeEnabled": true,
"package": "dev.amann.example.expo.monorepo"
},
"plugins": ["expo-router"],
"experiments": {
"typedRoutes": true
}
}
}
21 changes: 21 additions & 0 deletions examples/example-expo-monorepo/apps/mobile/metro.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Learn more https://docs.expo.dev/guides/customizing-metro
const {getDefaultConfig} = require('expo/metro-config');
const createExpoIntlPlugin = require('expo-intl/plugin');

const withExpoIntl = createExpoIntlPlugin({
experimental: {
// Single shared catalog at the workspace root: both apps and the shared
// `packages/ui` write/read from `examples/example-expo-monorepo/messages/`.
extract: {path: '../../messages'},
srcPath: ['./src', '../web/src', '../../packages/ui/src'],
messages: {
path: '../../messages',
format: 'po',
locales: ['en', 'de'],
sourceLocale: 'en',
precompile: true
}
}
});

module.exports = withExpoIntl(getDefaultConfig(__dirname));
37 changes: 37 additions & 0 deletions examples/example-expo-monorepo/apps/mobile/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"name": "mobile-app",
"version": "1.0.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"extract": "node ./scripts/extract-messages.mjs",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web"
},
"dependencies": {
"@example-monorepo/ui": "workspace:*",
"expo": "~55.0.24",
"expo-constants": "~55.0.16",
"expo-intl": "workspace:^",
"expo-linking": "~55.0.15",
"expo-router": "~55.0.14",
"expo-splash-screen": "~55.0.21",
"expo-status-bar": "~55.0.6",
"expo-system-ui": "~55.0.18",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-native": "0.83.6",
"react-native-gesture-handler": "~2.30.0",
"react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.23.0",
"react-native-web": "~0.21.0",
"use-intl": "workspace:^"
},
"devDependencies": {
"@types/react": "~19.2.2",
"intl-extractor": "workspace:^",
"typescript": "~5.9.2"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/usr/bin/env node
// Extracts into the shared workspace catalog. Scans this app's source plus
// the sibling web app and the shared `packages/ui` so a single run produces
// the full set of messages.
import {unstable_extractMessages} from 'intl-extractor';

await unstable_extractMessages({
extract: {path: '../../messages'},
srcPath: ['./src', '../web/src', '../../packages/ui/src'],
messages: {
path: '../../messages',
format: 'po',
locales: ['en', 'de'],
sourceLocale: 'en'
}
});

console.log('Extracted messages into ../../messages/{en,de}.po');
15 changes: 15 additions & 0 deletions examples/example-expo-monorepo/apps/mobile/src/app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {Stack} from 'expo-router';
import React from 'react';

import {IntlProviderShell} from '@/i18n/intl-provider-shell';
import {LocaleProvider} from '@/i18n/locale-context';

export default function RootLayout() {
return (
<LocaleProvider>
<IntlProviderShell>
<Stack screenOptions={{headerShown: false}} />
</IntlProviderShell>
</LocaleProvider>
);
}
66 changes: 66 additions & 0 deletions examples/example-expo-monorepo/apps/mobile/src/app/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import {useExtracted} from 'expo-intl';
import {Pressable, StyleSheet, Text, View} from 'react-native';
import {SafeAreaView} from 'react-native-safe-area-context';
import {Greeting, LocaleSwitcher} from '@example-monorepo/ui';

import {useAppLocale} from '@/i18n/locale-context';

export default function HomeScreen() {
const t = useExtracted('home');
const {locale, setLocale} = useAppLocale();

return (
<SafeAreaView style={styles.safe}>
<View style={styles.container}>
<Text style={styles.title}>{t('Mobile app — shared UI demo')}</Text>

<View style={styles.card}>
<Greeting
name="Hugo"
unreadCount={3}
Text={({children}) => <Text style={styles.body}>{children}</Text>}
Strong={({children}) => <Text style={styles.strong}>{children}</Text>}
/>
</View>

<View style={styles.switcher}>
<LocaleSwitcher
locale={locale}
setLocale={setLocale}
Label={({children}) => <Text style={styles.label}>{children}</Text>}
Button={({isActive, onPress, children}) => (
<Pressable
onPress={onPress}
style={[styles.chip, isActive && styles.chipActive]}>
<Text style={isActive ? styles.chipTextActive : styles.chipText}>
{children}
</Text>
</Pressable>
)}
/>
</View>
</View>
</SafeAreaView>
);
}

const styles = StyleSheet.create({
safe: {flex: 1, backgroundColor: '#0b1020'},
container: {flex: 1, padding: 24, gap: 20},
title: {color: '#fff', fontSize: 24, fontWeight: '600'},
card: {backgroundColor: '#1c2440', padding: 20, borderRadius: 12},
body: {color: '#dbe2ff', fontSize: 16, lineHeight: 24},
strong: {color: '#fff', fontWeight: '700'},
switcher: {flexDirection: 'row', alignItems: 'center', gap: 12, flexWrap: 'wrap'},
label: {color: '#aab4d4', fontSize: 14, marginRight: 4},
chip: {
paddingHorizontal: 14,
paddingVertical: 6,
borderRadius: 999,
borderWidth: 1,
borderColor: '#3a4673'
},
chipActive: {backgroundColor: '#3c87f7', borderColor: '#3c87f7'},
chipText: {color: '#dbe2ff', fontSize: 14},
chipTextActive: {color: '#fff', fontSize: 14, fontWeight: '600'}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {IntlProvider} from 'expo-intl';
import React, {useEffect, useState} from 'react';
import type {SharedLocale} from '@example-monorepo/ui';

import {useAppLocale} from './locale-context';

type MessageDictionary = Record<string, unknown>;

// Single shared catalog at the workspace root. Both apps and the shared
// `packages/ui` source live in the same .po file.
const loadMessages: Record<SharedLocale, () => Promise<{default: MessageDictionary}>> = {
en: () => import('../../../../messages/en.po'),
de: () => import('../../../../messages/de.po')
};

export function IntlProviderShell({children}: {readonly children: React.ReactNode}) {
const {locale} = useAppLocale();
const [messages, setMessages] = useState<MessageDictionary | null>(null);

useEffect(() => {
let cancelled = false;
loadMessages[locale]().then((mod) => {
if (!cancelled) setMessages(mod.default);
});
return () => {
cancelled = true;
};
}, [locale]);

if (!messages) return null;

return (
<IntlProvider locale={locale} messages={messages}>
{children}
</IntlProvider>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React, {createContext, useContext, useMemo, useState} from 'react';
import type {SharedLocale} from '@example-monorepo/ui';

interface LocaleContextValue {
readonly locale: SharedLocale;
readonly setLocale: (locale: SharedLocale) => void;
}

const LocaleContext = createContext<LocaleContextValue | null>(null);

export function LocaleProvider({
children,
initialLocale = 'en'
}: {
readonly children: React.ReactNode;
readonly initialLocale?: SharedLocale;
}) {
const [locale, setLocale] = useState<SharedLocale>(initialLocale);
const value = useMemo(() => ({locale, setLocale}), [locale]);
return <LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>;
}

export function useAppLocale(): LocaleContextValue {
const ctx = useContext(LocaleContext);
if (!ctx) {
throw new Error('useAppLocale must be used inside <LocaleProvider>');
}
return ctx;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
declare module '*.po' {
const messages: Record<string, unknown>;
export default messages;
}
17 changes: 17 additions & 0 deletions examples/example-expo-monorepo/apps/mobile/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
"expo-env.d.ts",
".expo/types/**/*.ts"
]
}
6 changes: 6 additions & 0 deletions examples/example-expo-monorepo/apps/web/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
25 changes: 25 additions & 0 deletions examples/example-expo-monorepo/apps/web/next.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type {NextConfig} from 'next';
import createNextIntlPlugin from 'next-intl/plugin';

const withNextIntl = createNextIntlPlugin({
experimental: {
// Single shared catalog at the workspace root: both apps and the shared
// `packages/ui` write/read from `examples/example-expo-monorepo/messages/`.
extract: {path: '../../messages'},
srcPath: ['./src', '../mobile/src', '../../packages/ui/src'],
messages: {
path: '../../messages',
format: 'po',
locales: ['en', 'de'],
sourceLocale: 'en',
precompile: true
}
}
});

const config: NextConfig = {
// `@example-monorepo/ui` ships TSX directly — let Next.js transpile it.
transpilePackages: ['@example-monorepo/ui']
};

export default withNextIntl(config);
Loading
Loading