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, FastAPI con Docker, un worker siempre activo.
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 — 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.
El navegador recibe HTML. El container recibe dos variables de entorno. El backend recibe los datos. Nadie recibe un servidor.
¿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.
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:
// Stack: Next.js 15 App Router | File: app/page.js
import { listNotes } from "../lib/backend";
import { addNote } from "./actions";
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”:
// 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:
# 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.
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:
// Stack: Next.js 15 server action | File: app/actions.js
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { createNote } from "../lib/backend";
export async function addNote(formData) {
const text = String(formData.get("text") ?? "");
try {
await createNote(text); // the backend's beforeSave decides what a valid note is
} catch (err) {
redirect(`/?error=${encodeURIComponent(err.message)}`);
}
revalidatePath("/");
redirect("/");
}# Stack: bash | Endpoint: POST /classes/Note
curl -X POST \
-H "X-Parse-Application-Id: $APP_ID" \
-H "X-Parse-REST-API-Key: $REST_KEY" \
-H "Content-Type: application/json" \
-d '{"text": "written through the REST API while the container was up"}' \
"$BASE/classes/Note"
# {"objectId":"…","createdAt":"…"} ← HTTP 201, on the page at the next loadLa misma escritura, el mismo backend. La página se vuelve a renderizar con cualquiera de las dos.
¿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:
// 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.

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.

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.

¿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.

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.

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.

¿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 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.

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 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/imagecorre 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 cacheenode_modulesentre 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
Userde Parse añade endpoints de registro e inicio de sesión (medido, con restablecimiento de contraseña); guarda el session token en una cookie httpOnly y pásalo comoX-Parse-Session-Tokendesdelib/backend.js. - Muestra los errores sin redirección.
useActionStateen 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
/healthzllame alistNotes()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 incluye deploy-check.sh, que ejecuta las comprobaciones de health, renderizado en el servidor y datos contra cualquier URL de deploy que le pases.