Static Exports

Static Exports te permite exportar tu aplicación NextJS como archivos HTML, CSS y JavaScript estáticos. No necesitas servidor Node.js.

¿Qué es Static Export?

Convierte tu app NextJS en archivos estáticos:

out/
├── index.html
├── productos.html
├── contacto.html
├── _next/
│   ├── static/
│   │   ├── chunks/
│   │   └── css/

Estos archivos pueden hospedarse en:

  • GitHub Pages
  • Netlify
  • Cloudflare Pages
  • S3 + CloudFront
  • Cualquier CDN o servidor web

¿Cuándo usar Static Export?

✅ Ideal para:

  • Blogs - Contenido que no cambia frecuentemente
  • Portfolios - Sitios personales
  • Landing pages - Marketing
  • Documentación - Como esta guía
  • Sites informativos - Sobre nosotros, términos

❌ NO usar para:

  • Dashboards - Datos en tiempo real
  • Apps con auth - Login/logout
  • Ecommerce dinámico - Carrito, checkout
  • APIs - Necesitas servidor
  • Datos personalizados - Por usuario
⚠️
Limitaciones importantes

Static Export NO soporta:

  • ❌ Server Components con datos dinámicos
  • ❌ Server Actions
  • ❌ API Routes
  • ❌ Incremental Static Regeneration (ISR)
  • ❌ Image Optimization (a menos que uses loader custom)
  • ❌ Internationalization routing
  • ❌ Dynamic Routes sin generateStaticParams

Básicamente, no puedes usar nada que requiera un servidor Node.js.

Configuración

1. Habilitar static export

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'export',
}

module.exports = nextConfig

2. Build

npm run build

NextJS genera la carpeta out/ con todos los archivos estáticos.

3. Preview local

# Instala servidor estático
npm install -g serve

# Sirve la carpeta out
serve out

# Abre http://localhost:3000

Rutas dinámicas

Para rutas dinámicas, debes especificar todos los paths:

// app/productos/[id]/page.tsx

// Genera estas páginas en build time
export async function generateStaticParams() {
  const productos = await fetch('https://api.mitienda.com/productos')
    .then(r => r.json())
  
  return productos.map((producto) => ({
    id: producto.id.toString(),
  }))
}

export default async function ProductoPage({ params }) {
  const producto = await fetch(`https://api.mitienda.com/productos/${params.id}`)
    .then(r => r.json())
  
  return (
    <div>
      <h1>{producto.nombre}</h1>
      <p>${producto.precio}</p>
    </div>
  )
}

NextJS genera:

out/
├── productos/
│   ├── 1.html
│   ├── 2.html
│   └── 3.html

Imágenes

El componente Image no funciona con export estático por defecto. Tienes 3 opciones:

Opción 1: Usar img (sin optimización)

// ❌ No funciona en static export
import Image from 'next/image'
<Image src="/logo.png" width={100} height={100} />

// ✅ Funciona
<img src="/logo.png" width={100} height={100} alt="Logo" />

Opción 2: Loader custom

// next.config.js
module.exports = {
  output: 'export',
  images: {
    loader: 'custom',
    loaderFile: './image-loader.js',
  },
}
// image-loader.js
export default function cloudinaryLoader({ src, width, quality }) {
  const params = ['f_auto', 'c_limit', `w_${width}`, `q_${quality || 'auto'}`]
  return `https://res.cloudinary.com/demo/image/upload/${params.join(',')}${src}`
}
// Ahora funciona con CDN externo
import Image from 'next/image'
<Image src="/producto.jpg" width={500} height={300} />

Opción 3: unoptimized

// next.config.js
module.exports = {
  output: 'export',
  images: {
    unoptimized: true,
  },
}

Ahora <Image> funciona pero sin optimización (mismo que usar <img>).

Ejemplo: Blog estático

// app/blog/[slug]/page.tsx
import { readdir, readFile } from 'fs/promises'
import matter from 'gray-matter'
import { remark } from 'remark'
import html from 'remark-html'

// Genera paths para todos los posts
export async function generateStaticParams() {
  const files = await readdir('./content/blog')
  
  return files.map((filename) => ({
    slug: filename.replace('.md', ''),
  }))
}

