Cambiar tema

Next.js TypeScript avanzado: optimización de tsconfig y prácticas de seguridad de tipos

Easton editorial illustration: server-client bridge

En el informe de pruebas aparecía una línea roja llamativa: «Production Error: Cannot read property ‘id’ of undefined». Los usuarios decían que al pulsar la página del perfil la pantalla quedaba en blanco. Revisé el código: la ruta era /users/profile en lugar de /user/profile, con una s de más. TypeScript no avisó, el IDE tampoco, y el error llegó a producción sin más.

¿No es un error de principiante? Sí. Pero este tipo de «errores tontos» aparecían con una frecuencia agotadora en los proyectos que mantenía. Rutas mal escritas, nombres de variables de entorno incorrectos, parámetros de funciones en any por todas partes… TypeScript promete «seguridad de tipos», pero a veces se siente igual que JavaScript.

Luego entendí que no era TypeScript el problema, sino mi configuración. En tsconfig.json hay montones de opciones y no sabía cuáles activar; los tutoriales se contradecen: unos dicen que el modo estricto complica el desarrollo, otros que sin él TypeScript no sirve. Tras casi un año con Next.js + TypeScript, el any seguía por doquier.

En este artículo comparto lo que aprendí tras un año de tropiezos: optimización de tsconfig, rutas con tipos seguros y tipado de variables de entorno, para convertir TypeScript de «obstáculo» en «guardián». Nada de teoría abstracta: cosas que puedes usar de verdad.

Optimización de tsconfig: sentar las bases

Entender de verdad el modo strict

Mucha gente (yo incluido al principio) cree que strict: true es un interruptor: lo activas y TypeScript se vuelve estricto. No es así.

En la documentación oficial de TypeScript verás que strict es un atajo de 7 opciones del compilador:

{
  "compilerOptions": {
    "strict": true,
    // equivale a que las 7 opciones siguientes sean true
    "strictNullChecks": true,        // comprobación estricta de null
    "strictFunctionTypes": true,     // comprobación estricta de tipos de funciones
    "strictBindCallApply": true,     // comprobación estricta de bind/call/apply
    "strictPropertyInitialization": true, // inicialización estricta de propiedades
    "noImplicitAny": true,          // prohibir any implícito
    "noImplicitThis": true,         // prohibir this implícito
    "alwaysStrict": true            // parsear siempre en modo estricto
  }
}

Las tres primeras suelen ser las más útiles. Empiezo por strictNullChecks: al activarla, TypeScript trata null y undefined como tipos independientes, no como «valores válidos de cualquier tipo».

Ejemplo: consultas un usuario en la base de datos:

// sin strictNullChecks
const user = await db.user.findOne({ id: userId })
console.log(user.name) // TypeScript no avisa, pero user puede ser null

// con strictNullChecks
const user = await db.user.findOne({ id: userId })
console.log(user.name) // ❌ TypeScript error: el objeto puede ser null

// hay que escribir así
if (user) {
  console.log(user.name) // ✅ correcto
}

La primera vez que activé esto en un proyecto antiguo, el IDE mostró más de 200 líneas rojas. Entré en pánico y casi lo desactivo. Al revisar con calma, esos «errores» eran bugs potenciales: sitios sin comprobación de null que en producción sí explotan.

noImplicitAny también es clave: impide que parámetros o variables se conviertan «implícitamente» en any:

// sin noImplicitAny
function handleData(data) {  // data pasa a ser any
  return data.value  // cualquier operación pasa sin error
}

// con noImplicitAny
function handleData(data) {  // ❌ error: el parámetro tiene any implícito
  return data.value
}

// hay que anotar explícitamente
function handleData(data: { value: string }) {  // ✅
  return data.value
}

Al principio parece molesto: antes escribías una función y listo; ahora hay que definir tipos. Tras un tiempo, el IDE se vuelve más inteligente: al escribir data., salen todas las propiedades y ya no hace falta ir a la documentación.

Configuración TypeScript específica de Next.js

En proyectos Next.js, tsconfig.json tiene matices. Aquí va la versión que uso como referencia:

