Seu app Next.js renderiza no servidor, e esse é o ponto: o HTML chega completo, com os dados dentro. Isso também significa que o app precisa de um lugar para rodar que não é uma CDN — um processo que executa seus componentes a cada requisição — e de um lugar para guardar os dados que não é esse processo.
Este post é sobre renderização no servidor: um processo que executa seus componentes por requisição, uma fonte de dados que ele lê no momento da requisição e chaves que ficam no servidor. Um container roda next start para você, atrás de HTTPS. Um backend guarda as notas, valida cada uma e responde a uma chamada REST.
As outras três formas têm posts próprios: Node.js com Docker, FastAPI com Docker, um worker sempre ligado.
Construímos um app de notas — um server component que lista as notas, uma server action que as adiciona, 94 linhas no total incluindo o Dockerfile —, enviamos para o GitHub e o colocamos renderizando em um container, com os dados fora dele.
O primeiro deploy levou 6 minutos e 4 segundos, a maior parte em next build. Tudo abaixo vem dessa execução, em 15 e 16 de setembro de 2026, incluindo a página 500 que a nossa primeira versão mostrou.
Pegue o código funcionando: o repositório companion está em github.com/templates-back4app/nextjs-notes — app/page.js, app/actions.js, lib/backend.js, Dockerfile, cloud/main.js e deploy-check.sh, que verifica health, renderização no servidor e dados em qualquer URL de deployment.
O que um app renderizado no servidor precisa que um app estático não precisa?
Um processo, e uma fonte de dados que esse processo alcance. Um export estático é um conjunto de arquivos em uma CDN e não precisa de nenhum dos dois; no instante em que uma página lê dados no momento da requisição, algo precisa executar o seu código e algo precisa guardar os dados.
| O que você precisa | Por que não é opcional | Onde fica neste post |
|---|---|---|
node server.js rodando | Server components executam por requisição | Container, seção 03 |
| HTTPS em uma URL pública | Navegadores recusam HTTP puro para formulários | Container, automático |
| Um banco de dados que você não opera | Os dados sobrevivem ao container | Backend, seção 04 |
| Validação fora do container | A action é descartável; as regras não | Backend, seção 05 |
| Chaves que nunca chegam ao navegador | Variáveis de ambiente só no servidor, nada de NEXT_PUBLIC_ | Env do container, seção 03 |
A maioria dos hosts que rodam Next.js para nos dois primeiros, e os dados viram uma connection string para outra coisa que você paga e mantém. A Back4app é a que testamos porque a segunda metade é um produto da mesma conta: um app de backend com banco de dados gerenciado e uma REST API, chamada do container com dois headers.
O navegador recebe HTML. O container recebe duas variáveis de ambiente. O backend recebe os dados. Ninguém recebe um servidor.
Do que é feito o app de notas renderizado no servidor?
De uma página de notas. app/page.js é um server component: chama o backend, renderiza a lista e um formulário e carimba a hora em que renderizou. app/actions.js é uma server action: o formulário faz o post para ela, ela cria a nota no backend e a página renderiza de novo. lib/backend.js é o único arquivo que conhece as chaves.
A página. Duas linhas a mantêm dinâmica — sem elas, next build a pré-renderizaria uma única vez, com o que quer que o backend tivesse durante o 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>
);
}
O cliente do backend roda só no servidor. cache: "no-store" é a segunda metade do “por requisição”:
// 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;
}
O Dockerfile tem dois estágios. O primeiro roda next build; o segundo copia só a saída standalone, então a imagem não tem node_modules além do que o servidor precisa:
# 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"]
Você precisa de uma conta gratuita na Back4app, de uma conta no GitHub e de curl. Nada de Docker na sua máquina: a plataforma faz o build da imagem, e este post foi produzido em um Mac sem Docker instalado. Node só para rodar o app localmente antes.
De onde vêm os dados?
De um app de backend — na dashboard, New App → Build your Backend, dê um nome, Create. A página Overview mostra o App ID e, no dropdown Keys, a REST API key. Esses dois valores vão para o ambiente do container e para nenhum outro lugar.
Se o seu frontend chamasse o backend diretamente, a chave no navegador seria uma client key e as permissões por classe (CLP) fariam a guarda: esse caso tem post próprio.
A classe Note foi criada pelo primeiro POST — sem schema prévio (Parse Server 7.5.2, setembro de 2026). Aqui está essa escrita de duas formas: a que a server action faz e a que você pode fazer do seu terminal com as mesmas chaves. As duas apareceram na renderização seguinte da 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 loadMesma escrita, mesmo backend. A página renderiza de novo com qualquer uma das duas.
Por que a regra da nota é um hook no backend e não uma server action?
Porque a regra é sobre o que uma nota é. Uma server action roda só neste container e só para este formulário; o hook do backend roda para a action, para o curl acima e para o próximo app que escrever notas. O nosso faz trim no texto, rejeita nota vazia e 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);
});
Cole em Cloud Code → cloud/main.js na dashboard do backend, Deploy, e prove com uma requisição. O nosso respondeu 400 {"code":142,"error":"a note needs some text."} para três espaços e 201 com o texto já sem os espaços para uma nota válida cheia de padding — depois do segundo deploy.
Como fazer o deploy a partir do GitHub?
Envie o repositório, depois New App → Deploy from GitHub no lado de Containers. Na primeira vez, o GitHub pede para instalar o app Back4app Containers nos repositórios que você escolher; a lista na dashboard atualiza no momento em que você termina.
Configure. Selecione o repositório. O formulário detectou o Dockerfile e ofereceu um Node.js buildpack como alternativa. Mantivemos o Dockerfile, deixamos a porta em auto (ele lê EXPOSE 8080), adicionamos PARSE_APP_ID e PARSE_REST_KEY e apontamos o health check para /healthz, um route handler de quatro linhas no repositório.