export default async function BlogPost({ params }) {
  // Lee archivo markdown
  const fileContent = await readFile(
    `./content/blog/${params.slug}.md`,
    'utf-8'
  )
  
  // Parsea frontmatter
  const { data, content } = matter(fileContent)
  
  // Convierte markdown a HTML
  const processedContent = await remark()
    .use(html)
    .process(content)
  
  const contentHtml = processedContent.toString()
  
  return (
    <article>
      <h1>{data.title}</h1>
      <time>{data.date}</time>
      <div dangerouslySetInnerHTML={{ __html: contentHtml }} />
    </article>
  )
}
content/
└── blog/
    ├── primer-post.md
    ├── segundo-post.md
    └── tercer-post.md
---
title: Mi primer post
date: 2024-01-15
---

# Hola mundo

Este es mi primer post en el blog.

Trailing slashes

Por defecto, NextJS genera /about.html. Algunos servidores esperan /about/index.html.

// next.config.js
module.exports = {
  output: 'export',
  trailingSlash: true,
}

Genera:

out/
├── about/
│   └── index.html
└── contact/
    └── index.html

Links funcionan normalmente:

import Link from 'next/link'

<Link href="/productos">Productos</Link>
<Link href="/contacto">Contacto</Link>

NextJS usa client-side navigation (SPA) incluso en export estático.

Deployment

GitHub Pages

# .github/workflows/deploy.yml
name: Deploy to GitHub Pages

on:
  push:
    branches: [main]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node
        uses: actions/setup-node@v3
        with:
          node-version: 18
      
      - name: Install
        run: npm ci
      
      - name: Build
        run: npm run build
      
      - name: Deploy
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./out

Base path para GitHub Pages:

// next.config.js
const isProd = process.env.NODE_ENV === 'production'

module.exports = {
  output: 'export',
  basePath: isProd ? '/nombre-repositorio' : '',
  assetPrefix: isProd ? '/nombre-repositorio/' : '',
}

Netlify

  1. Conecta repositorio
  2. Build settings:
    • Build command: npm run build
    • Publish directory: out
  3. Deploy

netlify.toml:

[build]
  command = "npm run build"
  publish = "out"

[[redirects]]
  from = "/*"
  to = "/index.html"
  status = 200

Cloudflare Pages

  1. Conecta repositorio
  2. Build settings:
    • Build command: npm run build
    • Build output: out
  3. Deploy

AWS S3 + CloudFront

# Build
npm run build

# Sync a S3
aws s3 sync out/ s3://mi-bucket --delete

# Invalidar cache de CloudFront
aws cloudfront create-invalidation --distribution-id ID --paths "/*"

Servidor tradicional (Apache/Nginx)

Nginx:

server {
  listen 80;
  server_name mitienda.com;
  root /var/www/mitienda/out;
  
  location / {
    try_files $uri $uri.html $uri/ /index.html;
  }
  
  # Cache static assets
  location /_next/static {
    add_header Cache-Control "public, max-age=31536000, immutable";
  }
}

Apache (.htaccess):

RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.html [L]

Client-side data fetching

Ya que no hay servidor, obtén datos en el cliente:

'use client'

import { useState, useEffect } from 'react'

export default function ProductosPage() {
  const [productos, setProductos] = useState([])
  
  useEffect(() => {
    fetch('https://api.mitienda.com/productos')
      .then(r => r.json())
      .then(data => setProductos(data))
  }, [])
  
  if (!productos.length) {
    return <div>Cargando...</div>
  }
  
  return (
    <div>
      {productos.map(p => (
        <div key={p.id}>{p.nombre}</div>
      ))}
    </div>
  )
}

Metadata en static export

Metadata funciona normalmente:

// app/page.tsx
export const metadata = {
  title: 'Mi Tienda',
  description: 'Los mejores productos',
}

Se genera en el HTML:

<!DOCTYPE html>
<html>
<head>
  <title>Mi Tienda</title>
  <meta name="description" content="Los mejores productos">
</head>

404 personalizado

// app/not-found.tsx
export default function NotFound() {
  return (
    <div>
      <h1>404 - Página no encontrada</h1>
      <a href="/">Volver al inicio</a>
    </div>
  )
}

Genera out/404.html.

Configurar en servidor:

Netlify: Automático

Cloudflare Pages: Automático

S3: Configura error document en bucket properties

Sitemap y robots