{
  "compilerOptions": {
    // configuración base
    "target": "ES2020",
    "lib": ["dom", "dom.iterable", "esnext"],
    "jsx": "preserve",
    "module": "esnext",
    "moduleResolution": "bundler",

    // requerido por Next.js
    "allowJs": true,
    "noEmit": true,
    "esModuleInterop": true,
    "isolatedModules": true,
    "resolveJsonModule": true,

    // modo estricto (núcleo)
    "strict": true,
    "skipLibCheck": true,

    // optimización de rendimiento
    "incremental": true,

    // plugin de Next.js
    "plugins": [
      {
        "name": "next"
      }
    ],

    // alias de rutas
    "paths": {
      "@/*": ["./src/*"],
      "@/components/*": ["./src/components/*"],
      "@/lib/*": ["./src/lib/*"],
      "@/styles/*": ["./src/styles/*"]
    }
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    ".next/types/**/*.ts"
  ],
  "exclude": ["node_modules"]
}

Detallo varios puntos que suelen pasarse por alto:

1. incremental: compilación incremental

60%
mejora de velocidad de compilación

Esta opción acelera mucho la compilación en proyectos grandes. TypeScript guarda en caché la compilación anterior y solo recompila lo modificado. En un proyecto con más de 300 componentes, el tiempo bajó de unos 45 segundos a unos 18.

2. paths: alias de rutas

Antes los imports eran así:

import Button from '../../../components/ui/Button'
import { formatDate } from '../../../../lib/utils'

Imposible contar los ..; un pequeño cambio de carpetas y todo falla.

Con alias:

import Button from '@/components/ui/Button'
import { formatDate } from '@/lib/utils'

Mucho más limpio. TypeScript infiere bien los tipos y el salto a definición del IDE funciona.

3. plugins: plugin de Next.js

El "plugins": [{ "name": "next" }] parece trivial, pero hace que TypeScript entienda lo propio de Next.js: tipos de layout.tsx, page.tsx en app, y la distinción entre componentes de servidor y cliente.

Sin este plugin, al escribir componentes de servidor TypeScript puede marcar errores de tipo incorrectos.

Activar el modo estricto de forma progresiva

Si el proyecto lleva tiempo y tiene bastante código, activar strict: true de golpe duele. Mi consejo: no forces todo a la vez.

Estrategia 1: código nuevo estricto, antiguo poco a poco

Mantén strict: true en tsconfig.json y, en archivos viejos que no puedas arreglar ya, pon al inicio:

// @ts-nocheck  // omitir la comprobación de tipos de todo el archivo

O en una línea concreta:

// @ts-ignore  // ignorar el error de tipos de la línea siguiente

Ojo: @ts-ignore y @ts-expect-error no son lo mismo:

// @ts-ignore
const x = 1 as any  // aunque la línea siguiente no tenga error, no avisa

// @ts-expect-error
const y = 1  // si la línea siguiente no tiene error, TypeScript avisa de comentario innecesario

Prefiero @ts-expect-error: evita olvidar borrar el comentario; cuando el bug esté corregido, TypeScript te dirá que ya no hace falta.

Estrategia 2: activar por módulos

Por ejemplo, limpia primero components y deja el resto más relajado:

// tsconfig.strict.json (modo estricto)
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "strict": true
  },
  "include": ["src/components/**/*"]
}

Desarrollo normal con tsconfig.json; al refactorizar un módulo, cambias a la versión strict.

El modo estricto no está para fastidiarte. Refactorizando un componente antiguo, con strictNullChecks encontré 5 comprobaciones de null faltantes; 3 ya habían fallado en producción, pero try-catch las ocultaba. De repente esas líneas rojas parecían razonables.

Rutas con tipos seguros: adiós a los errores ortográficos

Typed Routes integrado en Next.js

¿Recuerdas el bug del inicio? Una s de más en la ruta y 404. Se puede evitar.

Next.js 13 trajo una función experimental: typedRoutes. Al activarla, TypeScript genera definiciones de tipos para todas tus rutas.

¿Cómo activarlo?

En next.config.ts, añade:

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  experimental: {
    typedRoutes: true,  // activar rutas con tipos seguros
  },
}

export default nextConfig

Reinicia el servidor de desarrollo (npm run dev); Next.js escanea app y genera tipos en .next/types.

¿Cómo se ve en la práctica?

Supón esta estructura:

app/
├── page.tsx           // inicio
├── blog/
│   ├── page.tsx      // listado del blog
│   └── [slug]/
│       └── page.tsx  // detalle del artículo
└── user/
    └── [id]/
        └── profile/
            └── page.tsx  // perfil de usuario

Con typedRoutes, en Link y useRouter el IDE autocompleta:

import Link from 'next/link'

