Back

Construyendo una UI Desktop para alfaclub.app sin romper la versión móvil

Cómo añadí un shell desktop sobre una PWA Next.js 15 mobile-first existente. Route groups, parallel route slots, la cookie device-class y las decisiones de slot que realmente funcionaron.

Shell desktop de Alfa Club

Cómo añadí una UI desktop a alfaclub.app (antes Friendspace) sobre una PWA mobile-first existente. Stack: Next.js 15 App Router, React 19, Tailwind 4, primitives de shadcn, Privy, Zustand, TanStack Query.

¿Prefieres verlo primero? Salta a las demos en vivo al final (recorridos mobile + desktop).


Por qué hacemos esto

Al principio, todos los dispositivos recibían el diseño mobile-first porque estábamos limitados de tiempo. Los usuarios desktop veían el layout móvil estirado. Nada roto, simplemente no era un producto desktop. Después los datos de tráfico mostraron que una parte real de los usuarios activos abrían la app desde laptops, y eso fue lo que desbloqueó la decisión de construir esto en lugar de seguir aplazándolo.

Las restricciones:

  • Por debajo del breakpoint, nada cambia. El shell móvil se extrae tal cual.
  • Por encima del breakpoint, renderizamos una UI desktop real (umbral: lg / 1024px). Chrome diferente, layout diferente.
  • La misma URL en ambos. /rooms/abc abre la misma sala en cualquier lado.

El CSS responsive no cubre esta brecha. El shell es diferente (icon rail + topbar + rails persistentes vs top bar + bottom nav), los componentes son diferentes (una sala de chat puede convivir con un right rail persistente en desktop que no existe en mobile), y el contexto persistente que el desktop permite (un panel de lista de salas que sigue visible mientras navegas entre salas) no es algo que una media query pueda producir.

Next.js demuestra su valor aquí: el App Router expone los parallel route slots como una feature de primera clase del sistema de archivos, así cada rail se vuelve una carpeta @left / @right, SSR'd junto con la página sin orquestación adicional. Eso quita el mayor costo de implementación.


Separar en route groups, no ramificar en el root layout

Primer instinto: leer la cookie en el root layout y renderizar el shell correcto. El problema es que el App Router no le dice al root layout en qué route group está la request actual, así que no puede saltarse el shell para las pantallas de auth.

La solución son dos route groups con su propio 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 / share

El shell picker en sí es pequeño:

// 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 y left son parallel route slots; más detalle en la sección de mecanismos de slot más abajo.


La device class tiene que ser conocida en el servidor antes de que salga el primer byte, si no el usuario ve un flash del shell incorrecto. El navegador no envía el ancho del viewport, así que adivinamos y dejamos que el cliente corrija.

// 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";
}

El orden importa: override gana a la cookie almacenada, gana a Sec-CH-UA-Mobile, gana al UA regex. El middleware corre esto en cada request protegida y reescribe la cookie si cambió, así las siguientes requests se saltan el regex. Un URL param ?desktop=0|1|auto cambia el override y es el escape hatch que la gente realmente usa (el iPad en vertical reporta como Macintosh y se clasifica mal; el override es el fix hasta que cablee Sec-CH-UA-Platform).


Dos mecanismos de slot, a propósito: parallel routes para rails, context para topbar

Los rails por ruta (/rooms -> lista de salas + rail de info de sala, /explore -> rail de trending, etc.) necesitan SSR. Las piezas del topbar no. Así que usan mecanismos distintos:

  • Rails (@left, @right) son parallel route slots de Next.js. Pertenecen al sistema de archivos, SSR'd junto con la página.
  • Piezas del topbar (topbarTitle, topbarEyebrow, topbarRight) pasan por un slot context (DesktopSlotsProvider + useDesktopSlot).

El árbol de archivos de los 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.tsx

Tres detalles que me mordieron, en orden de cuánto me costaron:

Las soft navigations necesitan un sibling [...catchAll] que devuelva null. Sin él, navegar /rooms/123 -> /explore deja el slot anterior.

Las hard navigations necesitan default.tsx en cada nivel, tanto en el slot (@right/default.tsx) como en los implicit children ((shell)/default.tsx). Faltar uno tira error en hard nav.

No condiciones la visibilidad del rail con useSelectedLayoutSegment("right"). El sibling [...catchAll] a veces gana el segment lookup contra una página dinámica anidada. Usa pathname:

// 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;
}

El right rail va en porcentaje, no en píxeles

Rail resizable, ancho persistido. La escritura de cookie va en el 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, no localStorage: el servidor necesita el valor en el render inicial para que el rail salga del SSR al ancho correcto.

Un detalle que me costó una hora: en react-resizable-panels v4, los números pelados pasados a minSize/maxSize/defaultSize se interpretan como píxeles. Para porcentajes, hay que pasar strings:

