Nível 2Unidade 3 · Web dinâmica server-side3 aulas de 50 min + 1 h EAD
Aula 11 — Introdução ao Node.js e Express
Nível 2 — Desenvolvimento Web · WebLab
Na Aula 10 o Café Cerrado virou uma SPA: um único index.html, navegação por hash, fetch buscando data/produtos.json e uma API pública para treinar POST. Todo esse código rodou dentro do navegador de quem visita o site. Hoje começa a Unidade 3, e você troca de lado: em vez de consumir a API dos outros, você escreve o programa que fica esperando requisições e devolvendo respostas. É o mesmo JavaScript — const, arrow functions, async/await, objetos e arrays funcionam igualzinho — em outro endereço.
Na aula passada você fechou o cliente: fetch real, JSON.parse implícito com resposta.json(), estados de carregando/erro e navegação SPA por hash. A Unidade 2 acabou ali. Hoje a máquina que responde àqueles fetch deixa de ser da JSONPlaceholder e passa a ser sua, escrita em JavaScript, rodando na sua máquina na porta 3000.
Antes de começar, confirme:
[ ] Node.js 22 LTS instalado (node --version deve imprimir algo como v22.11.0). Instalação feita na Aula 01.
[ ] npm disponível (npm --version, versão 10 ou superior — vem junto com o Node).
[ ] Git configurado com seu nome e e-mail, e conta no GitHub ativa.
[ ] O repositório cafe-cerrado (Unidades 1 e 2) atualizado, com index.html, css/, js/app.js, img/ e data/produtos.json.
[ ] VS Code com a extensão REST Client instalada (procure por humao.rest-client). Vamos usá-la a partir da próxima aula.
[ ] Terminal integrado do VS Code funcionando (Ctrl+').
Até 2009, JavaScript era uma linguagem de navegador e ponto. O servidor era PHP, Java, Python, Ruby, C#. Nesse ano, Ryan Dahl pegou o V8 — o motor JavaScript que o Google tinha escrito para o Chrome, absurdamente rápido — e o colocou para rodar sozinho, fora do navegador, acoplado a uma biblioteca de entrada e saída não bloqueante.
O detalhe importante é esse "não bloqueante". Os servidores da época costumavam dedicar uma thread do sistema operacional a cada conexão: mil usuários simultâneos, mil threads, e a memória acabava. O Node adotou o modelo que você já conhece do navegador — uma thread, um event loop, callbacks — e resolveu o problema por outro caminho: enquanto uma requisição espera o disco ou o banco de dados responder, a thread não fica parada, ela atende as outras.
Aquele diagrama da Aula 09, com a pilha, a fila de tarefas e o event loop, é o mesmo aqui. A diferença é o que fica pendurado esperando: lá era um fetch ou um setTimeout; aqui é uma leitura de arquivo, uma consulta ao banco, uma conexão de rede que chegou.
🧠 Você sabia?
A demonstração que apresentou o Node.js ao mundo, na JSConf EU de 2009, era sobre uma barra de progresso. Ryan Dahl mostrou que, para exibir corretamente o progresso do upload de um arquivo, os servidores da época precisavam de gambiarras — porque a requisição só era entregue ao seu código depois de completamente recebida. Ele escreveu, ao vivo, um servidor de poucas linhas que reagia a cada pedaço do arquivo conforme ele chegava. A plateia aplaudiu de pé. Quinze anos depois, Node é a base do Netflix, do PayPal, do LinkedIn e do próprio npm.
Esta é a tabela que evita a maior parte da confusão das próximas semanas:
Existe no navegador
Existe no Node.js
Existe nos dois
document, window, DOM
fs (arquivos), path, http, process
const, let, arrow functions
localStorage, alert
require, module.exports, __dirname
async/await, Promises, fetch
Eventos de clique, addEventListener do DOM
Portas de rede, variáveis de ambiente
JSON, Array, Object, Map
Ou seja: não existe página no servidor. Não há document.querySelector, não há <div>, não há CSS. O servidor recebe texto (uma requisição HTTP) e devolve texto (uma resposta HTTP). Quem transforma esse texto em pixels é o navegador, do outro lado.
1.3 Primeiro contato: o Node como executor de scripts¶
Abra um terminal em uma pasta qualquer e crie um arquivo:
olamundo.js
JavaScript
// Isto é JavaScript comum — só que sem navegador em volta.constcidade="Sinop";constcafes=["coado","cappuccino","expresso"];console.log(`JavaScript rodando no terminal, em ${cidade}!`);console.log(`Temos ${cafes.length} tipos de café:`,cafes.join(", "));// Aqui está a primeira coisa que o navegador nunca deixaria você fazer:console.log("Esta pasta é:",process.cwd());console.log("Versão do Node:",process.version);
Rode:
Terminal
nodeolamundo.js
Saída esperada (o caminho varia na sua máquina):
Texto
JavaScript rodando no terminal, em Sinop!
Temos 3 tipos de café: coado, cappuccino, expresso
Esta pasta é: /home/aluno/projetos
Versão do Node: v22.11.0
O objeto global process representa o processo do sistema operacional em que o seu código está rodando: diretório atual, versão, variáveis de ambiente, argumentos da linha de comando. Ele não existe no navegador porque lá não há processo seu — há uma aba.
Agora tente, no mesmo arquivo, acrescentar console.log(document.title); e rodar de novo:
Texto
ReferenceError: document is not defined
Guarde esse erro. Ele é o sinal número 1 de que você colou código de front-end em um arquivo de back-end (ou o contrário).
🔬 Investigue
Digite só node no terminal, sem nome de arquivo, e aperte Enter. Você entrou no REPL (Read–Eval–Print Loop), um console idêntico ao do DevTools. Experimente: 2 + 2, [1,2,3].map(n => n * 10), typeof window, Object.keys(process.env).length. Compare a última resposta com o que você vê ao rodar printenv | wc -l em outro terminal. Para sair do REPL, .exit ou Ctrl+D. Anote: typeof window devolveu o quê? E no console do Chrome, o que devolveria?
Um projeto Node é uma pasta com um arquivo package.json — a certidão de nascimento do projeto. Ele guarda nome, versão, scripts e, principalmente, a lista de bibliotecas de que o projeto depende.
O -y aceita todas as respostas padrão. O resultado:
package.json
JSON
{"name":"cafe-cerrado-api","version":"1.0.0","main":"index.js","scripts":{"test":"echo \"Error: no test specified\" && exit 1"},"keywords":[],"author":"","license":"ISC"}
2.3 O acento circunflexo e o versionamento semântico¶
"express": "^5.1.0" não quer dizer "exatamente a versão 5.1.0". Os pacotes npm seguem o versionamento semântico (SemVer), em que o número tem três partes:
Parte
Exemplo
Quando aumenta
MAIOR
5.1.0
Mudança que quebra código existente (Express 4 → 5)
MENOR
5.1.0
Recurso novo, compatível com o que já existia
CORREÇÃO
5.1.0
Correção de bug, sem recurso novo
O ^ significa "esta versão ou qualquer atualização compatível": aceita 5.1.1, 5.2.0, 5.9.3 — mas nunca 6.0.0, porque a versão MAIOR pode quebrar tudo. É por isso que este curso usa Express 5 enquanto a maioria dos tutoriais da internet ainda mostra Express 4: são versões MAIORES diferentes, com diferenças reais de sintaxe (voltaremos a isso na seção 6).
O package-lock.json, por outro lado, guarda a versão exata que foi instalada hoje, na sua máquina. Ele existe para que quem clonar o projeto amanhã receba exatamente as mesmas versões, e não uma correção nova que apareceu no meio do caminho. Por isso: package-lock.json vai para o Git, sim.
node_modules/ pode ter dezenas de milhares de arquivos. Não é seu código — é código baixado, reconstruível a qualquer momento. Antes do primeiro commit, crie:
.gitignore
Texto
# dependências instaladas pelo npm — reconstruídas com "npm install"
node_modules/
# arquivos de ambiente com segredos — usaremos a partir da Aula 14
.env
# lixo de sistema operacional e de editor
.DS_Store
Thumbs.db
Quem clonar o repositório roda npm install e o npm reconstrói node_modules/ inteiro a partir do package.json e do package-lock.json.
⚠️ Atenção
Se você já commitou node_modules/ por engano, o .gitignore sozinho não resolve — ele só ignora arquivos ainda não rastreados. Rode git rm -r --cached node_modules e commite a remoção. O histórico continuará pesado, mas o próximo commit já fica limpo.
🔬 Investigue
Com o Express instalado, rode no terminal do projeto: find node_modules -type f | wc -l (Linux/macOS) ou (Get-ChildItem node_modules -Recurse -File).Count (PowerShell). Depois, du -sh node_modules para o tamanho total. Quantos arquivos o npm baixou para instalar um pacote? Agora rode npm ls express e depois npm ls --all | head -40: quem são as dezenas de dependências que vieram junto? Escreva em uma linha por que isso justifica a existência do .gitignore.
No front-end da Unidade 2 você usou módulos ES (import / export com type="module"). No servidor, o Node suporta os dois sistemas, e o padrão histórico — usado pelo Express, pela maior parte dos tutoriais e por este curso na Unidade 3 — é o CommonJS:
exemplos/produtos-de-teste.js
JavaScript
// Um módulo CommonJS: um arquivo que exporta algo.constprodutos=[{id:1,nome:"Espresso do Cerrado",preco:6},{id:2,nome:"Coado da Casa",preco:8.5},];module.exports=produtos;// isto é o que quem der require() vai receber
exemplos/uso.js
JavaScript
// Quem consome: o caminho relativo começa com ./ e a extensão é opcional.constprodutos=require("./produtos-de-teste");console.log("Produtos carregados:",produtos.length);console.log("Mais barato:",produtos.reduce((a,b)=>(a.preco<b.preco?a:b)).nome);
Comparando os dois sistemas:
CommonJS (servidor, este curso)
Módulos ES (front-end, Unidade 2)
const express = require("express")
import express from "express"
module.exports = router
export default router
exports.listar = fn
export function listar() {}
Para usar import/export no servidor, você acrescentaria "type": "module" ao package.json. Não faça isso agora: as aulas 12 a 16 usam require, e misturar os dois estilos no mesmo projeto é uma das fontes de erro mais irritantes do ecossistema Node.
⚠️ Atenção
Se escrever import express from "express" em um projeto CommonJS, o Node responde:
SyntaxError: Cannot use import statement outside a module. A correção é trocar por require — não é adicionar "type": "module" no meio do semestre.
Dá para escrever um servidor web usando só o que vem no Node, sem instalar nada. Vale ver uma vez, para você saber o que o Express está fazendo por baixo:
exemplos/servidor-sem-express.js
JavaScript
consthttp=require("node:http");constprodutos=[{id:1,nome:"Espresso do Cerrado",preco:6},{id:2,nome:"Coado da Casa",preco:8.5},];constservidor=http.createServer((req,res)=>{// Cada requisição cai aqui. Nós é que precisamos decidir tudo, na unha.if(req.method==="GET"&&req.url==="/api/produtos"){res.writeHead(200,{"Content-Type":"application/json; charset=utf-8"});res.end(JSON.stringify(produtos));return;}if(req.method==="GET"&&req.url==="/"){res.writeHead(200,{"Content-Type":"text/html; charset=utf-8"});res.end("<h1>Café Cerrado</h1>");return;}res.writeHead(404,{"Content-Type":"text/plain; charset=utf-8"});res.end("Nao encontrado");});servidor.listen(3000,()=>{console.log("Servidor sem framework em http://localhost:3000");});
Funciona. Mas repare no trabalho manual: comparar req.method e req.url com if, escrever o cabeçalho Content-Type na mão, serializar o JSON na mão, tratar o 404 na mão. Com dez rotas isso vira uma escada de if ilegível. É exatamente esse desconforto que o Express elimina.
constexpress=require("express");constapp=express();constPORTA=process.env.PORT||3000;app.get("/",(req,res)=>{res.send("<h1>Meu primeiro servidor Express!</h1>");});app.listen(PORTA,()=>{console.log(`Servidor rodando em http://localhost:${PORTA}`);});
Terminal
nodeserver.js
Abra http://localhost:3000 no navegador. Você acabou de atender uma requisição HTTP igual às que estudou na Aula 01 — só que do lado de dentro.
process.env.PORT lê uma variável de ambiente. Localmente ela não existe, então o || entrega 3000. Quando você publicar o projeto (trilha Deploy), o serviço de hospedagem define PORT e o seu código se adapta sem precisar de alteração. Duas linhas hoje, uma dor de cabeça a menos depois.
Leia assim: quando chegar uma requisição com o método GET no caminho /api/produtos, execute esta função.
app.get, app.post, app.put, app.delete — o método HTTP vira o nome da função. (No Express 5, app.del() foi removido: escreva app.delete().)
O primeiro argumento é o caminho (path), sempre começando com /.
O segundo é a função manipuladora (handler), que recebe req e res.
Reconheça o padrão: é o mesmo modelo de callback dos eventos do DOM. Lá, botao.addEventListener("click", (evento) => {}); aqui, app.get("/rota", (req, res) => {}). O "evento" agora é chegou uma requisição neste caminho.
Os pedaços variáveis do caminho: /produtos/:id → req.params.id
req.query
A query string: ?categoria=cafes&q=pao → req.query.categoria
req.headers
Os cabeçalhos HTTP enviados pelo navegador
res (response) é o que você vai devolver:
Método
O que faz
res.send(algo)
Devolve texto ou HTML, adivinhando o Content-Type
res.json(objeto)
Serializa para JSON e define Content-Type: application/json
res.status(404)
Define o código de status; encadeável: res.status(404).json({})
res.sendFile(caminho)
Devolve um arquivo do disco (caminho absoluto)
⚠️ Atenção
No Express 5, req.query é somente leitura. Tentar req.query.q = "cafe" derruba o servidor com TypeError: Cannot set property query of #<IncomingMessage> which has only a getter. Se precisar de uma versão modificada, copie para uma variável: const filtros = { ...req.query }.
📌 Vale gravar
Duas trocas do Express 4 para o 5 costumam confundir: res.json(obj, 201) virou res.status(201).json(obj), e res.redirect('/rota', 302) virou res.redirect(302, '/rota'). A regra mental é "status primeiro".
Alterou o server.js? O processo Node continua rodando o código antigo, carregado na memória quando você digitou node server.js. É preciso parar (Ctrl+C) e rodar de novo. Fazer isso quarenta vezes por aula é insuportável — e é a causa número 1 de "mas eu já corrigi e não mudou nada".
O Node 22 resolve isso nativamente:
Terminal
node--watchserver.js
Com o script dev do package.json, fica só:
Terminal
npmrundev
Saída esperada a cada salvamento:
Texto
Servidor rodando em http://localhost:3000
Restarting 'server.js'
Servidor rodando em http://localhost:3000
💡 Dica
Tutoriais mais antigos mandam instalar o pacote nodemon para isso. Ele continua funcionando, mas desde o Node 18/22 a flag --watch faz o mesmo sem dependência nenhuma. Uma dependência a menos é sempre uma boa notícia.
🔎 Por baixo do capô
O que acontece entre você digitar localhost:3000 e a página aparecer? (1) O navegador resolve localhost para o endereço 127.0.0.1 — a sua própria máquina — sem consultar DNS na internet. (2) Abre uma conexão TCP na porta 3000. (3) Envia um texto: GET / HTTP/1.1, mais os cabeçalhos. (4) O Node aceita a conexão, monta os objetos req e res e chama o handler que casou com método e caminho. (5) O seu res.send escreve de volta HTTP/1.1 200 OK, os cabeçalhos e o corpo. (6) O navegador lê o Content-Type, decide que é HTML e renderiza. Tudo isso em menos de um milissegundo, porque nada saiu da sua máquina.
Até agora você abria o site com o Live Server, um servidorzinho que a extensão do VS Code sobe para você. A partir de hoje o servidor é o seu — e ele também sabe entregar HTML, CSS, imagens e JavaScript.
Coloque o site inteiro em uma pasta public/ e acrescente uma linha:
server.js
JavaScript
constpath=require("node:path");constexpress=require("express");constapp=express();constPORTA=process.env.PORT||3000;// Entrega qualquer arquivo que exista dentro de public/app.use(express.static(path.join(__dirname,"public")));app.listen(PORTA,()=>{console.log(`Servidor rodando em http://localhost:${PORTA}`);});
Pronto: http://localhost:3000 devolve public/index.html, http://localhost:3000/css/estilo.css devolve o CSS, http://localhost:3000/img/logo.svg devolve a imagem. Nenhuma rota precisou ser escrita.
__dirname é uma variável que o CommonJS injeta em todo módulo: a pasta onde este arquivo está. Você poderia escrever express.static("public") e funcionaria — mas apenas se o terminal estivesse aberto exatamente na pasta do projeto, porque um caminho relativo é resolvido a partir do diretório de trabalho, não do arquivo. path.join(__dirname, "public") funciona sempre.
O express.static é registrado com app.use, e tudo que se registra com app.use entra em uma fila que é percorrida na ordem de registro. O express.static procura o arquivo pedido; se encontra, responde e encerra; se não encontra, passa a requisição adiante para as rotas seguintes.
JavaScript
app.use(express.static(path.join(__dirname,"public")));// Esta rota só é alcançada se NÃO existir um arquivo public/sobre.htmlapp.get("/sobre",(req,res)=>{res.send("<h1>Sobre o Café Cerrado</h1>");});
Se existir public/sobre.html, ele ganha — o arquivo estático é encontrado antes. Essa disputa entre arquivo e rota é fonte garantida de confusão; na próxima aula, ao estudar middlewares, a mecânica ficará explícita.
Tudo que está em public/ pode ser baixado por qualquer pessoa que saiba (ou adivinhe) o nome do arquivo. Isso é ótimo para CSS e imagens e péssimo para dados que você quer controlar.
Hoje o data/produtos.json do Café Cerrado está dentro do site: qualquer visitante pode abrir /data/produtos.json e ver o arquivo cru. Na nossa nova arquitetura ele sai de lá e passa a ficar fora de public/, acessível só pelo servidor. O visitante continua vendo os produtos — mas através do endpoint /api/produtos, que é código seu, e onde amanhã você poderá filtrar, esconder campos, exigir login ou registrar quem consultou.
⚠️ Atençãoexpress.static ignora dotfiles por padrão: um arquivo chamado .env dentro de public/ não é servido. Isso é uma rede de proteção, não uma permissão — segredo nenhum deve ficar em public/, ponto final.
Na Aula 10 você consumiu a JSONPlaceholder. Agora você é a JSONPlaceholder:
server.js (trecho)
JavaScript
constprodutos=[{id:1,nome:"Espresso do Cerrado",categoria:"cafes",preco:6},{id:2,nome:"Pão de Queijo Mineiro",categoria:"salgados",preco:7},];// GET /api/produtos → a lista completaapp.get("/api/produtos",(req,res)=>{res.json(produtos);// serializa para JSON e define o Content-Type});
res.json(produtos) faz três coisas: chama JSON.stringify no objeto, define o cabeçalho Content-Type: application/json; charset=utf-8 e envia. Sem ele, você teria que fazer os três passos na mão, como no exemplo com o módulo http.
Por que o caminho começa com /api? Convenção. Ela separa, na URL, o que é página para humanos (/, /cardapio.html) do que é dado para programas (/api/produtos). Quem lê a URL já sabe o que esperar.
// GET /api/produtos/2 → só o produto de id 2app.get("/api/produtos/:id",(req,res)=>{constid=Number(req.params.id);// params SEMPRE chegam como stringconstproduto=produtos.find((p)=>p.id===id);if(!produto){returnres.status(404).json({erro:"Produto não encontrado"});}res.json(produto);});
Três detalhes que valem cada um um bug evitado:
:id cria um parâmetro nomeado. Qualquer valor naquela posição casa: /api/produtos/2, /api/produtos/abc, /api/produtos/999.
req.params.id é string."2" === 2 é falso. Sem o Number(), o find nunca acha nada e a sua API responde 404 para tudo.
return antes do res.status(404). Sem ele, o código continua e tenta chamar res.json(produto) com produto valendo undefined — o Express reclama com Error: Cannot set headers after they are sent to the client.
O recurso pedido não existe (ou a rota não existe)
500 Internal Server Error
Algo quebrou dentro do seu servidor
Na próxima aula entram 201, 204, 400 e, na Aula 14, 401 e 403. Por hoje, três bastam — e a regra é sempre a mesma: o status é para a máquina, a mensagem no corpo é para a pessoa. Um fetch no front-end verifica resposta.ok (que é true só para status 200–299) e mostra a mensagem do corpo ao usuário.
⚠️ Atençãofetchnão rejeita a Promise quando o servidor responde 404 ou 500 — do ponto de vista da rede, a requisição foi um sucesso. Foi por isso que na Aula 09 você escreveu if (!resposta.ok) throw new Error(...). Aquele if continua obrigatório agora que o servidor é seu.
Manter a lista dentro do server.js funciona para dois produtos e trava na hora de crescer. Vamos ler de um arquivo JSON, com o módulo fs/promises — a versão do módulo de arquivos que devolve Promises, exatamente o que você aprendeu a manipular na Aula 09.
data/produtos.json
JSON
[{"id":1,"nome":"Espresso do Cerrado","categoria":"cafes","preco":6,"descricao":"Grãos de Alto Paraíso, torra média, corpo encorpado e final achocolatado.","imagem":"img/espresso.jpg"},{"id":2,"nome":"Coado da Casa","categoria":"cafes","preco":8.5,"descricao":"Duzentos mililitros em coador de papel, moagem média feita na hora do pedido.","imagem":"img/coado.jpg"}]
server.js (trecho)
JavaScript
constfs=require("node:fs/promises");constCAMINHO_DADOS=path.join(__dirname,"data","produtos.json");// Lê o arquivo e devolve um array de objetos JavaScript.asyncfunctionlerProdutos(){constconteudo=awaitfs.readFile(CAMINHO_DADOS,"utf-8");returnJSON.parse(conteudo);}app.get("/api/produtos",async(req,res)=>{constprodutos=awaitlerProdutos();res.json(produtos);});
Repare no async antes de (req, res). Ele é necessário porque temos um await dentro do handler — e o Express aceita handlers assíncronos sem qualquer configuração extra.
🔎 Por baixo do capô
Se data/produtos.json estiver corrompido, JSON.parse lança um erro. No Express 4, um throw dentro de um handler async não era capturado pelo framework: a Promise rejeitava em silêncio, a requisição ficava pendurada até o navegador desistir, e o processo Node inteiro corria risco de morrer com UnhandledPromiseRejection. Era por isso que todo tutorial mandava embrulhar tudo em try/catch ou instalar o pacote express-async-handler. No Express 5, o framework embrulha cada handler async internamente: qualquer exceção lançada — síncrona ou dentro de um await — é capturada e encaminhada ao tratador de erros. Você ganha um 500 limpo em vez de um servidor travado. Na próxima aula vamos escrever esse tratador.
O navegador só sabe fazer GET. Para os outros métodos (e para ver o status e os cabeçalhos), use o curl:
Terminal
# lista completa; -i mostra os cabeçalhos junto com o corpo
curl-ihttp://localhost:3000/api/produtos
# um item existente
curl-ihttp://localhost:3000/api/produtos/1
# um item inexistente: deve responder 404
curl-ihttp://localhost:3000/api/produtos/999
Saída esperada da última chamada (o curl mostra ainda outros cabeçalhos; estes são os que importam):
Texto
HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
Content-Length: 34
{"erro":"Produto não encontrado"}
Na próxima aula você troca o curl por um arquivo testes.http versionado junto com o projeto — muito mais confortável.
💻 Mão na massa — o Café Cerrado ganha um servidor¶
Vamos criar o repositório da Unidade 3 inteira. Ele nasce hoje e será o mesmo até a Aula 16.
Se aparecer express@4.x, você está com um package.json antigo ou com cache estranho: apague node_modules/ e package-lock.json e rode npm install express@5 de novo.
# dependências instaladas pelo npm — reconstruídas com "npm install"
node_modules/
# arquivos de ambiente com segredos — usaremos a partir da Aula 14
.env
# lixo de sistema operacional e de editor
.DS_Store
Thumbs.db
Confira que funcionou:
Terminal
gitstatus--short
node_modules/não pode aparecer na lista. Se aparecer, o .gitignore está no lugar errado (tem que estar na raiz do projeto) ou com o nome errado (é .gitignore, com ponto na frente).
{"name":"cafe-cerrado-api","version":"1.0.0","description":"API e site do Café Cerrado — Sinop/MT","main":"server.js","scripts":{"dev":"node --watch server.js","start":"node server.js"},"keywords":["express","api","cafeteria"],"author":"Seu Nome","license":"ISC","dependencies":{"express":"^5.1.0"}}
No Windows, use o Explorador de Arquivos: copie index.html, css/, js/ e img/ para dentro de public/, e data/produtos.json para uma pasta data/ na raiz do projeto (fora de public/).
Vale saber o que veio em cada peça, porque nenhuma delas é descartável:
Arquivo copiado
O que é
O que acontece com ele hoje
index.html
A página única da SPA, com as três <section data-rota>
Continua igual; o Express passa a servi-lo em /
css/estilo.css
Todo o CSS das Aulas 02 a 06
Continua igual
js/api.js
A camada de acesso a dados da Aula 10
Duas linhas mudam no Passo 7: as URLs
js/roteador.js
O roteador por hash da Aula 10
Continua igual — é ele que mostra e esconde as telas
js/app.js
A aplicação (filtros, cards, carrinho, contato)
Continua igual, sem uma vírgula de diferença
img/
As fotos dos dez produtos
Continua igual
data/produtos.json
O cardápio
Sai de public/ e vai para a raiz: agora é dado do servidor
data/categorias.json
A lista de categorias da Aula 10
Não é copiado: a partir de hoje quem responde categorias é a API
A estrutura final:
Texto
cafe-cerrado-api/
├── data/
│ └── produtos.json # dados: FORA de public/, só o servidor lê
├── public/ # o site das Unidades 1 e 2
│ ├── css/
│ │ └── estilo.css
│ ├── img/
│ ├── js/
│ │ ├── api.js
│ │ ├── app.js
│ │ └── roteador.js
│ └── index.html
├── .gitignore
├── package.json
├── package-lock.json
└── server.js
O arquivo que você copiou no passo anterior já é o cardápio certo — são os mesmos dez produtos das Aulas 03 a 10, com os mesmos ids, preços e categorias. Não reescreva nada: o data/produtos.json do cafe-cerrado-api tem de ser byte por byte igual ao do cafe-cerrado. Confira abaixo se o seu bate.
data/produtos.json
JSON
[{"id":1,"nome":"Espresso do Cerrado","categoria":"cafes","preco":6,"descricao":"Grãos de Alto Paraíso, torra média, corpo encorpado e final achocolatado.","imagem":"img/espresso.jpg"},{"id":2,"nome":"Coado da Casa","categoria":"cafes","preco":8.5,"descricao":"Duzentos mililitros em coador de papel, moagem média feita na hora do pedido.","imagem":"img/coado.jpg"},{"id":3,"nome":"Cappuccino Sinop","categoria":"cafes","preco":12,"descricao":"Espresso duplo, leite vaporizado e canela do Cerrado por cima.","imagem":"img/cappuccino.jpg"},{"id":4,"nome":"Latte de Baunilha","categoria":"cafes","preco":14,"descricao":"Espresso, leite vaporizado e calda de baunilha feita na casa.","imagem":"img/latte.jpg"},{"id":5,"nome":"Cold Brew da Chapada","categoria":"geladas","preco":15,"descricao":"Extração a frio por dezoito horas, servida com gelo e rodela de laranja.","imagem":"img/cold-brew.jpg"},{"id":6,"nome":"Frappê de Café","categoria":"geladas","preco":16,"descricao":"Espresso batido com gelo, leite e chantili. Também sai sem lactose.","imagem":"img/frappe.jpg"},{"id":7,"nome":"Pão de Queijo Mineiro","categoria":"salgados","preco":7,"descricao":"Porção com quatro unidades de polvilho azedo com queijo canastra.","imagem":"img/pao-de-queijo.jpg"},{"id":8,"nome":"Torta de Frango","categoria":"salgados","preco":13,"descricao":"Fatia generosa com massa amanteigada e recheio de frango desfiado.","imagem":"img/torta-de-frango.jpg"},{"id":9,"nome":"Bolo de Milho Verde","categoria":"doces","preco":9.5,"descricao":"Fatia de bolo cremoso feito com milho da feira do produtor.","imagem":"img/bolo-de-milho.jpg"},{"id":10,"nome":"Brownie de Castanha","categoria":"doces","preco":11,"descricao":"Chocolate meio amargo com castanha-do-pará. Sem glúten.","imagem":"img/brownie.jpg"}]
⚠️ Atenção
Este arquivo é o contrato entre o seu back-end e o seu front-end. O js/app.js que veio da Aula 10 filtra por produto.categoria === "geladas", mostra produto.imagem e formata produto.preco — se você renomear uma categoria ou apagar um campo aqui, a SPA quebra sem dar erro no servidor. Da Aula 12 até a 16 esses dez objetos continuam sendo a base; o que muda é quem os serve.
constpath=require("node:path");constfs=require("node:fs/promises");constexpress=require("express");constapp=express();constPORTA=process.env.PORT||3000;constCAMINHO_DADOS=path.join(__dirname,"data","produtos.json");// As quatro categorias do cardápio, na ordem em que aparecem no site.// Este array substitui o data/categorias.json da Aula 10: a partir de hoje,// quem publica a lista de categorias é a API, não um arquivo do front-end.constCATEGORIAS=[{id:"cafes",nome:"Cafés"},{id:"geladas",nome:"Bebidas geladas"},{id:"salgados",nome:"Salgados"},{id:"doces",nome:"Doces"},];// ---------------------------------------------------------------// Arquivos estáticos: o site das Unidades 1 e 2, servido por nós.// ---------------------------------------------------------------app.use(express.static(path.join(__dirname,"public")));// ---------------------------------------------------------------// Acesso aos dados// ---------------------------------------------------------------asyncfunctionlerProdutos(){constconteudo=awaitfs.readFile(CAMINHO_DADOS,"utf-8");returnJSON.parse(conteudo);}// ---------------------------------------------------------------// API// ---------------------------------------------------------------// GET /api/produtos → lista completa do cardápioapp.get("/api/produtos",async(req,res)=>{constprodutos=awaitlerProdutos();res.json(produtos);});// GET /api/produtos/:id → um produto, ou 404app.get("/api/produtos/:id",async(req,res)=>{constid=Number(req.params.id);constprodutos=awaitlerProdutos();constproduto=produtos.find((p)=>p.id===id);if(!produto){returnres.status(404).json({erro:`Produto ${req.params.id} não encontrado`});}res.json(produto);});// GET /api/categorias → [{ id, nome }], só as categorias que têm produto hoje.// O formato é o MESMO do antigo data/categorias.json, porque o front-end da// Aula 10 monta o <select> lendo categoria.id e categoria.nome.app.get("/api/categorias",async(req,res)=>{constprodutos=awaitlerProdutos();constusadas=newSet(produtos.map((p)=>p.categoria));res.json(CATEGORIAS.filter((categoria)=>usadas.has(categoria.id)));});// ---------------------------------------------------------------app.listen(PORTA,()=>{console.log(`Café Cerrado no ar em http://localhost:${PORTA}`);});
Rode:
Terminal
npmrundev
Texto
Café Cerrado no ar em http://localhost:3000
Repare na decisão do /api/categorias: ele não devolve ["cafes", "doces", "geladas", "salgados"]. Devolveria, se fosse só um [...new Set(…)] — e o <select> do cardápio ficaria com quatro opções escritas [object Object] ou, pior, com os ids técnicos na cara do usuário. O front-end da Aula 10 espera objetos com id (o valor da <option>) e nome (o texto). Um endpoint só é "pronto" quando devolve o formato que o consumidor precisa; a API existe para servir o cliente, não o contrário.
Aqui está a recompensa de ter concentrado todo o fetch em um arquivo só na Aula 10. O front-end inteiro — o roteador por hash, o index.html com as três <section data-rota>, os ids #cards, #status-cardapio, #resumo, #filtro-categoria, o formulário de contato — fica exatamente como está. Você vai editar duas linhas, as duas dentro de public/js/api.js:
public/js/api.js (trecho — o resto do arquivo não muda)
É isso. Salve, recarregue http://localhost:3000 e o cardápio aparece — agora vindo do seu servidor.
Três detalhes valem o comentário:
A barra inicial importa."/api/produtos" é um caminho absoluto na origem: sempre resolve para http://localhost:3000/api/produtos, esteja você em /, em /index.html#/cardapio ou em qualquer rota futura. Sem a barra, o navegador resolveria em relação à página atual e um dia buscaria /alguma/pasta/api/produtos.
Mesma origem, zero CORS. O site e a API saem do mesmo http://localhost:3000, então nenhum cabeçalho de CORS precisa ser configurado. Aquele erro de Access-Control-Allow-Origin da Aula 10 simplesmente não existe aqui — e é por isso que servir o front pelo próprio Express é o caminho mais curto até uma aplicação que funciona.
data/categorias.json foi aposentado. Ele não existe mais no cafe-cerrado-api (o cp do Passo 4 copiou só o produtos.json), e a lista de categorias passa a nascer no server.js. Se você ainda tiver o arquivo no repositório antigo, deixe-o lá como histórico — mas nada mais o lê.
⚠️ Atenção
Não apague o js/roteador.js nem troque o <script type="module"> por um script clássico. A SPA da Aula 10 depende dos dois: sem o roteador, as <section data-rota> continuam com hidden e a tela do cardápio nunca aparece, mesmo com a API respondendo 200. Se o cardápio "sumiu" depois desta aula, o primeiro lugar para olhar é o console — um erro de import derruba o módulo inteiro em silêncio visual.
Com npm run dev rodando, abra http://localhost:3000. O site aparece sem Live Server, e o link Cardápio do menu leva a http://localhost:3000/#/cardapio com os dez cards.
Abra o DevTools na aba Network, recarregue e clique na requisição produtos. Confira: Status200, Request URLhttp://localhost:3000/api/produtos, aba Response com o array JSON de dez objetos.
Confira o <select> de categoria: cinco opções ("Todas" mais as quatro), com os textos "Cafés", "Bebidas geladas", "Salgados" e "Doces" — não com os ids técnicos.
No terminal, curl -i http://localhost:3000/api/produtos/3 deve devolver o Cappuccino Sinop com status 200.
curl -i http://localhost:3000/api/produtos/999 deve devolver 404 e {"erro":"Produto 999 não encontrado"}.
curl http://localhost:3000/api/categorias deve devolver [{"id":"cafes","nome":"Cafés"},{"id":"geladas","nome":"Bebidas geladas"},{"id":"salgados","nome":"Salgados"},{"id":"doces","nome":"Doces"}].
Edite data/produtos.json, mude um preço, salve e recarregue a página do navegador. O preço novo aparece sem reiniciar nada: o arquivo é lido a cada requisição.
Renomeie data/produtos.json para data/produtos-x.json e recarregue o cardápio. O front-end mostra a mensagem de erro e o botão "Tentar de novo" da Aula 09 — a camada de erro que você escreveu continua valendo com a API nova. Volte o nome depois.
Commit:
Terminal
gitadd.
gitcommit-m"Servidor Express servindo o site e a API de produtos"
gitbranch-Mmain
gitremoteaddoriginhttps://github.com/SEU-USUARIO/cafe-cerrado-api.git
gitpush-uoriginmain
A1. Sem rodar, diga o que cada comando abaixo imprime (ou que erro dá). Depois rode e confira.
Terminal
node-e"console.log(typeof window)"
node-e"console.log(typeof process)"
node-e"console.log([1,2,3].map(n => n * 2))"
node-e"console.log(document.title)"
A2. O package.json de um projeto tem "express": "^5.1.0". Quais destas versões o npm install aceitaria instalar: 5.1.4, 5.3.0, 6.0.0, 5.0.9? Justifique cada uma em meia linha.
A3. Dado o server.js do Passo 6, o que acontece com uma requisição para http://localhost:3000/api/produtos/dois? Qual o status e qual o corpo da resposta? Explique o papel do Number() nesse resultado.
A4. Existe um arquivo public/api/produtos (sem extensão) com o texto oi. Qual das duas respostas o servidor devolve para GET /api/produtos: o arquivo ou a rota? Aponte a linha do server.js que decide isso.
A5. Complete o código para que a rota devolva apenas os produtos da categoria pedida, ignorando maiúsculas. Se a query string não vier, devolva a lista inteira.
JavaScript
app.get("/api/produtos",async(req,res)=>{constprodutos=awaitlerProdutos();constcategoria=req.query.categoria;// Escreva aqui: filtre por categoria quando ela existir.res.json(produtos);});
A6. Um colega jura que corrigiu o bug, salvou o arquivo, e o navegador continua mostrando a resposta antiga. Ele está rodando node server.js. Cite duas causas possíveis e como distinguir uma da outra em dez segundos.
B1. Endpoint de busca. Acrescente ao server.js a rota GET /api/produtos/busca/:termo, que devolve os produtos cujo nome contenha o termo, sem diferenciar maiúsculas de minúsculas.
Resultado esperado: curl http://localhost:3000/api/produtos/busca/cafe devolve os dois cafés; curl http://localhost:3000/api/produtos/busca/xyz devolve [] com status 200 (lista vazia não é erro).
Dica
Registre esta rota antes de /api/produtos/:id, senão busca vira o valor de :id e o Number("busca") produz NaN. String.prototype.includes combinado com toLowerCase() nos dois lados resolve a comparação.
B2. Estatísticas do cardápio. Crie GET /api/estatisticas, que devolve um objeto com total (quantidade de produtos), precoMedio (arredondado para duas casas) e maisCaro (o nome do produto de maior preço).
Resultado esperado: com o cardápio de hoje, {"total":10,"precoMedio":11.2,"maisCaro":"Frappê de Café"}.
Dica
reduce para somar os preços (Aula 08), Number(x.toFixed(2)) para arredondar mantendo o tipo número, e outro reduce (ou sort seguido de at(-1)) para o mais caro.
B3. A página que o servidor monta. Crie a rota GET /cardapio-texto que devolve uma resposta em texto puro (não JSON) listando os produtos, um por linha, no formato Nome — R$ 7,50.
Resultado esperado: abrir http://localhost:3000/cardapio-texto no navegador mostra seis linhas de texto sem formatação, e curl -i confirma Content-Type: text/html ou text/plain.
Dica
res.type("text/plain") antes de res.send(...) define o cabeçalho. Para juntar as linhas, produtos.map(...).join("\n").
B4. Servidor sem framework. Reescreva só o endpoint GET /api/produtos em um arquivo exemplos/servidor-sem-express.js, usando apenas o módulo node:http, e rode na porta 3001.
Resultado esperado: curl -i http://localhost:3001/api/produtos devolve o mesmo JSON e o mesmo Content-Type que a versão Express. Escreva em duas linhas, no topo do arquivo, o que o Express fez por você.
Dica
Você vai precisar escrever res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }) e res.end(JSON.stringify(produtos)) na mão — é justamente esse o ponto do exercício.
C1. Paginação no servidor. Faça GET /api/produtos aceitar ?pagina=2&limite=2 e devolver um objeto — não mais um array cru — no formato { "dados": [...], "pagina": 2, "limite": 2, "total": 6, "totalPaginas": 3 }. Sem query string, o comportamento padrão deve ser pagina=1 e limite=10. Depois, ajuste o public/js/app.js para continuar funcionando com o novo formato e acrescente dois botões, "Anterior" e "Próxima", que ficam desabilitados nos extremos.
Resultado esperado: curl "http://localhost:3000/api/produtos?pagina=3&limite=2" devolve os dois últimos produtos e "totalPaginas":3; na tela, os botões navegam e o botão "Anterior" nasce desabilitado.
Dica
slice((pagina - 1) * limite, pagina * limite) faz o recorte. Valide os dois parâmetros: Number("abc") é NaN, e NaN em um slice devolve resultados estranhos — use Number.isInteger(x) && x > 0 antes de confiar. No front, guarde a página atual em uma variável de módulo e chame carregarCardapio() de novo a cada clique.
O front-end do Café Cerrado precisa montar os botões de filtro por categoria, mas hoje ele só sabe pedir a lista inteira de produtos e deduzir as categorias no navegador — o que significa baixar seis produtos completos (com descrição e imagem) só para descobrir quatro palavras. O server.js desta aula já tem um GET /api/categorias que resolve metade do problema: ele devolve ["cafes","doces","geladas","salgados"], mas o front precisa também de quantos itens há em cada uma, para mostrar "Cafés (2)". Faça a API entregar exatamente o que a tela precisa, e nem um byte a mais.
Critérios de pronto
GET /api/categorias devolve um array de objetos com nome e quantidade, ordenado por nome.
A soma dos quantidade é igual ao total de produtos do arquivo (confira com /api/produtos).
Acrescentar um produto de categoria nova em data/produtos.json faz a nova categoria aparecer na resposta sem alterar uma linha de código.
O front-end monta os botões de filtro a partir desse endpoint, exibindo o rótulo no formato Cafés (2).
Um comentário de duas linhas no server.js compara o tamanho da resposta de /api/categorias com o de /api/produtos (use curl -s ... | wc -c).
Pistas
O reduce da Aula 08 monta um objeto contador: produtos.reduce((acc, p) => { acc[p.categoria] = (acc[p.categoria] ?? 0) + 1; return acc; }, {}).
Object.entries(contagem) devolve pares [nome, quantidade] — de onde sai o map para o formato final.
Para ordenar textos em português (com acento), array.sort((a, b) => a.nome.localeCompare(b.nome, "pt-BR")).
Rótulos bonitos ("Cafés" em vez de "cafes") são um problema de apresentação: resolva no front, com um objeto de tradução, e não polua a API.
Abra data/produtos.json, apague uma vírgula qualquer no meio e salve. Agora recarregue o site. O que você vê? Provavelmente a página em branco, um erro feio no terminal e um usuário que nunca vai saber o que aconteceu. O arquivo é a única fonte de dados da sua API — e ele pode estar corrompido, sumido ou sem permissão de leitura. Descubra exatamente o que o Express 5 faz nesse caso, prove com evidência, e transforme o desastre em uma resposta honesta.
Critérios de pronto
Um arquivo docs/investigacao-erro.md registra: a mensagem literal do terminal, o status HTTP devolvido ao cliente e o corpo da resposta, nos três cenários (JSON inválido, arquivo apagado, arquivo com permissão negada).
Com o JSON inválido, a API responde 500 com um corpo JSON { "erro": "..." } — nunca uma página HTML de erro, nunca o caminho do arquivo no seu computador.
Com o arquivo apagado, a resposta é 500 e o terminal mostra um log com a mensagem completa do erro (incluindo o código ENOENT).
O site mostra a mensagem de falha do catch em vez de ficar em branco.
Uma frase no docs/investigacao-erro.md explica por que expor error.stack ao cliente é um risco de segurança.
Pistas
Rode curl -i http://localhost:3000/api/produtos com o JSON quebrado antes de escrever qualquer código: o Express 5 já responde algo. O quê, exatamente?
Para simular permissão negada no Linux/macOS: chmod 000 data/produtos.json (e chmod 644 depois para desfazer).
try { ... } catch (erro) { ... } dentro de lerProdutos permite distinguir os casos: erro.code === "ENOENT" é arquivo ausente; um SyntaxError é JSON inválido.
A resposta ao cliente deve ser genérica; o detalhe vai para o console.error. Esse par — log detalhado no servidor, mensagem genérica ao cliente — é a regra que vamos formalizar em um middleware na próxima aula.
"Por que instalar um framework se o Node já tem um servidor embutido?" É uma pergunta legítima, e a resposta "porque todo mundo usa" não vale nada em uma prova ou em uma entrevista. Reimplemente a sua API inteira — as três rotas mais os arquivos estáticos — usando apenasnode:http, node:fs/promises e node:path, e depois defenda uma das duas versões com evidência, não com opinião.
Critérios de pronto
exemplos/servidor-sem-express.js responde, na porta 3001, exatamente às mesmas quatro coisas que a versão Express: /api/produtos, /api/produtos/:id, /api/categorias e os arquivos de public/.
Os Content-Type batem com os da versão Express para HTML, CSS, JavaScript, imagem e JSON.
Uma tabela no README.md compara as duas versões em três colunas: linhas de código, o que foi difícil, o que o Express faz de graça.
O curl -i de /api/produtos/999 devolve 404 com o mesmo corpo nas duas versões.
Uma nota no README.md explica o que aconteceu ao tentar servir os arquivos estáticos na mão (esse é o ponto onde o Express ganha por muitos comprimentos).
Pistas
new URL(req.url, "http://localhost") separa caminho e query string de graça — o req.url cru traz os dois grudados.
Parâmetros de rota, sem framework, viram manipulação de string: caminho.split("/") e comparação de posições.
Para os estáticos: leia o arquivo com fs.readFile, escolha o Content-Type por extensão (path.extname) e devolva 404 quando o readFile lançar ENOENT.
Cuidado com a travessia de diretório: uma requisição para /../data/produtos.json não pode devolver nada. Compare o caminho resolvido com path.resolve antes de abrir o arquivo — é justamente esse tipo de detalhe que o express.static já trata.
Abra o DevTools na aba Network, recarregue o site e olhe a coluna Size do estilo.css: em vez do tamanho em kB, aparece (disk cache) ou o status 304. O Express fez isso sozinho para os arquivos estáticos — mas a sua rota /api/produtos baixa o JSON inteiro toda vez, mesmo quando nada mudou no cardápio. Em uma cafeteria com trezentos produtos e mil visitas por dia, isso é tráfego jogado fora. Descubra como o navegador e o servidor combinam "não mudou nada desde a última vez" e aplique o mesmo mecanismo à sua API.
Critérios de pronto
Um documento docs/cache.md explica, com as requisições capturadas no DevTools, o que são ETag, If-None-Match, Last-Modified e If-Modified-Since, e qual deles o express.static usou no seu CSS.
GET /api/produtos passa a devolver um cabeçalho ETag calculado a partir do conteúdo do arquivo.
Uma segunda requisição enviando If-None-Match com o mesmo valor recebe 304 Not Modifiedsem corpo.
Alterar data/produtos.json muda o ETag e a requisição seguinte volta a receber 200 com o corpo completo.
Uma tabela no docs/cache.md compara os bytes transferidos antes e depois, medidos com curl -s -w '%{size_download}\n' -o /dev/null.
Uma frase justifica por que um Cache-Control: max-age=3600 puro seria a escolha errada para um cardápio que o dono edita durante o dia.
Pistas
Comece medindo: curl -i http://localhost:3000/css/estilo.css duas vezes, a segunda com -H "If-None-Match: <valor que veio>".
Um ETag é só uma impressão digital do conteúdo. require("node:crypto").createHash("sha1").update(conteudo).digest("hex") serve muito bem.
O cabeçalho enviado pelo cliente chega em req.headers["if-none-match"] — sempre em minúsculas, sempre string.
res.status(304).end() encerra sem corpo. Enviar corpo junto com 304 é violação da especificação HTTP e alguns clientes engasgam.
Para o caso do cardápio editado durante o dia, investigue a diretiva no-cache — que, apesar do nome, não significa "não guarde", e sim "guarde, mas confirme antes de usar".
No seu projeto autoral, crie o repositório da Unidade 3 e repita a arquitetura de hoje com os seus dados:
Crie o repositório <seu-projeto>-api no GitHub, com npm init -y, npm install express e .gitignore contendo node_modules/.
Mova o site das Unidades 1 e 2 para public/ e sirva-o com express.static. O Live Server deve deixar de ser necessário.
Tire o arquivo de dados de dentro de public/ e coloque-o em data/<seu-recurso>.json, com no mínimo seis itens e os mesmos seis campos do modelo (id, nome, categoria, preco, descricao, imagem — adapte os nomes ao seu domínio).
Implemente GET /api/<seu-recurso> e GET /api/<seu-recurso>/:id, com 404 correto para id inexistente.
Aponte o fetch do seu front-end para a sua própria API e confirme os cards renderizando.
Acrescente ao README.md uma seção "Como rodar" com os três comandos necessários (npm install, npm run dev, endereço).
Critério de pronto: clonando o repositório em uma pasta vazia e rodando npm install && npm run dev, o site abre em http://localhost:3000 com os dados vindos da API, e curl -i http://localhost:3000/api/<seu-recurso>/999 devolve 404.
QUEIRÓS, Ricardo; PORTELA, Filipe. Introdução ao Desenvolvimento Moderno para a Web. FCA — capítulo sobre a camada de back-end com Node.js.
PUREWAL, Semmy. Aprendendo a Desenvolver Aplicações Web. Novatec — introdução ao Node.js e ao ecossistema npm.
Na próxima aula o server.js de arquivo único chega ao seu limite: as rotas ganham arquivo próprio com express.Router, o servidor aprende a receber dados de um POST com express.json(), e você conhece a peça que sustenta o Express inteiro — o middleware — junto com o tratamento centralizado de erros e o 404 da API.