export default function Nav() {
  return (
    <nav>
      <Link href="/">Inicio</Link>
      <Link href="/blog">Blog</Link>
      <Link href="/blog/hello-world">Detalle del artículo</Link>
      <Link href="/user/123/profile">Perfil</Link>

      {/* ❌ TypeScript error: la ruta no existe */}
      <Link href="/users/123/profile" />  // users en lugar de user
    </nav>
  )
}

Al escribir href="/, aparecen todas las rutas válidas. Si te equivocas, error al instante.

La primera vez que lo probé, solo pensé: qué maravilla.

Limitaciones

Aún tiene restricciones:

  1. Solo App Router: con el directorio pages no funciona
  2. Parámetros dinámicos a mano: en /blog/[slug] sigues concatenando el slug tú
  3. Sin comprobación de query: en /user?tab=settings, tab no se valida por tipos

En resumen: evita rutas mal escritas; los valores de parámetros siguen siendo tu responsabilidad.

Librería de terceros: nextjs-routes

Si usas pages o quieres tipado más completo (incluidos query params), prueba nextjs-routes.

Instalación y configuración:

npm install nextjs-routes

En next.config.ts:

const nextRoutes = require('nextjs-routes/config')

const nextConfig = nextRoutes({
  // tu configuración original de Next.js
})

export default nextConfig

Uso:

Genera una función route para definir rutas con objetos:

import { route } from 'nextjs-routes'

// objeto de ruta con tipos seguros
const profileRoute = route({
  pathname: '/user/[id]/profile',
  query: {
    id: '123',
    tab: 'settings',  // los query params también se comprueban
  }
})

router.push(profileRoute)  // totalmente type-safe

// si la ruta está mal
const wrongRoute = route({
  pathname: '/users/[id]/profile',  // ❌ TypeScript error: la ruta no existe
})

Frente al esquema integrado de Next.js, nextjs-routes aporta:

  • Soporte para el directorio pages
  • Comprobación de query params
  • Rutas como objetos, sin concatenar strings a mano

Inconvenientes: dependencia extra y regeneración de tipos al cambiar rutas (automática).

Inferencia de tipos en parámetros de ruta

¿Y los parámetros dinámicos? En app/blog/[slug]/page.tsx, ¿qué tipo tiene slug?

Next.js genera el tipo de params:

// app/blog/[slug]/page.tsx
export default function BlogPost({
  params,
}: {
  params: { slug: string }
}) {
  return <h1>Artículo: {params.slug}</h1>
}

El problema: slug es solo string; entra cualquier valor. Para ser más estricto —por ejemplo, solo slugs con un formato concreto— usa zod en runtime:

import { z } from 'zod'

const slugSchema = z.string().regex(/^[a-z0-9-]+$/)

export default function BlogPost({
  params,
}: {
  params: { slug: string }
}) {
  // validar formato del slug
  const validatedSlug = slugSchema.parse(params.slug)

  return <h1>Artículo: {validatedSlug}</h1>
}

Si el slug no cumple el formato (mayúsculas o caracteres raros), zod lanza error.

Muy útil en rutas API: no controlas lo que envía el usuario; mejor validar antes que explotar en producción.

Tipado de variables de entorno: eliminar el any de verdad

De dónde viene el problema

Con variables de entorno, el soporte por defecto de TypeScript es flojo.

Seguro que has escrito:

const apiKey = process.env.API_KEY

Sobre apiKey, el tipo es string | undefined. Al menos sabes que puede faltar.

Lo habitual es peor:

const apiUrl = process.env.NEXT_PUBLIC_API_URL
console.log(apiUrl.toUpperCase())  // explota en runtime: apiUrl is undefined

TypeScript no avisa; en ejecución descubres que la variable no está configurada.

Y si te equivocas en el nombre, tampoco:

const key = process.env.API_SECRE  // falta la T
// TypeScript: todo bien, string | undefined

Usas TypeScript y sigues revisando nombres a ojo, como en JavaScript.

T3 Env (recomendado)

La solución más aceptada en la comunidad es T3 Env: comprobación de tipos y validación en runtime.

Instalación:

npm install @t3-oss/env-nextjs zod

Configuración:

Crea env.mjs (o env.ts) en la raíz:

import { createEnv } from "@t3-oss/env-nextjs"
import { z } from "zod"

