# Cómo desplegar una app Next.js con SSR usando Docker y un backend

Una app de notas en Next.js 15, subida a GitHub, renderizando en un container con sus datos en un backend gestionado. Medida de punta a punta, incluidos el primer build de seis minutos y la página 500 que mostró nuestra primera versión.

URL: https://www.back4app.com/blog/es/como-desplegar-una-app-nextjs-ssr-con-docker-y-backend
Publicado: 2026-11-04 · Probado: 2026-09-16
Probado en: Containers: Back4app, plan Shared, 0,5 vCPU, 512 MB (plan Free durante la primera hora) · Backend: Back4app, USA East · Local: Node 25, Next.js 15, React 19
Publisher: Back4app Engineering

Tu app Next.js se renderiza en el servidor, y esa es justamente la gracia: el HTML llega completo, con los datos dentro. También significa que la app necesita un sitio donde ejecutarse que no sea una CDN (un proceso que ejecute tus componentes en cada solicitud) y un sitio donde guardar los datos que no sea ese proceso.

Este post trata del renderizado en el servidor: un proceso que ejecuta tus componentes por solicitud, una fuente de datos que lee en el momento de la solicitud y claves que se quedan en el servidor. Un **container** ejecuta `next start` por ti, detrás de HTTPS. Un **backend** guarda las notas, las valida y responde a una llamada REST.