Deploy. Clicamos às 15:03:41. O botão ficou em “Deploying…” por 37 segundos até a página do app aparecer; depois PREPARING às 15:04:18, BUILDING IMAGE por 5 minutos e 19 segundos (npm ci, depois next build dentro do kaniko), LAUNCHING CONTAINER às 15:09:37, DEPLOYMENT READY às 15:09:45.
Como medimos: um cronômetro do clique em Deploy até DEPLOYMENT READY, plano Free, um deploy em 15 de setembro de 2026; os três tempos de build são três deploys do mesmo commit, lidos do log de build. next build domina, então um app maior faz o build mais devagar, e nada é cacheado entre builds.
deploy-check.sh então bateu em /healthz, carregou / e procurou rendered on the server at — presente, com um timestamp que mudava a cada requisição. Uma nota criada pela REST API estava na página no carregamento seguinte; nada foi cacheado entre os dois.

Depois, o formulário. Um navegador headless digitou uma nota e clicou em Add: o POST para a server action levou 368 ms de ida e volta, e o backend tinha a nova linha 300 ms depois do clique. Em seguida fizemos grep no HTML servido pelas duas chaves: zero ocorrências.

Onde as notas ficam? O backend por trás da página
Em três telas da dashboard do backend, e cada linha nelas foi escrita pelo container acima — sem dados de seed, sem schema definido à mão.
Overview — de onde vêm as duas variáveis. Dashboard → New App → Build your Backend → Create. A página Overview mostra o App ID e, no dropdown Keys, a REST API key. Esses dois valores vão para o ambiente do container e são lidos só por lib/backend.js.

Cloud Code — onde a regra mora. cloud/main.js aberto no editor com o hook beforeSave("Note"), e o selo Files pending deploy (1) ao lado do botão Deploy. Em um backend novo, esse selo é o sinal: ele continua lá depois do primeiro Deploy, e é assim que você sabe que nada foi enviado.

Database Browser — a classe Note. Seis linhas: quatro adicionadas pelo formulário (a server action), uma escrita pela REST API a partir de um terminal e a primeira, trimmed by the hook, que chegou cheia de espaços. São as mesmas linhas que a página renderiza — o navegador mostra o HTML; isto mostra de onde o HTML veio.

Quanto tempo a URL dura, e quanto custa um redeploy?
No plano Free, a URL e o container vivem 60 minutos a partir do momento em que o deploy começa — e um build de seis minutos consome seis deles. Vimos acontecer: o log às 16:04:12 dizia The Back4app custom domain has expired for free plan, depois COOLING DOWN…, FINISHING CONTAINER…, DEPLOYMENT DESTROYED às 16:05:14. O status na dashboard virou URL Expired.
Na manhã seguinte, mudamos o plano para Shared (0,5 vCPU, 512 MB, US$ 5/mês em 16 de setembro de 2026). Sem nenhum clique em Deploy, o log mostrou PREPARING DEPLOYMENT às 12:35:42 e DEPLOYMENT READY às 12:38:57 na mesma URL b4a.run — 3 minutos e 14 segundos, com o build da imagem caindo de 5 min 19 s para 3 min 4 s.