export const env = createEnv({
  // variables de servidor (no accesibles en el cliente)
  server: {
    DATABASE_URL: z.string().url(),
    API_SECRET: z.string().min(32),
    SMTP_HOST: z.string().min(1),
  },

  // variables de cliente (deben empezar por NEXT_PUBLIC_)
  client: {
    NEXT_PUBLIC_APP_URL: z.string().url(),
    NEXT_PUBLIC_ANALYTICS_ID: z.string().optional(),
  },

  // mapeo en runtime
  runtimeEnv: {
    DATABASE_URL: process.env.DATABASE_URL,
    API_SECRET: process.env.API_SECRET,
    SMTP_HOST: process.env.SMTP_HOST,
    NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
    NEXT_PUBLIC_ANALYTICS_ID: process.env.NEXT_PUBLIC_ANALYTICS_ID,
  },
})

Uso:

import { env } from './env.mjs'

// ✅ type-safe, autocompletado
const dbUrl = env.DATABASE_URL  // string
const appUrl = env.NEXT_PUBLIC_APP_URL  // string

// ❌ TypeScript error: error ortográfico
const wrong = env.DATABASE_UR

// ❌ TypeScript error: el cliente no puede acceder a variables de servidor
// en un componente cliente
'use client'
const secret = env.API_SECRET  // error de compilación

Lo mejor:

  1. Validación al arrancar: si falta una variable o el formato es incorrecto, falla al iniciar, no en medio de la ejecución
  2. Inferencia de tipos: tipos exactos, no string | undefined
  3. Anti-filtrado: acceder a variables de servidor desde el cliente da error de compilación

Antes de T3 Env, en staging olvidaba variables y el servicio no arrancaba; había que revisar logs. Ahora lo ves al levantar la app.

Declaración de tipos personalizada

Si no quieres T3 Env o el proyecto es pequeño, extiende ProcessEnv:

// env.d.ts
namespace NodeJS {
  interface ProcessEnv {
    // variables de servidor
    DATABASE_URL: string
    API_SECRET: string
    SMTP_HOST: string

    // variables de cliente
    NEXT_PUBLIC_APP_URL: string
    NEXT_PUBLIC_ANALYTICS_ID?: string  // opcional con ?
  }
}

TypeScript conoce los tipos:

const dbUrl = process.env.DATABASE_URL  // string
const apiSecret = process.env.API_SECRET  // string

// ❌ TypeScript error
const wrong = process.env.DATABASE_UR  // Property 'DATABASE_UR' does not exist

Inconvenientes:

  • Sin validación en runtime; los faltantes salen tarde
  • No impide que el cliente lea variables de servidor
  • Mantenimiento manual de definiciones

Vale para proyectos pequeños o exigencias bajas. Si ya usas TypeScript, T3 Env suele compensar.

Modo estricto en la práctica

Tipos de librerías de terceros

A veces el problema no es tu código, sino que la librería no trae tipos o los trae mal.

Caso 1: sin definiciones de tipos

Importas un paquete antiguo y todo es any:

import oldLib from 'some-old-lib'  // any

Busca en npm @types/some-old-lib:

npm install -D @types/some-old-lib

Si no existe, escríbelos tú en types/some-old-lib.d.ts:

declare module 'some-old-lib' {
  export function doSomething(param: string): number
  export default someOldLib
}

Caso 2: tipos incorrectos

A veces @types no coincide con la API real. Puedes usar aserción de tipo de forma temporal:

import { someFunction } from 'buggy-lib'

// los tipos dicen string, pero devuelve number
const result = someFunction() as number

Es un parche; mejor abrir issue o PR en el repo.

¿Activar skipLibCheck?

En tsconfig, skipLibCheck omite la comprobación de tipos en node_modules.

Mi recomendación: sí, actívalo.

Los errores de node_modules no los arreglas tú y ralentizan la compilación. Mejor centrarte en tu código.

Fugas habituales hacia any y cómo cerrarlas

Con strict activo, aún hay sitios donde escapa any.

Escenario 1: manejadores de eventos

// ❌ mal
const handleSubmit = (e: any) => {
  e.preventDefault()
}

// ✅ bien
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault()
  // e.currentTarget tiene tipos completos
}

Tipos frecuentes:

  • React.MouseEvent<HTMLButtonElement>
  • React.ChangeEvent<HTMLInputElement>
  • React.KeyboardEvent<HTMLDivElement>

Escenario 2: respuestas de API

// ❌ mal
const res = await fetch('/api/user')
const data = await res.json()  // any

