Construire une UI Desktop pour alfaclub.app sans casser le mobile
Comment j'ai ajouté un shell desktop par-dessus une PWA Next.js 15 mobile-first existante. Route groups, parallel route slots, le cookie device-class et les décisions de slot qui ont réellement tenu.

Comment j'ai ajouté une UI desktop à alfaclub.app (anciennement Friendspace) par-dessus une PWA mobile-first existante. Stack : Next.js 15 App Router, React 19, Tailwind 4, primitives shadcn, Privy, Zustand, TanStack Query.
Vous préférez voir d'abord ? Sautez aux démos en direct en bas (visites mobile + desktop).
Pourquoi on fait ça
Au début, chaque appareil recevait le design mobile-first parce qu'on était contraint par le temps. Les utilisateurs desktop voyaient le layout mobile étiré. Rien de cassé, simplement pas un produit desktop. Les données de trafic ont ensuite montré qu'une part réelle des utilisateurs actifs ouvraient l'app depuis un laptop, et c'est ce qui a débloqué la décision de construire ça plutôt que de continuer à la repousser.
Les contraintes :
- Sous le breakpoint, rien ne change. Le shell mobile est extrait tel quel.
- Au-dessus du breakpoint, on rend une vraie UI desktop (seuil :
lg/ 1024px). Chrome différent, layout différent. - Même URL des deux côtés.
/rooms/abcouvre la même room peu importe.
Le CSS responsive ne comble pas ce fossé. Le shell est différent (icon rail + topbar + rails persistants vs top bar + bottom nav), les composants sont différents (une room de chat peut cohabiter avec un right rail persistant sur desktop qui n'existe pas sur mobile), et le contexte persistant que le desktop permet (un panel de liste de rooms qui reste visible quand on clique entre les rooms) n'est pas quelque chose qu'une media query peut produire.
Next.js fait gagner son investissement ici : l'App Router expose les parallel route slots comme une feature de première classe du système de fichiers, donc chaque rail devient un dossier @left / @right, SSR'd avec la page sans orchestration en plus. Ça enlève le plus gros coût d'implémentation.
Séparer en route groups, plutôt que de ramifier dans le root layout
Premier réflexe : lire le cookie dans le root layout, rendre le bon shell. Le problème est que l'App Router ne dit pas au root layout dans quel route group se trouve la requête en cours, donc il ne peut pas sauter le shell pour les écrans d'auth.
La solution est deux route groups possédant chacun leur layout :
app/
├── layout.tsx # providers, fonts, html/body. NO shell logic.
├── (shell)/
│ └── layout.tsx # picks DesktopShell vs MobileShell from a cookie
└── (full-page)/
└── layout.tsx # bare wrapper for login / invite / shareLe shell picker lui-même est petit :
// app/(shell)/layout.tsx
export default async function ShellLayout({ children, right, left }) {
const deviceClass = await getDeviceClass();
if (deviceClass !== "desktop") return <MobileShell>{children}</MobileShell>;
const defaultRightRailSize = await getRightRailSize();
return (
<DesktopShell
left={left}
right={right}
defaultRightRailSize={defaultRightRailSize}
>
{children}
</DesktopShell>
);
}right et left sont des parallel route slots ; plus de détails dans la section sur les mécanismes de slot plus bas.
Le cookie device-class
La device class doit être connue côté serveur avant que le premier byte ne parte, sinon l'utilisateur voit un flash du mauvais shell. Le navigateur n'envoie pas la largeur du viewport, donc on devine et on laisse le client corriger.
// src/lib/device-class.ts
export const DEVICE_CLASS_COOKIE = "device-class";
export const DEVICE_CLASS_OVERRIDE_COOKIE = "device-class-override";
export function detectDeviceClass(input: {
overrideValue?: string | null;
cookieValue?: string | null;
secChUaMobile?: string | null;
userAgent?: string | null;
}): DeviceClass {
if (input.overrideValue === "mobile" || input.overrideValue === "desktop") {
return input.overrideValue;
}
if (input.cookieValue === "mobile" || input.cookieValue === "desktop") {
return input.cookieValue;
}
if (input.secChUaMobile === "?1") return "mobile";
if (input.secChUaMobile === "?0") return "desktop";
const ua = input.userAgent ?? "";
if (/Mobi|iPhone|Android(?!.*Tablet)|webOS|BlackBerry|IEMobile/i.test(ua)) {
return "mobile";
}
return "desktop";
}L'ordre compte : override bat le cookie stocké, bat Sec-CH-UA-Mobile, bat le UA regex. Le middleware exécute ça sur chaque requête protégée et réécrit le cookie s'il a changé, donc les requêtes suivantes sautent le regex. Un URL param ?desktop=0|1|auto change l'override et c'est l'échappatoire que les gens utilisent vraiment (l'iPad en mode portrait se déclare comme Macintosh et se fait mal classer ; l'override est le fix jusqu'à ce que je câble Sec-CH-UA-Platform).
Deux mécanismes de slot, volontairement : parallel routes pour les rails, context pour le topbar
Les rails par route (/rooms -> liste de rooms + rail d'info de room, /explore -> rail de trending, etc.) ont besoin de SSR. Les pièces du topbar non. Donc ils utilisent des mécanismes différents :
- Rails (
@left,@right) sont des parallel route slots Next.js. Possédés par le système de fichiers, SSR'd avec la page. - Pièces du topbar (
topbarTitle,topbarEyebrow,topbarRight) passent par un slot context (DesktopSlotsProvider+useDesktopSlot).
L'arbre de fichiers des rails :
app/(shell)/
├── @left/
│ ├── [...catchAll]/page.tsx # returns null
│ ├── default.tsx
│ └── rooms/...
├── @right/
│ ├── [...catchAll]/page.tsx # returns null
│ ├── default.tsx
│ ├── explore/page.tsx
│ ├── leaderboard/page.tsx
│ └── rooms/[roomId]/page.tsx
└── default.tsxTrois choses qui m'ont mordu, par ordre de temps perdu :
Les soft navigations ont besoin d'un sibling [...catchAll] qui retourne null. Sans lui, naviguer /rooms/123 -> /explore garde le slot précédent.
Les hard navigations ont besoin de default.tsx à chaque niveau, à la fois pour le slot (@right/default.tsx) et pour les implicit children ((shell)/default.tsx). Manquer fait throw en hard nav.
Ne conditionnez pas la visibilité du rail sur useSelectedLayoutSegment("right"). Le sibling [...catchAll] gagne parfois le segment lookup contre une page dynamique imbriquée. Utilisez pathname à la place :
// src/lib/right-rail-routes.ts
const ROOT_SEGMENTS_WITH_RAIL: ReadonlySet<string> = new Set([
"explore",
"leaderboard",
]);
const ROOMS_RESERVED_CHILD_SEGMENTS: ReadonlySet<string> = new Set(["create"]);
export function hasRightRailForPath(pathname: string): boolean {
const [top, child] = pathname.split("/").filter(Boolean);
if (!top) return false;
if (ROOT_SEGMENTS_WITH_RAIL.has(top)) return true;
if (top === "rooms")
return child != null && !ROOMS_RESERVED_CHILD_SEGMENTS.has(child);
return false;
}Le right rail est en pourcentage, pas en pixels
Rail redimensionnable, largeur persistée. L'écriture du cookie vit dans le callback de resize :
const persistRightRailSize = useCallback((layout: Layout) => {
const size = layout[RIGHT_RAIL_PANEL_ID];
if (typeof size !== "number" || !Number.isFinite(size)) return;
document.cookie = `${RIGHT_RAIL_COOKIE}=${size.toFixed(2)}; path=/; max-age=${RIGHT_RAIL_COOKIE_MAX_AGE}; samesite=lax`;
}, []);
const handleLayoutChanged = useDebounceCallback(persistRightRailSize, 300);Cookies, pas localStorage : le serveur a besoin de la valeur pendant le rendu initial pour que le rail sorte du SSR à la bonne largeur.
Un détail qui m'a coûté une heure : dans react-resizable-panels v4, les nombres bruts passés à minSize/maxSize/defaultSize sont interprétés comme des pixels. Pour des pourcentages, passez des strings :
<ResizablePanel
id={RIGHT_RAIL_PANEL_ID}
defaultSize={`${defaultRightRailSize}%`}
minSize={`${RIGHT_RAIL_MIN_PCT}%`}
maxSize={`${RIGHT_RAIL_MAX_PCT}%`}
/>Le left rail n'est pas redimensionnable ; c'est une colonne à largeur fixe avec des états expanded/collapsed depuis un store Zustand. Le garder à l'intérieur du main panel du groupe redimensionnable (au lieu d'en faire son propre group panel) garde stables les maths de pourcentage du right rail quand le left collapse.
Pattern de slot du topbar
Le hook est petit :
export type DesktopSlotName = "topbarTitle" | "topbarEyebrow" | "topbarRight";
export function useDesktopSlot(name: DesktopSlotName, node: ReactNode | null) {
const setSlot = useContext(DesktopSlotsContext)?.setSlot;
useEffect(() => {
if (!setSlot) return;
setSlot(name, node);
return () => setSlot(name, null);
}, [setSlot, name, node]);
}Deux choses volontaires :
- No-op sur mobile. Le context est
nullhors deDesktopShell, donc le call site n'a pas à brancher selon le device. - Mémoïser
nodeau call site. Sinon un élément React frais à chaque rendu boucle l'effet (setSlot-> re-render -> nouvelle identité -> l'effet refire).
La palette de recherche desktop
Cmd+K est un dialog de rechercher-et-sauter (rooms + users), pas un command palette générique. Construit sur Dialog + Tabs + InputGroup ; on n'a pas tiré le Command de shadcn pour ce périmètre.
// src/components/search/desktop-search.tsx (excerpt)
useHotkeys(
"mod+k",
(e) => {
e.preventDefault();
setOpen((v) => !v);
},
{ enableOnFormTags: true, enableOnContentEditable: true }
);
const [debouncedInput] = useDebounce(input, DEBOUNCE_MS);enableOnFormTags/enableOnContentEditable sont explicitement à true : par défaut react-hotkeys-hook ne déclencherait pas quand le focus est dans le composer de chat, et Cmd+K devrait quand même ouvrir la palette là. Le modificateur signale l'intention.
Quand l'input est vide, le dialog affiche une liste de nav récente depuis un store Zustand (useRecentNavStore) pour qu'une ouverture fraîche ne soit pas un écran blanc.
Politique de réutilisation de composants
Le desktop est du chrome neuf autour des composants de feature existants. Le chat thread, la message bubble, la ligne du leaderboard, l'item du feed explore sont réutilisés tels quels. RoomsListPanel compose les mêmes building blocks ; RoomRightRail est une pile de petits section components sous components/desktop/rooms/room-right-rail/.
Règle de décision pour "variante ou nouveau composant" :
- Petit delta -> ajouter une variante avec un force mode. Si desktop diffère de mobile uniquement en spacing, densité, ou un sous-composant échangé, ajoutez une
variantaucvadu composant partagé, ou un propdensity/compact. La partie importante est le force mode : les callers doivent pouvoir passerdensity="compact"explicitement pour avoir le look compact même quand l'auto-détection ne le ferait pas. Cet échappatoire permet de rendre le même composant dans un right rail desktop étroit OU dans un drawer mobile plus large sans que personne ne doive forker. - Layout réellement différent -> nouveau composant dans
components/desktop/<feature>/. Empty state différent, sections réordonnées, forme de données différente, modèle mental différent. Les composants de feature partagés restent la source de vérité du comportement ; le fichier desktop les compose.
Autre règle dure : installez les primitives shadcn manquantes via la CLI, jamais à la main. La Phase 0 a exécuté npx shadcn@latest add sidebar kbd avatar breadcrumb sheet. Les primitives faites main dérivent.
Rollout et observabilité
Pas un cutover. Deux échappatoires laissent un utilisateur retomber sur le shell mobile immédiatement :
- L'URL param
?desktop=0|1|autoécrit le cookie d'override et recharge. - Le cookie d'override lui-même, aussi écrit depuis un toggle "Forcer vue desktop/mobile" dans les paramètres. Persiste entre sessions.
L'override est checké en premier dans detectDeviceClass, avant tout le reste, c'est ce qui en fait un échappatoire fiable.
L'observabilité tient en une ligne :
// src/app/providers.tsx
Sentry.setTag("shell", deviceClass);Chaque erreur et perf event Sentry qu'on collecte déjà se segmente maintenant par shell. Si les erreurs shell=desktop montent pendant que shell=mobile reste plat, c'est le signal de revert. Les hydration mismatches passent par l'intégration standard Next.js + Sentry. Rien d'autre n'a été ajouté ; c'est un changement de couche de présentation et la couverture existante capture les vrais bugs.
Notes de clôture
Ce qui a tenu :
- Deux shells, séparés par un cookie
device-classà(shell)/layout.tsx. Les pages restent agnostiques. - Parallel route slots (
@left,@right) pour les rails SSR'd ; slot context pour les pièces du topbar côté client uniquement. - Visibilité du rail depuis pathname, pas depuis
useSelectedLayoutSegment. - Largeur du right rail en pourcentage dans un cookie pour que le SSR rende à la largeur choisie.
- Les composants mobile sont la source de vérité ; les forks desktop vivent sous
components/desktop/<feature>/. - Un tag Sentry (
shell) est toute l'ajout d'observabilité.
Si vous faites quelque chose de similaire sur Next.js App Router, les deux fichiers à lire en premier sont app/(shell)/layout.tsx (le shell picker) et src/lib/device-class.ts (le cookie + la détection). Tout le reste découle de ces deux choix.
Démos en direct
À quoi ressemblent les deux shells en usage, ou essayez en direct sur alfaclub.app.
AlfaClub version mobile
AlfaClub version desktop