Visor de PDF en Next.js con Apryse WebViewer: guía real
Un cliente me escribió con lo que parecía un encargo de dos semanas.
«Necesitamos que el usuario abra su contrato dentro de la plataforma, lo anote, tache los datos sensibles y lo firme. Sin descargar nada, sin salir de la app.»
Dos semanas. Claro.
Lo que estaba pidiendo era un visor de PDF en Next.js con capa de anotaciones persistentes, redacción de verdad —no un rectángulo negro pintado encima, sino borrar el texto del documento— y firma. Es decir: un producto entero metido dentro de una ruta de la aplicación.
Lo he visto intentar a mano tres veces. Las tres acabaron igual: seis meses después seguían peleando con fuentes embebidas y zoom en móvil.
Resumen: visor de PDF en Next.js en 5 puntos
- Un visor de PDF en Next.js es un componente de cliente que renderiza documentos dentro de tu aplicación, sin descargarlos ni delegar en el visor nativo del navegador. Requiere dos cosas que no son obvias: cargar la librería solo en el navegador y servir sus assets estáticos desde
public/. - Construir a mano anotaciones, redacción y firma sobre PDF es un pozo sin fondo: el formato tiene 30 años de casos borde.
- Apryse WebViewer (
@pdftron/webviewer) te da ese flujo completo en el navegador, sin backend de por medio. - Es un SDK comercial con trial gratuito. Los paquetes de entrada arrancan en $1.500 según su web de precios —consultado en julio de 2026—, y el precio final es a medida.
- Si solo necesitas mostrar un PDF, no lo uses.
pdf.jsoreact-pdfte resuelven eso gratis.
Por qué "solo un visor de PDF" nunca es solo un visor
Un PDF no es una imagen con texto: es un contenedor con tipografías embebidas, capas, formularios AcroForm, XFA, firmas criptográficas, anotaciones con su propio modelo de datos y treinta años de decisiones heredadas. Parece simple solo porque lo abres todos los días y funciona.
Renderizar la primera página con pdf.js te lleva una tarde. El problema empieza después.
Que la anotación quede anclada al párrafo correcto al hacer zoom. Que la redacción elimine el texto del stream y no solo lo tape —si lo tapas, cualquiera lo copia con Ctrl+C y tienes un incidente de datos—. Que la firma se incruste sin romper la validez del documento. Y que todo eso funcione igual en Safari iOS.
Ese es el trabajo real. Y no es trabajo de dos semanas: es trabajo de un equipo dedicado durante trimestres.
Antes de meter una pieza así en tu aplicación, escribe qué necesitas exactamente. Suena obvio y casi nadie lo hace. En cómo aplico Spec-Driven Development antes de escribir código explico el proceso —y lo tienes entero en el libro de Spec-Driven Development—. Esta es justo la decisión donde una especificación de una página te evita elegir mal una licencia comercial.
Qué es Apryse WebViewer y qué te ahorra
Apryse WebViewer es un SDK comercial que monta un visor y editor de PDF en React dentro de tu aplicación, ejecutándose entero en el navegador. No necesitas un servicio de conversión detrás.
Lo que trae de fábrica:
- Anotación completa: resaltados, notas, dibujo, formas, comentarios.
- Redacción real, que elimina el contenido del documento.
- Edición de texto sobre el PDF.
- Fill & sign para formularios y firma.
- Búsqueda dentro del documento.
- Cambio programático del documento cargado.
- Soporte de más de 100 formatos: Office, imágenes, CAD. No solo PDF.
Ese último punto suele cerrar la decisión. Cuando el cliente añade «ah, y también suben Word y planos», ya no evalúas un visor: evalúas si escribes tu propio pipeline de conversión.
Cómo montar un visor de PDF en Next.js paso a paso
La guía oficial de Apryse para Next.js cubre la instalación y es correcta. Lo que sigue añade lo que no te cuenta: la limpieza al desmontar, el fallo silencioso cuando path está mal y en qué casos no deberías estar leyendo este tutorial.
1. Instalar el paquete
npm i @pdftron/webviewer@^12
Fijar la mayor te evita que un npm update te cambie la API por debajo. Este tutorial está escrito sobre la rama 12 con Next.js 16 y App Router.
2. Copiar los assets estáticos
Este es el paso que más gente se salta y luego pasa una tarde mirando 404 en la pestaña de red. WebViewer necesita sus propios archivos servidos estáticamente: workers, fuentes, recursos de UI.
npx --yes cpy-cli "node_modules/@pdftron/webviewer/public/**/*" public/lib/webviewer
Guárdalo como script de postinstall. Si no lo haces, funcionará en tu máquina y fallará en el primer despliegue limpio de CI:
{
"scripts": {
"postinstall": "npx --yes cpy-cli \"node_modules/@pdftron/webviewer/public/**/*\" public/lib/webviewer"
}
}
3. El componente, siempre en cliente (y el error "window is not defined")
WebViewer toca window y el DOM en el momento de inicializarse. Si Next.js intenta renderizarlo en el servidor, revienta con el clásico ReferenceError: window is not defined.
Aquí está el matiz que atasca a casi todo el mundo: marcar el componente con 'use client' no basta si el import es estático. Esa directiva define dónde se hidrata el componente, no impide que el módulo se evalúe al construir el bundle del servidor.
La solución es importar el módulo dinámicamente dentro del useEffect, para que solo se resuelva en el navegador después del montaje.
39;use client39;
import { useEffect, useRef } from 39;react39;
export default function PdfViewer() {
const viewer = useRef(null)
useEffect(() => {
const container = viewer.current
let instance = null
let unmounted = false
const destroy = () => {
instance?.UI?.dispose?.()
instance = null
if (container) container.innerHTML = 39;39;
}
import(39;@pdftron/webviewer39;)
.then(({ default: WebViewer }) =>
WebViewer(
{
path: 39;/lib/webviewer39;,
licenseKey: process.env.NEXT_PUBLIC_APRYSE_LICENSE_KEY,
initialDoc: 39;https://apryse.s3.amazonaws.com/public/files/samples/WebviewerDemoDoc.pdf',
},
container,
),
)
.then((i) => {
instance = i
if (unmounted) return destroy()
i.Core.documentViewer.addEventListener(39;documentLoaded39;, () => {
// el documento ya está en pantalla: engancha aquí tu lógica
})
})
.catch((error) => {
console.error(39;WebViewer no arrancó. Revisa la opción `path`:39;, error)
})
return () => {
unmounted = true
destroy()
}
}, [])
return <div ref={viewer} style={{ height: 39;100dvh39; }} />
}
Cuatro detalles que conviene entender, no copiar:
pathapunta exactamente a donde copiaste los assets en el paso 2. Si cambias la carpeta, cambia esto.- El array de dependencias vacío no te salva de StrictMode. En desarrollo React monta, desmonta y vuelve a montar: el efecto corre dos veces y, sin función de limpieza, acabas con dos visores peleando por el mismo div. Por eso el
returndeluseEffectllama aUI.dispose()y vacía el contenedor. Es la parte que casi ningún tutorial escribe. - La bandera
unmountedcubre el otro orden posible: que el usuario navegue a otra ruta antes de que resuelva elimport()dinámico. Sin ella te quedas un iframe y unos workers WASM vivos en memoria. - La promesa resuelve con un
instanceque exponeinstance.Core—donde vivedocumentViewer— einstance.UI. Ese objeto es tu mando a distancia.
Y sí, el .catch() importa: si path está mal, sin él la promesa se rechaza en silencio y te quedas mirando un div en blanco sin una sola pista en consola.
Si necesitas la API completa, añade fullAPI: true a las opciones.
La clave va en variable de entorno para no hardcodearla en el repo:
NEXT_PUBLIC_APRYSE_LICENSE_KEY=tu_clave_de_trial
Ojo con la etiqueta: cualquier variable NEXT_PUBLIC_ viaja al bundle del navegador, así que esto no la convierte en un secreto. En WebViewer la licencia es de cliente y va ligada a tu dominio, así que es correcto y esperado; pero si algún día metes aquí una credencial de verdad, ese secreto vive en tu backend, no en una NEXT_PUBLIC_.
Controlar el visor desde tu propia interfaz
Casi nadie quiere la UI del SDK tal cual: quieres tus botones, con tu marca, en tu layout.
El patrón es guardar la instancia en estado o en una ref y llamar a sus métodos desde tus componentes. El visor deja de ser una caja negra y pasa a ser un motor que tú comandas.
39;use client39;
import { useEffect, useRef, useState } from 39;react39;
export default function PdfWorkspace() {
const viewer = useRef(null)
const [instance, setInstance] = useState(null)
useEffect(() => {
const container = viewer.current
let current = null
let unmounted = false
const destroy = () => {
current?.UI?.dispose?.()
current = null
setInstance(null)
if (container) container.innerHTML = 39;39;
}
import(39;@pdftron/webviewer39;)
.then(({ default: WebViewer }) =>
WebViewer(
{
path: 39;/lib/webviewer39;,
licenseKey: process.env.NEXT_PUBLIC_APRYSE_LICENSE_KEY,
fullAPI: true,
},
container,
),
)
.then((i) => {
current = i
if (unmounted) return destroy()
setInstance(i)
})
.catch((error) => {
console.error(39;WebViewer no arrancó:39;, error)
})
return () => {
unmounted = true
destroy()
}
}, [])
return (
<>
<button
disabled={!instance} => {
if (!instance) return
const { documentViewer } = instance.Core
// llama aquí al método que necesites sobre el documento
}}
>
Acción propia
</button>
<div ref={viewer} style={{ height: 39;100dvh39; }} />
</>
)
}
Un aviso de rendimiento: este bundle es grande. No lo cargues en el layout raíz ni en una ruta que la gente visita de paso. Aíslalo en su propia ruta y deja que el router precargue lo demás — hablé de esto al analizar las navegaciones instantáneas de Next.js 16.3, y aquí la diferencia entre hacerlo bien y mal se nota en segundos, no en milisegundos.
La parte incómoda: cuánto cuesta Apryse WebViewer
Apryse WebViewer no es gratis: su web de precios indica paquetes de entrada desde $1.500, y el precio final es a medida. Esa es la cifra, y aquí es donde la mayoría de tutoriales se callan.
El importe depende de las features que actives, del volumen de documentos y de si la solución es cliente o servidor. No hay tarifa pública por tramos: hay que pedir presupuesto.
Antes de pagar puedes probarlo. Apryse ofrece un trial gratuito, y su documentación de instalación te pide obtener una trial key en el portal de desarrolladores como paso previo. No te fíes de las condiciones que leas en un blog —incluido este—: mira el portal, que es donde cambian.
Vas a encontrar otras cifras circulando por foros y comparativas. Ninguna sale de Apryse. Ignóralas y pide presupuesto: es la única cifra que vale para tu caso.
¿Cuándo NO usar Apryse?
Si lo único que necesitas es mostrar un PDF en modo lectura, esto es un cañón para matar una mosca.
Para eso tienes pdf.js de Mozilla, o react-pdf —construido encima— si quieres la integración con componentes ya resuelta. Son gratis, open source, maduros y te resuelven ese caso entero. Meter un SDK comercial ahí es quemar presupuesto y añadir peso al bundle sin ganar nada.
Esta es la comparativa que a mí me habría ahorrado dos días de evaluación:
| Necesidad | pdf.js |
react-pdf |
Apryse WebViewer |
|---|---|---|---|
| Renderizar, paginar, zoom | ✅ | ✅ | ✅ |
| Búsqueda en el documento | ✅ | ⚠️ manual | ✅ |
| Componentes React listos | ❌ | ✅ | ✅ |
| Anotaciones persistentes | ❌ | ❌ | ✅ |
| Redacción real (borra del stream) | ❌ | ❌ | ✅ |
| Edición de texto sobre el PDF | ❌ | ❌ | ✅ |
| Fill & sign / firma | ❌ | ❌ | ✅ |
| Office, imágenes, CAD (100+ formatos) | ❌ | ❌ | ✅ |
| Licencia | Apache 2.0 | MIT | Comercial |
| Coste | Gratis | Gratis | Desde $1.500, a medida |
Léela en diagonal y verás el patrón: las tres primeras filas son un empate, y todo lo demás es una columna sola. Si tu requisito vive en las tres primeras filas, ya tienes tu respuesta y es gratis.
Apryse gana en un escenario concreto: cuando el documento es parte del flujo de negocio. Cuando el usuario tiene que anotar, redactar, rellenar, firmar o editar, y ese flujo es lo que el cliente está pagando.
Y ahí el cálculo no es "SDK caro contra librería gratis". Es esto: cuántos meses de ingeniería cuesta construir y mantener redacción, anotaciones y firma bien hechas, contra el precio de licenciarlo.
Cuando lo planteas así, la respuesta suele ser evidente. El coste no es el SDK. Es el tiempo que no gastas.
Qué hacer hoy
Instala el trial, copia los assets, monta el componente de arriba y ábrelo con un PDF real de tu cliente. No el de ejemplo: uno feo, escaneado, de 80 páginas.
En veinte minutos sabrás si esto resuelve tu problema o si te sobra con react-pdf. Esa decisión, tomada con el visor delante y no leyendo comparativas, vale más que cualquier post.
Y si lo que quieres es integrar piezas grandes como esta sin perder tres días leyendo documentación, ese es exactamente el flujo que enseño en el curso Construye con IA: especificar primero, delegar la integración después. La metodología completa está en el libro de Spec-Driven Development, y si prefieres hacerlo acompañado, en Dominicode Labs trabajamos integraciones como esta sobre proyectos reales.
Preguntas frecuentes
¿Apryse WebViewer es gratis?
No. Es un SDK comercial. Sí ofrece un trial gratuito para validar si encaja con tu caso: su documentación de instalación te pide obtener una trial key en el portal de desarrolladores antes de empezar. Para producción, su web de precios indica paquetes de entrada desde $1.500 (consultado en julio de 2026), con precio final a medida según las features que actives, el volumen de documentos y si el despliegue es en cliente o en servidor. Las condiciones exactas del trial las marca su portal, no los blogs.
¿Se puede usar Apryse WebViewer con el App Router de Next.js?
Sí, con dos condiciones. El componente que lo monta debe llevar la directiva 'use client' y el import del paquete tiene que ser dinámico dentro del useEffect, no estático en la cabecera del archivo. Así el bundle del servidor nunca evalúa el SDK. Además tienes que copiar los assets estáticos del paquete a public/lib/webviewer y apuntar la opción path a esa ruta.
¿Por qué me da el error "window is not defined" al integrar el visor?
Porque Next.js está intentando ejecutar el SDK durante el renderizado en servidor, donde no existe el objeto window. Marcar el componente como cliente no basta si el import es estático: el módulo se evalúa igualmente al construir. La solución es cargarlo con import('@pdftron/webviewer') dentro del useEffect, de forma que solo se resuelva en el navegador después del montaje.
¿Qué alternativa gratuita hay a Apryse?
pdf.js de Mozilla y react-pdf, que está construido encima. Ambos son open source y cubren perfectamente la visualización de documentos: renderizado, paginación, zoom y búsqueda básica. Donde no llegan es en el flujo completo de trabajo con documentos —redacción que borra contenido de verdad, edición de texto, fill & sign, anotaciones persistentes con su modelo de datos—. Si tu requisito es leer, usa las gratuitas. Si tu requisito es operar sobre el documento, compara con Apryse.
¿Sirve solo para PDF?
No. WebViewer soporta más de 100 formatos, incluidos documentos de Office, imágenes y archivos CAD, y los renderiza en el mismo visor sin necesidad de un servicio de conversión en el servidor. Suele ser el factor decisivo cuando los usuarios suben lo que tienen a mano y no un PDF bien generado.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