<ResizablePanel
  id={RIGHT_RAIL_PANEL_ID}
  defaultSize={`${defaultRightRailSize}%`}
  minSize={`${RIGHT_RAIL_MIN_PCT}%`}
  maxSize={`${RIGHT_RAIL_MAX_PCT}%`}
/>

El left rail no es resizable; es una columna de ancho fijo con estados expanded/collapsed desde un store Zustand. Mantenerlo dentro del main panel del grupo resizable (en lugar de hacerlo su propio group panel) mantiene estables las matemáticas de porcentaje del right rail cuando el left colapsa.


Patrón de slot del topbar

El hook es pequeño:

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]);
}

Dos cosas a propósito:

  • No-op en mobile. El context es null fuera de DesktopShell, así el call site no tiene que ramificar por dispositivo.
  • Memoizar node en el call site. Si no, un React element fresco en cada render loopea el efecto (setSlot -> re-render -> nueva identidad -> el efecto vuelve a disparar).

El search palette de desktop

Cmd+K es un dialog de buscar-y-saltar (rooms + users), no un command palette genérico. Construido sobre Dialog + Tabs + InputGroup; no metimos el Command de shadcn para este alcance.

// 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 están explícitamente en true: por defecto react-hotkeys-hook no dispararía cuando el foco está en el composer de chat, y Cmd+K debería abrir el palette igual ahí. El modificador señala la intención.

Cuando el input está vacío, el dialog muestra una lista de nav reciente desde un store Zustand (useRecentNavStore) para que abrir no sea una pantalla en blanco.


Política de reutilización de componentes

El desktop es chrome nuevo alrededor de los componentes de feature existentes. El chat thread, el message bubble, la fila del leaderboard, el item del feed de explore se reutilizan tal cual. RoomsListPanel compone los mismos building blocks; RoomRightRail es una pila de pequeños section components bajo components/desktop/rooms/room-right-rail/.

Regla de decisión para "variante o componente nuevo":

  • Delta pequeño -> añadir una variante con force mode. Si desktop difiere de mobile solo en spacing, densidad, o un subcomponent intercambiado, añade una variant al cva del componente compartido, o un prop density/compact. La parte importante es el force mode: los callers deben poder pasar density="compact" explícitamente para obtener el look compacto incluso cuando la auto-detección no lo haría. Ese escape hatch es lo que permite renderizar el mismo componente en un right rail estrecho de desktop O en un drawer más ancho de mobile sin que nadie tenga que forkearlo.
  • Layout genuinamente diferente -> componente nuevo en components/desktop/<feature>/. Empty state distinto, secciones reordenadas, forma de datos distinta, modelo mental distinto. Los componentes de feature compartidos siguen siendo la fuente de la verdad del comportamiento; el archivo desktop los compone.

Otra regla dura: instala los primitives de shadcn que falten con la CLI, nunca a mano. La Phase 0 corrió npx shadcn@latest add sidebar kbd avatar breadcrumb sheet. Los primitives a mano divergen con el tiempo.


Rollout y observabilidad

No es un cutover. Hay dos escape hatches que permiten a un usuario caer de vuelta al shell móvil al instante:

  1. URL param ?desktop=0|1|auto escribe la cookie de override y recarga.
  2. La cookie de override misma, también escribible desde un toggle "Forzar vista desktop/mobile" en settings. Persiste entre sesiones.

El override se chequea primero dentro de detectDeviceClass, antes que todo lo demás, eso es lo que lo hace un escape confiable.

La observabilidad es una línea:

// src/app/providers.tsx
Sentry.setTag("shell", deviceClass);

Cada error y perf event de Sentry que ya recogemos ahora se segmenta por shell. Si los errores en shell=desktop suben mientras shell=mobile se mantiene plano, ese es el signal para revertir. Los hydration mismatches llegan por la integración estándar Next.js + Sentry. Nada más se añadió; este es un cambio de capa de presentación y la cobertura existente captura los bugs reales.


Notas finales

Lo que sobrevivió:

  • Dos shells, separados por una cookie device-class en (shell)/layout.tsx. Las páginas se mantienen agnósticas.
  • Parallel route slots (@left, @right) para rails SSR'd; slot context para piezas del topbar solo client.
  • Visibilidad del rail desde pathname, no desde useSelectedLayoutSegment.
  • Ancho del right rail como porcentaje en una cookie para que SSR renderice al ancho elegido.
  • Los componentes mobile son la fuente de la verdad; los forks desktop viven bajo components/desktop/<feature>/.
  • Un tag de Sentry (shell) es toda la adición de observabilidad.

Si haces algo similar en Next.js App Router, los dos archivos para leer primero son app/(shell)/layout.tsx (el shell picker) y src/lib/device-class.ts (la cookie + detección). Todo lo demás es downstream de esas dos decisiones.


Demos en vivo

Cómo se ven los dos shells en uso, o pruébalo en vivo en alfaclub.app.

AlfaClub versión mobile

AlfaClub versión desktop

Construyendo una UI Desktop para alfaclub.app sin romper la versión móvil Lou1s