Las otras tres formas tienen su propio post: [Node.js con Docker](https://www.back4app.com/blog/deploy-node-app-dockerfile-back4app), [FastAPI con Docker](https://www.back4app.com/blog/deploy-python-fastapi-dockerfile-database), [un worker siempre activo](https://www.back4app.com/blog/run-a-bot-webhook-or-cron-24-7-without-a-vps).

Construimos una app de notas (un server component que lista las notas, una server action que las añade, 94 líneas en total con el Dockerfile incluido), la subimos a GitHub y la pusimos a renderizar en un container con los datos fuera de él.

El primer deploy tardó **6 minutos 4 segundos**, la mayor parte en `next build`. Todo lo que sigue sale de esa ejecución, el 15 y el 16 de septiembre de 2026, incluida la página 500 que mostró nuestra primera versión.

**Consigue el código funcionando:** el repositorio companion está en [github.com/templates-back4app/nextjs-notes](https://github.com/templates-back4app/nextjs-notes) — `app/page.js`, `app/actions.js`, `lib/backend.js`, `Dockerfile`, `cloud/main.js` y `deploy-check.sh`, que comprueba el health check, el renderizado en el servidor y los datos en cualquier URL de deploy.

## ¿Qué necesita una app renderizada en el servidor que una estática no?

Un proceso, y una fuente de datos a la que ese proceso pueda llegar. Un export estático son archivos en una CDN y no necesita ninguna de las dos cosas; en cuanto una página lee datos en el momento de la solicitud, algo tiene que ejecutar tu código y algo tiene que guardar los datos.

| Qué necesitas | Por qué no es opcional | Dónde vive en este post |
|---|---|---|
| `node server.js` en ejecución | Los server components se ejecutan por solicitud | Container, sección 03 |
| HTTPS en una URL pública | Los navegadores rechazan HTTP plano para formularios | Container, automático |
| Una base de datos que no administras tú | Los datos sobreviven al container | Backend, sección 04 |
| Validación fuera del container | La action es desechable; las reglas, no | Backend, sección 05 |
| Claves que nunca llegan al navegador | Variables de entorno solo de servidor, sin `NEXT_PUBLIC_` | Entorno del container, sección 03 |

La mayoría de los hosts que ejecutan Next.js se quedan en las dos primeras filas, y los datos son una cadena de conexión a otra cosa que pagas y mantienes tú. Back4app es el que probamos porque la segunda mitad es un producto de la misma cuenta: una **app de backend** con base de datos gestionada y una REST API, llamada desde el container con dos headers.

## ¿De qué se compone la app de notas renderizada en el servidor?

Una página de notas. `app/page.js` es un server component: llama al backend, renderiza la lista y un formulario, y estampa la hora en que se renderizó. `app/actions.js` es una server action: el formulario le hace el POST, ella crea la nota en el backend y la página se vuelve a renderizar. `lib/backend.js` es el único archivo que conoce las claves.

![Arquitectura: el navegador carga el HTML renderizado por el container de Next.js; el server component y la server action del container llaman al backend de Back4app por REST con el App ID y la REST key; un hook de Cloud Code valida cada Note](/blog/blog-assets/deploy-nextjs-app-github-backend/architecture-diagram.svg)

La página. Dos líneas la mantienen dinámica; sin ellas, `next build` la prerenderizaría una sola vez, con lo que el backend tuviera durante el build:

```javascript
// Stack: Next.js 15 App Router | File: app/page.js

export const dynamic = "force-dynamic";           // render per request, never at build time

export default async function Home({ searchParams }) {
  const { error } = await searchParams;
  const notes = await listNotes();
  const renderedAt = new Date().toISOString();
  return (
    <main>
      <h1>Notes</h1>
      <form action={addNote}>
        <input name="text" placeholder="Write a note" required />
        <button>Add</button>
      </form>
      {error && <p role="alert">{error}</p>}
      <ul>{notes.map((n) => <li key={n.objectId}>{n.text}</li>)}</ul>
      <p>{notes.length} notes · rendered on the server at {renderedAt}</p>
    </main>
  );
}
```

El cliente del backend se ejecuta solo en el servidor. `cache: "no-store"` es la otra mitad de "por solicitud":

```javascript
// Stack: Next.js 15 (server only) | File: lib/backend.js
const BASE = process.env.PARSE_SERVER_URL ?? "https://parseapi.back4app.com";
const headers = () => ({
  "X-Parse-Application-Id": process.env.PARSE_APP_ID,
  "X-Parse-REST-API-Key": process.env.PARSE_REST_KEY,
  "Content-Type": "application/json",
});

export async function listNotes() {
  const r = await fetch(`${BASE}/classes/Note?order=-createdAt&limit=50`, { headers: headers(), cache: "no-store" });
  if (!r.ok) throw new Error(`backend ${r.status}: ${await r.text()}`);
  return (await r.json()).results;
}

export async function createNote(text) {
  const r = await fetch(`${BASE}/classes/Note`, { method: "POST", headers: headers(), body: JSON.stringify({ text }) });
  const body = await r.json();
  if (!r.ok) throw new Error(body.error ?? `backend ${r.status}`);
  return body;
}
```

El Dockerfile tiene dos etapas. La primera ejecuta `next build`; la segunda copia solo la salida standalone, así que la imagen no lleva más `node_modules` que los que necesita el servidor:

```dockerfile
# Stack: Docker | Node.js 22 (alpine), multi-stage | File: Dockerfile
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production PORT=8080 HOSTNAME=0.0.0.0
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
EXPOSE 8080
CMD ["node", "server.js"]
```

Necesitas una cuenta gratuita de Back4app, una cuenta de GitHub y `curl`. Docker en tu máquina, no: la plataforma construye la imagen, y este post se produjo en un Mac sin Docker instalado. Node solo hace falta para ejecutar la app en local antes.

## ¿De dónde salen los datos?

De una app de backend: Dashboard → **New App → Build your Backend**, ponle nombre, **Create**. La página Overview muestra el App ID y, en el desplegable Keys, la REST API key. Esos dos valores van al entorno del container y a ningún otro sitio.

Si tu frontend llamara al backend directamente, la clave en el navegador sería una clave de cliente y los permisos de la clase harían de guardia: [ese caso tiene su propio post](https://www.back4app.com/blog/lock-down-a-backend-your-frontend-talks-to).

La clase `Note` la creó el primer `POST`, sin schema previo (Parse Server 7.5.2, septiembre de 2026). Esta es esa escritura de dos formas: la que hace la server action y la que puedes hacer desde tu terminal con las mismas claves. Las dos aparecieron en el siguiente render de la página:

## ¿Por qué la regla de las notas es un hook del backend y no una server action?

Porque la regla define qué es una nota, no qué hace este formulario. Una server action se ejecuta solo en este container y solo para este formulario; el hook del backend se ejecuta para la action, para el curl de arriba y para la próxima app que escriba notas. El nuestro recorta el texto, rechaza una nota vacía y la limita a 280 caracteres:

```javascript
// Stack: Node.js 22.x | Parse Server 8.x | File: cloud/main.js
Parse.Cloud.beforeSave("Note", (request) => {
  const n = request.object;
  const text = (n.get("text") ?? "").trim();
  if (!text) throw new Parse.Error(Parse.Error.VALIDATION_ERROR, "a note needs some text.");
  if (text.length > 280) throw new Parse.Error(Parse.Error.VALIDATION_ERROR, "keep it under 280 characters.");
  n.set("text", text);
});
```

Pégalo en **Cloud Code → cloud/main.js** en la dashboard del backend, **Deploy**, y demuéstralo con una solicitud. El nuestro respondió `400 {"code":142,"error":"a note needs some text."}` a tres espacios, y `201`, con el texto ya recortado, a una nota válida rellena de espacios; eso sí, después del segundo deploy.

## ¿Cómo se despliega desde GitHub?

Sube el repositorio y después **New App → Deploy from GitHub** en el lado de Containers. La primera vez, GitHub te pide instalar la app de Back4app Containers en los repositorios que elijas; la lista de la dashboard se actualiza en cuanto terminas.

**Configurar.** Selecciona el repositorio. El formulario detectó el Dockerfile y ofreció un **Node.js buildpack** como alternativa. Mantuvimos el Dockerfile, dejamos el puerto en automático (lee `EXPOSE 8080`), añadimos `PARSE_APP_ID` y `PARSE_REST_KEY` y pusimos el health check en `/healthz`, un route handler de cuatro líneas en el repositorio.

![El formulario de deploy de nextjs-notes: Dockerfile seleccionado con Node.js Buildpack como alternativa, dos variables de entorno con los valores ocultos](/blog/blog-assets/deploy-nextjs-app-github-backend/02-deploy-form-v2.jpg)

**Desplegar.** Hicimos clic a las 15:03:41. El botón mostró "Deploying…" durante 37 segundos antes de que apareciera la página de la app; después `PREPARING` a las 15:04:18, `BUILDING IMAGE` durante 5 minutos 19 segundos (`npm ci` y luego `next build` dentro de kaniko), `LAUNCHING CONTAINER` a las 15:09:37 y `DEPLOYMENT READY` a las 15:09:45.

Cómo lo medimos: un cronómetro desde el clic en Deploy hasta `DEPLOYMENT READY`, plan Free, un deploy el 15 de septiembre de 2026; los tres tiempos de build son tres deploys del mismo commit, leídos del log de build. `next build` domina, así que una app más grande construye más despacio, y nada se cachea entre builds.

`deploy-check.sh` llamó después a `/healthz`, cargó `/` y buscó `rendered on the server at`: presente, con una marca de tiempo que cambiaba en cada solicitud. Una nota creada a través de la REST API estaba en la página en la siguiente carga; nada se cacheó entre las dos.

![La página de notas desplegada: un campo de texto, un botón Add, dos notas con sus marcas de tiempo UTC y la línea 2 notes · rendered on the server at 2026-09-16T12:43:01.063Z](/blog/blog-assets/deploy-nextjs-app-github-backend/05-live-app-v2.jpg)

Después, el formulario. Un navegador headless escribió una nota e hizo clic en Add: el `POST` a la server action tardó 368 ms de ida y vuelta, y el backend tenía la fila nueva 300 ms después del clic. A continuación buscamos las dos claves en el HTML servido con grep: cero coincidencias.

![La página de notas justo después de añadir una nota desde el formulario, con la nota nueva la primera de la lista](/blog/blog-assets/deploy-nextjs-app-github-backend/06-note-added-v2.jpg)

## ¿Dónde viven las notas? El backend detrás de la página

Tres pantallas de la dashboard del backend, y cada fila que aparece en ellas la escribió el container de arriba: sin datos de semilla, sin schema definido a mano.

**Overview: de dónde salen las dos variables.** Dashboard → New App → Build your Backend → Create. La página Overview muestra el App ID y, en el desplegable Keys, la REST API key. Esos dos valores van al entorno del container y solo los lee `lib/backend.js`.

![El Overview del backend de nextjs-notes: App ID, el desplegable Keys en Rest Key (valor difuminado), Parse Server 7.5.2 y MongoDB 3.6 a la derecha](/blog/blog-assets/deploy-nextjs-app-github-backend/09-backend-overview-v2.jpg)

**Cloud Code: donde vive la regla.** `cloud/main.js` abierto en el editor con el hook `beforeSave("Note")`, y la insignia *Files pending deploy (1)* junto al botón Deploy. En un backend recién creado esa insignia es la pista: sigue ahí después del primer Deploy, y así sabes que no se envió nada.

![El editor de Cloud Code con cloud/main.js abierto mostrando el hook beforeSave de Note, y la insignia Files pending deploy (1) junto al botón Deploy](/blog/blog-assets/deploy-nextjs-app-github-backend/11-cloud-code-pending.jpg)

**Database Browser: la clase Note.** Seis filas: cuatro añadidas desde el formulario (la server action), una escrita a través de la REST API desde una terminal y la primera, *recortada por el hook*, que llegó rellena de espacios. Son las mismas filas que renderiza la página: el navegador muestra HTML, y esto muestra de dónde salió ese HTML.

![El Database Browser mostrando la clase Note con seis objetos y su columna text: cuatro notas añadidas desde el formulario, una escrita a través de la REST API y una recortada por el hook](/blog/blog-assets/deploy-nextjs-app-github-backend/10-database-note.jpg)

## ¿Cuánto dura la URL y cuánto cuesta un redeploy?

En el plan Free, la URL y el container viven 60 minutos desde que empieza el deploy, y un build de seis minutos se come seis de ellos. Lo vimos ocurrir: el log a las 16:04:12 decía `The Back4app custom domain has expired for free plan`, después `COOLING DOWN…`, `FINISHING CONTAINER…` y `DEPLOYMENT DESTROYED` a las 16:05:14. El estado en la dashboard pasó a **URL Expired**.

A la mañana siguiente cambiamos el plan a Shared (0,5 vCPU, 512 MB, US$ 5 al mes a 16 de septiembre de 2026). Sin ningún clic en Deploy, el log mostró `PREPARING DEPLOYMENT` a las 12:35:42 y `DEPLOYMENT READY` a las 12:38:57 en la misma URL `b4a.run`: 3 minutos 14 segundos, con el build de la imagen bajando de 5 min 19 s a 3 min 4 s.

![El Overview del container en el plan Shared: US$ 5,00 al mes, 0,5 vCPU y 512 MB, estado Available, origen GitHub main con auto, el deployment v1.1.0 Ready en 4m 35s](/blog/blog-assets/deploy-nextjs-app-github-backend/04-shared-plan-ready.jpg)

El plan de pago también desbloquea **Autodeploy** (Settings → Build & deploy). Lo activamos y subimos la versión con la corrección del error de más abajo a las 13:02:55. `PREPARING DEPLOYMENT` quedó registrado a las 13:02:58, antes de que el comando push hubiera terminado, y `DEPLOYMENT READY` a las 13:07:34.

Nuestra sonda vio la versión nueva 4 minutos 34 segundos después del push; el container antiguo siguió respondiendo hasta el cambio.

## ¿Por qué Next.js mostró "Application error" y qué más se rompió?

Rompimos la app a propósito, y la plataforma la rompió una vez por nosotros. Cada entrada empieza por lo que vas a ver de verdad.

**`Application error: a server-side exception has occurred while loading … (see the server logs for more information). Digest: 3998820666`.** Nuestra primera versión. El backend rechazó una nota en blanco, `createNote` lanzó el error, la server action lo relanzó y Next.js mostró su página de error genérica; el digest corresponde al stack trace en Runtime Logs. Captura el error en la action y haz `redirect("/?error=…")`; la página mostrará entonces el mensaje del backend junto al formulario.

![La página de error genérica de Next.js: Application error: a server-side exception has occurred while loading nextjsnotes-sz7qfr45.b4a.run, con un digest](/blog/blog-assets/deploy-nextjs-app-github-backend/07-application-error-v2.jpg)

Con la redirección en su sitio (v1.1.0, desplegada mediante autodeploy), el mismo envío en blanco responde `303` a `/?error=a%20note%20needs%20some%20text.` y la página muestra la frase del backend bajo el formulario:

![La página de notas tras un envío en blanco en la v1.1.0: el mensaje a note needs some text. en rojo bajo el formulario, con la lista de notas intacta](/blog/blog-assets/deploy-nextjs-app-github-backend/08-backend-message-v2.jpg)

**La página muestra notas antiguas después de una escritura.** La ruta se está prerenderizando o el fetch está cacheado. Hacen falta las dos cosas: `export const dynamic = "force-dynamic"` en la página y `cache: "no-store"` en el fetch; `revalidatePath("/")` en la action es lo que hace que la redirección aterrice en datos frescos.

**Cada carga de página es un `500` y Runtime Logs dice `⨯ Error: backend 403: {"error":"unauthorized"}`.** Una `PARSE_REST_KEY` incorrecta: `listNotes()` lanza el error dentro del server component, así que el visitante recibe la página de error genérica.

`/healthz` sigue en verde porque no toca el backend; haz que llame a `listNotes()` para que tu monitorización vea la clave incorrecta antes que nadie.

**El build tarda minutos y la hora gratuita corre.** Los 60 minutos empiezan en `PREPARING DEPLOYMENT`, antes del build. Nuestro primer build de seis minutos dejó 54 minutos de ejecución. Prueba en esa ventana; ejecuta en un plan de pago.

**`Success on deploying your changes!` pero el hook no hace nada.** El primer deploy de Cloud Code en un backend recién creado no envía ningún archivo. Busca `main.js not found` en Logs → System, edita el archivo en el editor y despliega otra vez.

## ¿Cuándo es un container la respuesta equivocada para Next.js?

Según nuestro propio manifiesto, un post en el que solo gana la plataforma no se publica, así que aquí está la otra columna.

- **Tus páginas son estáticas.** Un blog, un sitio de documentación, una página de marketing sin datos por solicitud: `output: "export"` y una CDN son más baratos, más rápidos y no necesitan ningún proceso.
- **Necesitas renderizado en el edge u optimización de imágenes en la CDN.** Esto es un container en una región; no hay runtime de edge y la optimización de `next/image` corre en la CPU del container.
- **Necesitas funciones de Postgres.** Joins, funciones de ventana, un schema que migras: el backend es Parse Server sobre MongoDB (Postgres en los planes de pago), consultado con JSON en `where`, no con SQL.
- **Los builds de seis minutos duelen.** Cada push reconstruye la imagen desde `npm ci`; si despliegas veinte veces al día, un host que cachee `node_modules` entre builds se sentirá más rápido.

Si ninguno de esos casos es el tuyo, un container Next.js renderizado en el servidor delante de un backend gestionado es la forma que elegiríamos otra vez, y los dos headers son toda la integración.

## ¿Qué construirías a continuación sobre la app de notas?

Tienes una página que se renderiza por solicitud, un formulario que escribe a través del servidor, una base de datos que nunca aprovisionaste y claves que el navegador nunca ve. A partir de aquí:

- **Dale propietarios a las notas.** La clase `User` de Parse añade endpoints de registro e inicio de sesión ([medido, con restablecimiento de contraseña](https://www.back4app.com/blog/add-signup-login-password-reset-without-an-auth-server)); guarda el session token en una cookie httpOnly y pásalo como `X-Parse-Session-Token` desde `lib/backend.js`.
- **Muestra los errores sin redirección.** `useActionState` en un client component mantiene en su sitio el estado del formulario y el mensaje; la versión con redirección de arriba no necesita JavaScript en el cliente.
- **Haz honesto el health check.** Haz que `/healthz` llame a `listNotes()` para que una clave incorrecta aparezca en tu monitorización y no ante el primer visitante; la comprobación de la plataforma solo necesita que el puerto responda.

Todo el código de arriba está completo y probado: el [repositorio companion](https://github.com/templates-back4app/nextjs-notes) incluye `deploy-check.sh`, que ejecuta las comprobaciones de health, renderizado en el servidor y datos contra cualquier URL de deploy que le pases.