// ✅ opción 1: interfaz manual
interface User {
  id: string
  name: string
  email: string
}

const data: User = await res.json()

// ✅ opción 2: validar con zod (recomendado)
import { z } from 'zod'

const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
})

const data = UserSchema.parse(await res.json())  // tipo inferido

Con zod tienes tipos y validación en runtime; si el backend cambia la forma, lo detectas al momento.

Escenario 3: importación dinámica

// ❌ mal
const module = await import('./utils')  // any

// ✅ bien
const module = await import('./utils') as typeof import('./utils')

O import concreto:

const { formatDate } = await import('./utils')  // tipo inferido

Tipos utilitarios de TypeScript

TypeScript trae utilidades que ahorran código.

Pick: extraer propiedades

interface User {
  id: string
  name: string
  email: string
  password: string
  createdAt: Date
}

// solo información pública
type PublicUser = Pick<User, 'id' | 'name' | 'email'>
// { id: string; name: string; email: string }

Omit: excluir propiedades

// al crear usuario no hace falta id ni createdAt
type CreateUserInput = Omit<User, 'id' | 'createdAt'>

Partial: todo opcional

// al actualizar, todos los campos son opcionales
type UpdateUserInput = Partial<User>

Required: todo obligatorio

type RequiredUser = Required<Partial<User>>  // operación inversa

Tipos utilitarios propios

Si los integrados no bastan:

// hacer opcionales solo las propiedades string
type PartialString<T> = {
  [K in keyof T]: T[K] extends string ? T[K] | undefined : T[K]
}

Al principio asustan; con el uso ahorran mucho código repetido en objetos complejos.

Conclusión

Volviendo al bug de las tres de la madrugada del inicio.

Con typedRoutes de Next.js, la ruta mal escrita no habría llegado a producción; con T3 Env, variables faltantes fallarían al arrancar; con strict bien configurado, los any implícitos habrían saltado antes.

La seguridad de tipos no es fastidio: mueve los bugs de runtime a tiempo de escritura. Mejor que el IDE se ponga rojo mientras escribes que que el usuario vea pantalla en blanco.

Resumen:

  1. Optimizar tsconfig: strict, incremental y paths, más el plugin de Next.js
  2. Rutas seguras: typedRoutes en Next.js 13+ o la librería nextjs-routes
  3. Variables de entorno: T3 Env para tipos + validación en runtime
  4. Modo estricto en la práctica: activación progresiva, tipos de terceros y cerrar fugas de any

Al principio parece más trabajo; cuando acostumbras al autocompletado preciso y a detectar problemas al editar, ya no querrás volver a JavaScript «a pelo».

Abre tu tsconfig.json y pon strict en true. Cuantas más líneas rojas, más bugs potenciales estás encontrando — y eso es bueno.

FAQ

¿El modo strict hace más lenta la compilación del proyecto?
No. El modo strict solo aumenta el rigor de la comprobación de tipos y no afecta de forma notable la velocidad de compilación. Junto con la opción incremental, en proyectos grandes la compilación puede ser un 30%-50% más rápida.
¿Cómo activar el modo strict de forma segura en un proyecto antiguo?
Usa una estrategia progresiva: activa strict en tsconfig.json, marca los archivos que no puedas corregir de inmediato con @ts-expect-error, exige rigor en el código nuevo y refactoriza el antiguo poco a poco. También puedes activarlo módulo por módulo.
¿Qué diferencia hay entre T3 Env y definir manualmente el tipo ProcessEnv?
T3 Env ofrece validación en tiempo de ejecución: al arrancar la app comprueba si faltan variables de entorno o si el formato es incorrecto, y evita que el cliente acceda a variables del servidor. La definición manual de tipos solo comprueba en compilación, sin protección en runtime.
¿typedRoutes de Next.js admite el directorio pages?
No. typedRoutes es una función experimental de Next.js 13+ diseñada para App Router y solo funciona con el directorio app. Si sigues usando pages, prueba la librería de terceros nextjs-routes.
¿Activar skipLibCheck supone un riesgo de seguridad?
No. skipLibCheck solo omite la comprobación de tipos en node_modules; tu código sigue comprobándose con rigor. Los errores de tipos de librerías de terceros no los puedes corregir tú; omitir esa comprobación acelera la compilación y te deja centrarte en tu propio código.

13 min de lectura · Publicado el: 6 ene 2026 · Actualizado el: 21 ago 2026

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog