Tag: NextJS

  • Arquitectura moderna Microfrontends: Caso real TCG Pocket – II

    Arquitectura moderna Microfrontends: Caso real TCG Pocket – II

    Ahora comenzamos la parte práctica del proyecto:

    ✅ Creamos el monorepo con Nx.
    ✅ Generamos la app shell, que servirá de host para los microfrontends.
    ✅ Configuramos next-i18next para traducción en español e inglés.
    ✅ Verificamos que el servidor funcione localmente y muestra el texto traducido correctamente.

    Paso 1: Crear el monorepo con Nx

    Usaremos Nx por su robustez y soporte para múltiples frameworks (Next.js, NestJS, React, etc.).

    npx create-nx-workspace@latest tcg-pocket --preset=apps --package-manager=npm --workspaceType=integrated

    Explicación:

    • tcg-pocket: nombre del workspace.

    • --preset=apps: crearemos un monorepo sin proyectos iniciales.

    • --package-manager=npm: usaremos npm (puedes usar pnpm o yarn si prefieres).

     

    Paso 2: Instalar plugins para Next.js y NestJS

    npm install --save-dev @nx/next @nx/nest
    • @nx/next: para apps Next.js.

    • @nx/nest: para el backend BFF con NestJS.

    Paso 3: Generar la aplicación shell (Next.js)

    npx nx g @nx/next:app apps/shell --style=tailwind

    Esto crea:

    /apps/shell

    Con todos los archivos de un proyecto Next.js básico.

    Paso 4: Añadir soporte para next-i18next

    Instalamos la librería:

    npm install next-i18next
    

    Creamos la configuración base de i18n en la raíz del shell:

    // next-i18next.config.js
    module.exports = {
      i18n: {
        defaultLocale: 'es',
        locales: ['es', 'en'],
      },
    };
    

    Paso 5: Configurar Next.js para usar i18n

    Edita /apps/shell/next.config.js:

    //@ts-check
    
     
    const { composePlugins, withNx } = require('@nx/next');
    const { i18n } = require('./next-i18next.config');
    
    /**
     * @type {import('@nx/next/plugins/with-nx').WithNxOptions}
     **/
    const nextConfig = {
      reactStrictMode: true,
      i18n,
      nx: {},
    };
    
    const plugins = [
      withNx,
    ];
    
    module.exports = composePlugins(...plugins)(nextConfig);
    
    

    Paso 6: Crear archivos de traducción

    Creamos carpetas de traducción dentro de la app shell:

    /apps/shell/public/locales
      /es
        common.json
      /en
        common.json
    

    Ejemplo de /apps/shell/public/locales/es/common.json:

    {
      "title": "Bienvenido a TCG Pocket Explorer",
      "description": "Explora cartas de Pokémon TCG Pocket con arquitectura moderna"
    }

    Y para /en/common.json:

    {
      "title": "Welcome to TCG Pocket Explorer",
      "description": "Explore Pokémon TCG Pocket cards with modern architecture"
    }
    

    Paso 7: Usar traducción en la app

    Edita /apps/shell/pages/index.tsx:

    import { useTranslation } from 'next-i18next';
    import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
    
    export default function Home() {
      const { t } = useTranslation('common');
    
      return (
        <main className="flex flex-col items-center justify-center min-h-screen">
          <h1 className="text-3xl font-bold">{t('title')}</h1>
          <p className="mt-2 text-gray-600">{t('description')}</p>
        </main>
      );
    }
    
    // Para que Next.js cargue las traducciones en SSR:
    export async function getStaticProps({ locale }: { locale: string }) {
      return {
        props: {
          ...(await serverSideTranslations(locale, ['common'])),
        },
      };
    }
    

    Editar el archivo pages/_app.tsx:

    import { AppProps } from 'next/app';
    import Head from 'next/head';
    import './styles.css';
    import { appWithTranslation } from 'next-i18next';
    
    function CustomApp({ Component, pageProps }: AppProps) {
      return (
        <>
          <Head>
            <title>Welcome to shell!</title>
          </Head>
          <main className="app">
            <Component {...pageProps} />
          </main>
        </>
      );
    }
    
    export default appWithTranslation(CustomApp);

    Esto nos permitirá usar la traducción en toda la web.

    Paso 8: Levantar el servidor y probar

    Arrancamos la app shell:

    nx run shell:dev

    Esto corre en http://localhost:3000.

    Probar el cambio de idioma

    Para ver cómo funciona la traducción:

    • Accede a http://localhost:3000/es → ves la versión en español.

    • Accede a http://localhost:3000/en → ves la versión en inglés.

    ¡La traducción ya está activa y funcionando!

    ¿Qué logramos?

    ✅ Monorepo base creado y estructurado con Nx.
    ✅ App shell Next.js lista como host de microfrontends.
    ✅ Configuración de i18n con next-i18next y traducciones en español e inglés.
    ✅ Servidor corriendo y funcionando correctamente.

    ¿Qué sigue?

    En el próximo post:

    • Agregaremos los microfrontends sets y cards.

    • Configuraremos Module Federation para que el shell cargue sus módulos remotos.

    • Documentaremos la configuración y primeras integraciones.

  • Arquitectura moderna con microfrontends, monorepo y BFF: Caso real TCG Pocket

    Arquitectura moderna con microfrontends, monorepo y BFF: Caso real TCG Pocket

    Introducción

    En este proyecto construiremos una aplicación real usando datos de cartas del juego Pokémon TCG Pocket, siguiendo una arquitectura moderna basada en:

    • Microfrontends con Next.js y Module Federation

    • Un backend centralizado tipo BFF con NestJS

    • Almacenamiento de imágenes en AWS S3

    • Infraestructura gestionada con GitHub Actions

    • Seguridad, observabilidad, estado compartido y CI/CD listos para producción

    Objetivo del proyecto

    Crear una app web escalable y mantenible, explorando sets y cartas del juego TCG Pocket, aplicando buenas prácticas reales de arquitectura frontend y backend.

    Arquitectura del sistema

    Tecnologías principales:

    Capa Tecnología
    Frontend Next.js + Module Federation
    Estado global Zustand + BroadcastChannel
    Backend BFF NestJS
    Almacenamiento AWS S3 (para imágenes)
    CI/CD GitHub Actions
    Observabilidad Sentry + logs estructurados

    Estructura del monorepo:

    /apps
      /shell            → MFE host (layout, navegación, login)
      /sets             → Microfrontend: exploración de sets
      /cards            → Microfrontend: detalle de cartas
      /admin            → (opcional) manejo de imágenes u orden
      /bff              → Backend For Frontend con NestJS
    
    /libs
      /shared           → Tipos TypeScript, helpers, componentes comunes
      /ui               → Design system / componentes compartidos
    

    Microservicios y contratos

    Usaremos solo un backend (BFF) centralizado que expone:

    Método Ruta Descripción
    GET /sets Lista todos los sets
    GET /sets/:setCode Detalle y cartas de un set
    GET /sets/:setCode/cards/:cardId Detalle completo de una carta específica

    Los datos serán cargados previamente en una base de datos, a partir del scraping ya realizado. El backend BFF accederá a esta base para servir los endpoints públicos de sets y cartas.

    Autenticación (más adelante)

    • OAuth con Google en el frontend

    • Validación en el BFF

    • Emisión de JWT propio

    • Uso de HttpOnly cookie o Authorization header

     

    Almacenamiento y deployment

    • Imágenes ya scrapeadas → se almacenarán en AWS S3 y servidas vía CloudFront

    • Microfrontends: deploy en Vercel

    • Backend: deploy en Render o AWS ECS

    • CI/CD: GitHub Actions con despliegues preview por rama

     

    Observabilidad y métricas

    • Sentry para capturar errores frontend y backend

    • Pino o Winston para logs estructurados en BFF

    • (Opcional) Prometheus/Grafana o Datadog para trazas y rendimiento

     

    Fases del proyecto (posts futuros)

    1. Setup del monorepo con Nx o Turborepo + estructura base

    2. Implementación del shell (layout principal)

    3. Microfrontend de sets

    4. Microfrontend de cartas

    5. Backend BFF con NestJS + rutas

    6. Integración de datos y despliegue del API

    7. Estado global compartido (Zustand + BroadcastChannel)

    8. AWS S3: servir imágenes scrapeadas

    9. OAuth con Google + JWT + roles

    10. CI/CD con GitHub Actions

    11. Observabilidad con Sentry y logs

    12. Extras: favoritos, versión offline (PWA), etc.

     

    Cierre

    Este será el inicio de una serie completa donde aplicamos todas las decisiones arquitectónicas modernas en un proyecto real, usable y extensible. En el próximo post crearemos el monorepo, el shell de layout y comenzaremos a conectar los microfrontends.

  • Manejo de estado global con Zustand en Next.js – Login y persistencia

    Manejo de estado global con Zustand en Next.js – Login y persistencia

    Zustand es una librería de manejo de estado global para React, ligera y muy poderosa. En este tutorial vamos a integrarla en un proyecto con Next.js (App Router) para:

    • Simular un login con un endpoint mock

    • Guardar y persistir los datos del usuario

    • Proteger rutas

    • Mostrar la información del usuario en un Header

    • Pasar datos al componente Slider

    Vamos a trabajar directamente sobre un proyecto Next.js ya creado, usando la estructura con app, components, stores, etc.

    Estructura base

    Nuestro árbol de carpetas relevante será:

    src/
    ├── app/
    │   ├── page.tsx         // Login (index)
    │   └── dashboard/
    │       └── page.tsx     // Página interna
    ├── components/
    │   ├── Header.tsx
    │   └── Slider.tsx
    ├── hooks/
    │   └── useUserClient.ts
    ├── mocks/
    │   └── user.ts
    └── stores/
        └── userStore.ts
    

     

    Instalando Zustand

    Si aún no lo tienes instalado:

    npm install zustand

    Y para persistencia:

    npm install zustand/middleware

    1. Mock del endpoint de login

    Creamos un login simulado en src/mocks/user.ts:

    export async function mockLogin(username: string, password: string) {
      await new Promise((r) => setTimeout(r, 500));
    
      if (username === "admin" && password === "1234") {
        return {
          id: 1,
          name: "Luis Velito",
          email: "admin@example.com",
          role: "admin",
          sliderValue: 60,
          sliderNegative: true,
          steps: 10,
          showTooltip: "always",
        };
      }
    
      throw new Error("Invalid credentials");
    }
    

    2. Zustand store con persistencia

    Creamos el store en src/stores/userStore.ts:

    import { create } from "zustand";
    import { persist } from "zustand/middleware";
    
    interface User {
      id: number;
      name: string;
      email: string;
      role: string;
      sliderValue: number;
      sliderNegative: boolean;
      steps: number;
      showTooltip: string;
    }
    
    interface UserStore {
      user: User | null;
      login: (data: User) => void;
      logout: () => void;
    }
    
    export const useUserStore = create<UserStore>()(
      persist(
        (set) => ({
          user: null,
          login: (data) => set({ user: data }),
          logout: () => set({ user: null }),
        }),
        {
          name: "user-storage", // clave en localStorage
        }
      )
    );
    

    3. Hook useUserClient para manejar hidratación

    En Next.js con SSR, hay que esperar a que Zustand se hidrate. Creamos un hook en src/hooks/useUserClient.ts:

    "use client";
    
    import { useEffect, useState } from "react";
    import { useUserStore } from "@/stores/userStore";
    
    export function useUserClient() {
      const user = useUserStore((s) => s.user);
      const [isHydrated, setIsHydrated] = useState(false);
    
      useEffect(() => {
        setIsHydrated(true);
      }, []);
    
      return { user, isHydrated };
    }
    

    4. Página de login (/)

    Usamos el mock de login y guardamos la sesión con Zustand:

    // src/app/page.tsx
    "use client";
    
    import { useEffect, useState } from "react";
    import { useRouter } from "next/navigation";
    import { mockLogin } from "@/mocks/user";
    import { useUserStore } from "@/stores/userStore";
    import { useUserClient } from "@/hooks/useUserClient";
    
    export default function LoginPage() {
      const router = useRouter();
      const login = useUserStore((s) => s.login);
      const { user } = useUserClient();
    
      const [username, setUsername] = useState("");
      const [password, setPassword] = useState("");
      const [error, setError] = useState("");
    
      useEffect(() => {
        if (user) router.push("/dashboard");
      }, [user]);
    
      const handleLogin = async () => {
        try {
          const loggedUser = await mockLogin(username, password);
          login(loggedUser);
        } catch (err) {
          setError("Credenciales inválidas: " + err);
        }
      };
    
      return (
        <div className="flex items-center justify-center min-h-screen min-w-screen bg-gray-200">
          <div className="bg-white p-10 rounded-lg shadow-md text-gray-700">
            <h1 className="text-2xl font-bold mb-4">Iniciar sesión</h1>
            <input
              className="border p-2 w-full mb-2"
              placeholder="Usuario"
              value={username}
              onChange={(e) => setUsername(e.target.value)}
            />
            <input
              className="border p-2 w-full mb-4"
              type="password"
              placeholder="Contraseña"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
            />
            <button className="bg-black text-white px-4 py-2" onClick={handleLogin}>
              Entrar
            </button>
            {error && <p className="text-red-600 mt-2">{error}</p>}
          </div>
        </div>
      );
    }
    

    5. Página interna /dashboard

    Protegida, con redirección si no hay sesión:

    // src/app/dashboard/page.tsx
    "use client";
    
    import Header from "@/components/Header";
    import Slider from "@/components/Slider";
    import { useUserClient } from "@/hooks/useUserClient";
    
    export default function DashboardPage() {
      const { user, isHydrated } = useUserClient();
    
      if (!isHydrated) return null;
      if (!user) return null;
    
      return (
        <div className="min-h-screen min-w-screen bg-gray-200">
          <Header />
          <div className="flex items-center justify-center container mx-auto p-4">
            <div className="bg-white p-8 rounded-lg shadow-md min-w-[600px]">
              <h2 className="text-xl font-semibold mb-12 text-gray-700">Slider personalizado</h2>
              <Slider
                value={user.sliderValue}
                negative={user.sliderNegative}
                steps={user.steps}
                showTooltip={user.showTooltip as "always" | "onInteraction" | undefined}
              />
            </div>
          </div>
        </div>
      );
    }
    

    5. Componente Header con cierre de sesión

    Código del componente header:

    // src/components/Header.tsx
    "use client";
    
    import { useUserClient } from "@/hooks/useUserClient";
    import { useUserStore } from "@/stores/userStore";
    import { useRouter } from "next/navigation";
    import { useEffect } from "react";
    
    export default function Header() {
      const { user, isHydrated } = useUserClient();
      const logout = useUserStore((s) => s.logout);
      const router = useRouter();
    
      useEffect(() => {
        if (isHydrated && !user) {
          router.push("/");
        }
      }, [user, isHydrated]);
    
      if (!isHydrated || !user) return null;
    
      return (
        <header className="flex justify-between items-center p-4 bg-gray-100 rounded-md shadow">
          <div>
            <p className="text-sm text-gray-700">Bienvenido,</p>
            <p className="font-bold text-gray-900 text-lg">{user.name}</p>
          </div>
          <div className="flex flex-col items-end">
            <p className="text-sm text-gray-600">{user.email}</p>
            <p
              className="mt-4 text-red-500 cursor-pointer text-xs hover:text-red-600 uppercase"
              onClick={() => {
                logout();
                router.push("/");
              }}
            >
              Cerrar sesión
            </p>
          </div>
        </header>
      );
    }
    

    Resultado final

    • ✅ Login simulado con Zustand

    • ✅ Datos persistentes entre recargas

    • ✅ Página protegida con redirección

    • ✅ Header con sesión e información

    • ✅ Slider dinámico con props desde el store

    Conclusión

    Zustand es ideal para manejar sesiones de usuario en aplicaciones React y Next.js, sin el peso de soluciones más complejas. Con persist, una estructura clara y algunos hooks de cliente, puedes construir experiencias sólidas y modernas fácilmente.

  • Por qué deberías usar actualizaciones funcionales con setState en React

    Por qué deberías usar actualizaciones funcionales con setState en React

    En React, cuando necesitas actualizar un estado basado en su valor anterior — como en casos reales de gestión de un carrito de compras o una lista de tareas — es fundamental usar la forma funcional de setState para evitar errores sutiles causados por estados desactualizados.

    React agrupa (batch) las actualizaciones de estado para optimizar el rendimiento.
    Si llamas a setState varias veces dentro del mismo ciclo de evento sin usar la forma funcional, es probable que cada llamada trabaje sobre una versión antigua del estado.

    Ejemplo 1: Agregar productos a un carrito

    Incorrecto:

    setCart([...cart, newItem]);

    Correcto:

    setCart(prevCart => [...prevCart, newItem]);

    Ejemplo 2: Incrementar contadores

    Incorrecto:

    setQuantity(quantity + 1);
    setQuantity(quantity + 1);

    Esperado: +2
    Realidad: +1

    Correcto:

    setQuantity(prevQuantity => prevQuantity + 1);
    setQuantity(prevQuantity => prevQuantity + 1);

    ¿Por qué importa?
    En aplicaciones reales — especialmente cuando manejas actualizaciones concurrentes, patrones de UI optimista o interacciones de alta frecuencia — no usar la forma funcional puede provocar pérdida de datos, estados inconsistentes y bugs muy difíciles de rastrear.

    La actualización funcional garantiza que setState siempre trabaje sobre el estado más reciente y correcto, independientemente de las renderizaciones o del agrupamiento de actualizaciones.

    Regla de oro:
    Si tu nuevo estado depende del anterior, siempre usa la forma funcional.

  • Configura Next.js + TailwindCSS + TypeScript + Storybook

    Configura Next.js + TailwindCSS + TypeScript + Storybook

    En este tutorial aprenderás a:

    • Crear un proyecto con Next.js + TypeScript.
    • Integrar TailwindCSS para estilos rápidos y potentes.
    • Instalar Storybook para documentar y testear componentes UI.
    • Hacer que TailwindCSS funcione dentro de Storybook.

    1. Crear el Proyecto Next.js con TypeScript

    Primero, genera un nuevo proyecto Next.js usando TypeScript:

    npx create-next-app@latest my-next-project --typescript
    cd my-next-project

    Te va a dar opciones a escoger para ciertas configuraciones del proyecto:

    • ESLint
    • TailwindCSS
    • Directorio SRC
    • App Router
    • TurboPack
    • Import Alias

    Luego de esas respuestas va a instalar las dependencias necesarias e ingresar en el folder del proyecto.

    2. Probar que Tailwind Funciona

    Abre src/app/page.tsx y reemplázalo por:

    export default function Home() {
      return (
        <div className="flex items-center justify-center min-h-screen bg-gradient-to-r from-purple-400 via-pink-500 to-red-500">
          <h1 className="text-5xl font-bold text-white">¡Hola Next.js + Tailwind!</h1>
        </div>
      )
    }

    Levanta el servidor de desarrollo:

    npm run dev

    Deberías ver un fondo degradado y un título blanco centrado.

    3. Instalar Storybook

    Ahora agregamos Storybook para documentar los componentes:

    npx storybook init --builder webpack5

    Esto instalará Storybook y creará la carpeta .storybook/.

    4. Integrar TailwindCSS en Storybook

    Para que Storybook también use Tailwind, importa el CSS global en el archivo .storybook/preview.ts:

    import '../styles/globals.css'; // Importa estilos de Tailwind
    import type { Preview } from "@storybook/react";
    
    const preview: Preview = {
      parameters: {
        controls: {
          matchers: {
            color: /(background|color)$/i,
            date: /Date$/,
          },
        },
      },
    };
    
    export default preview;

    Con esto, todos tus componentes en Storybook podrán usar clases de TailwindCSS.

    5. Levantar Storybook

    Corre Storybook:

    npm run storybook

    Se abrirá automáticamente en http://localhost:6006.

    Ahora puedes empezar a crear components y stories usando TailwindCSS.

    🚀 Próximos pasos

    • Crear componentes en components/.
    • Escribir archivos .stories.tsx para cada componente.
    • Organizar las historias en “Atoms”, “Molecules” y “Organisms” (arquitectura Atomic Design).