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

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
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:
- Solo App Router: con el directorio
pagesno funciona - Parámetros dinámicos a mano: en
/blog/[slug]sigues concatenando el slug tú - Sin comprobación de query: en
/user?tab=settings,tabno 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:
- Validación al arrancar: si falta una variable o el formato es incorrecto, falla al iniciar, no en medio de la ejecución
- Inferencia de tipos: tipos exactos, no
string | undefined - 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:
- Optimizar tsconfig: strict, incremental y paths, más el plugin de Next.js
- Rutas seguras: typedRoutes en Next.js 13+ o la librería nextjs-routes
- Variables de entorno: T3 Env para tipos + validación en runtime
- 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?
¿Cómo activar el modo strict de forma segura en un proyecto antiguo?
¿Qué diferencia hay entre T3 Env y definir manualmente el tipo ProcessEnv?
¿typedRoutes de Next.js admite el directorio pages?
¿Activar skipLibCheck supone un riesgo de seguridad?
13 min de lectura · Publicado el: 6 ene 2026 · Actualizado el: 21 ago 2026
Guía completa de Next.js
Si llegaste desde búsqueda, lo más rápido es ir al artículo anterior o siguiente de esta misma serie.
Anterior
Guía de configuración de Sitemap y robots.txt en Next.js: que los buscadores indexen tu sitio rápido
Guía completa para configurar Sitemap y robots.txt en Next.js: tres métodos de generación, errores habituales que debes evitar e integración con Google Search Console para que los sitios nuevos se indexen antes.
Parte 29 de 51
Siguiente
Configuración de ingeniería en Next.js: guía integral de ESLint + Prettier + Husky
¿PR rechazada por formato o conflictos de estilo en el equipo? Guía paso a paso para configurar ESLint, Prettier y Husky en Next.js con validaciones y formateo automático antes de cada commit.
Parte 31 de 51



Comentarios
Inicia sesión con GitHub para dejar un comentario