O plano pago também libera o Autodeploy (Settings → Build & deploy). Ligamos e enviamos a versão com a correção de erro abaixo às 13:02:55. PREPARING DEPLOYMENT foi registrado às 13:02:58 — antes de o comando de push retornar — e DEPLOYMENT READY às 13:07:34. O poller viu a nova versão 4 minutos e 34 segundos depois do push; o container antigo respondeu até a troca.
Por que o Next.js mostrou “Application error”, e o que mais quebrou?
Quebramos o app de propósito, e a plataforma o quebrou uma vez para nós. Cada item começa com o que você vai ver de fato.
Application error: a server-side exception has occurred while loading … (see the server logs for more information). Digest: 3998820666. A nossa primeira versão. O backend rejeitou uma nota em branco, createNote lançou o erro, a server action o relançou, e o Next.js mostrou sua página de erro genérica — o digest aponta para o stack trace em Runtime Logs. Capture o erro na action e faça redirect("/?error=…"); a página passa a renderizar a mensagem do backend ao lado do formulário.

Com o redirect no lugar (v1.1.0, deploy feito pelo autodeploy), o mesmo envio em branco responde 303 para /?error=a%20note%20needs%20some%20text. e a página mostra a frase do backend abaixo do formulário:

A página mostra notas antigas depois de uma escrita. A rota está sendo pré-renderizada ou o fetch está cacheado. Os dois são necessários: export const dynamic = "force-dynamic" na página e cache: "no-store" no fetch; revalidatePath("/") na action é o que faz o redirect cair em dados frescos.
Todo carregamento de página é um 500, e Runtime Logs diz ⨯ Error: backend 403: {"error":"unauthorized"}. Uma PARSE_REST_KEY errada — listNotes() lança o erro dentro do server component, e o visitante recebe a página de erro genérica. /healthz continua verde porque não toca o backend; faça-o chamar listNotes() para que o seu monitoramento veja a chave errada primeiro.
O build leva minutos e a hora gratuita está correndo. Os 60 minutos começam em PREPARING DEPLOYMENT, antes do build. O nosso primeiro build de seis minutos deixou 54 minutos de execução. Teste nessa janela; rode em um plano pago.
Success on deploying your changes!, mas o hook não faz nada. O primeiro deploy de Cloud Code em um backend novo não envia arquivo nenhum. Procure main.js not found em Logs → System, edite o arquivo no editor e faça o deploy de novo.
Quando um container é a resposta errada para Next.js?
Pelo nosso próprio manifesto, um post em que só a plataforma ganha não é publicado — então aqui está a outra coluna.
- Suas páginas são estáticas. Um blog, um site de documentação, uma página de marketing sem dados por requisição:
output: "export"e uma CDN são mais baratos, mais rápidos e não precisam de processo nenhum. - Você precisa de renderização no edge ou de otimização de imagens na CDN. Isto é um container em uma região; não há edge runtime, e a otimização do
next/imageroda na CPU do container. - Você precisa de recursos do Postgres. Joins, window functions, um schema que você migra — o backend é Parse Server sobre MongoDB (Postgres nos planos pagos), consultado com JSON no
where, não com SQL. - Builds de seis minutos doem. Cada push refaz a imagem a partir do
npm ci; se você faz deploy vinte vezes por dia, um host que cacheianode_modulesentre builds vai parecer mais rápido.
Se nada disso descreve você, um container Next.js renderizado no servidor na frente de um backend gerenciado é a forma que escolheríamos de novo — e os dois headers são toda a integração.
O que construir em seguida sobre o app de notas?
Você tem uma página que renderiza por requisição, um formulário que escreve pelo servidor, um banco de dados que você nunca provisionou e chaves que o navegador nunca vê. A partir daqui:
- Dê donos às notas. A classe
Userdo Parse adiciona endpoints para criar conta e entrar (medido, com redefinição de senha); guarde o session token em um cookie httpOnly e passe-o comoX-Parse-Session-Tokena partir delib/backend.js. - Mostre erros sem redirect.
useActionStateem um client component mantém o estado do formulário e a mensagem no lugar; a versão com redirect acima não precisa de JavaScript no cliente. - Torne o health check honesto. Faça
/healthzchamarlistNotes()para que uma chave errada apareça no seu monitoramento, não para o primeiro visitante — a verificação da própria plataforma só precisa que a porta responda.
Todo o código acima está completo e testado — o repositório companion inclui deploy-check.sh, que roda as verificações de health, renderização no servidor e dados contra qualquer URL de deployment que você informar.