// app/sitemap.ts
export default function sitemap() {
  return [
    {
      url: 'https://mitienda.com',
      lastModified: new Date(),
    },
    {
      url: 'https://mitienda.com/productos',
      lastModified: new Date(),
    },
  ]
}

Genera out/sitemap.xml

// app/robots.ts
export default function robots() {
  return {
    rules: {
      userAgent: '*',
      allow: '/',
    },
    sitemap: 'https://mitienda.com/sitemap.xml',
  }
}

Genera out/robots.txt

Ventajas de Static Export

Performance

Súper rápido - Solo HTML, CSS, JS ✅ CDN-friendly - Se cachea perfectamente ✅ Sin latencia de servidor - No hay Node.js

Costo

Gratis o muy barato

  • GitHub Pages: Gratis
  • Netlify: Gratis (100 GB/mes)
  • Cloudflare Pages: Gratis (ilimitado)

Simplicidad

Sin servidor que mantenerSin base de datosEscala infinito (es solo archivos)

Limitaciones

No puedes usar:

Server Components dinámicos

// ❌ No funciona
export default async function Page() {
  const data = await fetch('...', { cache: 'no-store' })
  return <div>{data}</div>
}

Server Actions

// ❌ No funciona
'use server'
export async function createTodo() {
  await db.todo.create(...)
}

API Routes

// ❌ No funciona
// app/api/productos/route.ts
export async function GET() {
  return Response.json({ productos: [] })
}

ISR (Incremental Static Regeneration)

// ❌ No funciona
export const revalidate = 60

Image Optimization

Sin loader custom, optimización de imágenes no funciona.

Redirects/Rewrites dinámicos

Solo funcionan redirects estáticos definidos en next.config.js.

Migrar a static export

Si tu app ya existe, estos pasos:

1. Cambiar configuración

// next.config.js
module.exports = {
  output: 'export',
}

2. Eliminar funciones de servidor

// ❌ Eliminar
export const dynamic = 'force-dynamic'
export const revalidate = 60

// ❌ Cambiar fetch dinámico a estático
const data = await fetch('...', { cache: 'no-store' })

// ✅ Por
const data = await fetch('...', { cache: 'force-cache' })

3. Convertir Server Actions a client fetch

// ❌ Antes (Server Action)
'use server'
export async function createProduct(data) {
  await db.product.create({ data })
}

// ✅ Después (Client fetch a API externa)
'use client'
export async function createProduct(data) {
  await fetch('https://api.externa.com/products', {
    method: 'POST',
    body: JSON.stringify(data)
  })
}

4. Agregar generateStaticParams

// Para rutas dinámicas
export async function generateStaticParams() {
  return [
    { id: '1' },
    { id: '2' },
    { id: '3' },
  ]
}

5. Build y revisar errores

npm run build

# Revisa errores y corrígelos uno por uno

Mejores prácticas

1. Usa para contenido estático

// ✅ Perfecto para static export
- Blogs
- Portfolios  
- Landing pages
- Documentación

// ❌ Mal para static export
- Dashboards
- Apps con login
- Ecommerce checkout

2. Client-side para datos dinámicos

// Datos que cambian: fetch en el cliente
useEffect(() => {
  fetch('https://api.com/latest')
    .then(r => r.json())
    .then(setData)
}, [])

3. Considera híbrido

// Static export para frontend
// API separada para backend

Frontend (static): GitHub Pages
Backend (API): Vercel serverless functions

4. Optimiza imágenes antes de build

# Comprime imágenes antes de agregar a /public
# Usa tinypng.com o imageoptim

Resumen

Static Export:

  • Convierte NextJS a archivos HTML estáticos
  • No necesita servidor Node.js
  • Hospeda en cualquier CDN

Ideal para:

  • Blogs, portfolios, landing pages
  • Sitios con contenido que no cambia mucho
  • Presupuesto limitado (hosting gratis)

No usar para:

  • Apps con autenticación
  • Dashboards con datos en tiempo real
  • Ecommerce dinámico
  • Cualquier cosa que necesite servidor

Setup:

// next.config.js
module.exports = {
  output: 'export',
}
npm run build
# Archivos en ./out/

Hosting:

  • GitHub Pages (gratis)
  • Netlify (gratis)
  • Cloudflare Pages (gratis)
  • Cualquier CDN

¡Con esto completamos la guía de Deployment! 🚀