Exemplo — Aplicação Full-Stack
Uma aplicação full-stack com frontend em Next.js, API em Express e banco PostgreSQL.
Estrutura do projeto
A API e o frontend viram dois projetos HTTP. Neste exemplo, eles ficam em repositórios separados. Se preferir um único repositório (monorepo), crie os dois projetos a partir dele e preencha o Diretório raiz de cada um com a pasta correspondente, como todo-api e todo-web; veja Diretório raiz e monorepos.
todo-api/
├── index.js
├── package.json
└── package-lock.json
todo-web/
├── app/
│ ├── api/
│ │ └── todos/
│ │ └── route.ts
│ ├── layout.tsx
│ └── page.tsx
├── package.json
└── package-lock.jsonO build de projetos Node.js instala as dependências com npm ci, que exige o package-lock.json. Rode npm install em cada repositório e envie o arquivo gerado.
API
todo-api/package.json
{
"name": "todo-api",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"express": "^5.1.0",
"pg": "^8.16.0"
}
}todo-api/index.js
const express = require('express');
const { Pool } = require('pg');
const app = express();
app.use(express.json());
// A URI do console usa sslmode=verify-full&sslrootcert=system.
// O node-postgres não lê sslrootcert=system; com ssl: true ele valida
// o certificado e o hostname usando as autoridades confiáveis do Node.js.
const databaseUrl = new URL(process.env.DATABASE_URL);
databaseUrl.searchParams.delete('sslmode');
databaseUrl.searchParams.delete('sslrootcert');
const pool = new Pool({ connectionString: databaseUrl.toString(), ssl: true });
app.get('/todos', async (req, res) => {
const { rows } = await pool.query('SELECT * FROM todos ORDER BY created_at DESC');
res.json(rows);
});
app.post('/todos', async (req, res) => {
const { text } = req.body ?? {};
if (!text) return res.status(400).json({ error: 'Text required' });
const { rows } = await pool.query(
'INSERT INTO todos (text) VALUES ($1) RETURNING *',
[text]
);
res.status(201).json(rows[0]);
});
pool.query(`
CREATE TABLE IF NOT EXISTS todos (
id SERIAL PRIMARY KEY,
text TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
)
`)
.then(() => app.listen(process.env.PORT || 3000, '0.0.0.0'))
.catch((error) => {
console.error('Database initialization failed', error);
process.exit(1);
});Frontend
todo-web/package.json
{
"name": "todo-web",
"private": true,
"scripts": {
"build": "next build",
"start": "next start -p 3000"
},
"dependencies": {
"next": "^15.5.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.1.0",
"typescript": "^5.9.0"
}
}todo-web/app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="pt-BR">
<body>{children}</body>
</html>
);
}todo-web/app/api/todos/route.ts
O navegador chama /api/todos no próprio frontend, e esta rota repassa a chamada para a API usando a variável API_URL:
export const dynamic = 'force-dynamic';
const API_URL = process.env.API_URL;
export async function GET() {
const res = await fetch(`${API_URL}/todos`, { cache: 'no-store' });
return Response.json(await res.json(), { status: res.status });
}
export async function POST(request: Request) {
const res = await fetch(`${API_URL}/todos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: await request.text(),
});
return Response.json(await res.json(), { status: res.status });
}todo-web/app/page.tsx
'use client';
import { useState, useEffect } from 'react';
interface Todo {
id: number;
text: string;
}
export default function Home() {
const [todos, setTodos] = useState<Todo[]>([]);
const [text, setText] = useState('');
useEffect(() => {
fetch('/api/todos')
.then(r => r.json())
.then(setTodos);
}, []);
const addTodo = async () => {
const res = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
if (!res.ok) return;
const todo = await res.json();
setTodos(prev => [todo, ...prev]);
setText('');
};
return (
<main style={{ padding: 20, maxWidth: 600, margin: '0 auto' }}>
<h1>Todos</h1>
<div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
<input
value={text}
onChange={e => setText(e.target.value)}
placeholder="Adicione uma tarefa..."
style={{ flex: 1, padding: 8 }}
/>
<button onClick={addTodo}>Adicionar</button>
</div>
<ul>
{todos.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
</main>
);
}Configuração no console
Crie antes um PostgreSQL gerenciado e copie a URI de conexão. Depois, crie os dois projetos HTTP com Repositório Git em Origem do Projeto e GitHub ou Forgejo em Provedor Git, runtime Node.js 24 e porta 3000:
| Projeto | Comando Build | Comando Start | Variável de ambiente |
|---|---|---|---|
API (todo-api) | vazio | npm start | DATABASE_URL com a URI do PostgreSQL |
Frontend (todo-web) | npm run build | npm start | API_URL com a URL pública da API, como https://<api>.clients.zenifra.com |
Publicação
- Envie cada repositório, incluindo o
package-lock.json, para o GitHub ou Forgejo. - Crie primeiro o projeto da API e copie a URL dele.
- Crie o projeto do frontend com
API_URLapontando para a URL da API. - Com Automático por branch selecionado em Modo de deploy, cada push na branch selecionada publica uma nova versão do projeto correspondente.
Comportamento esperado
- Abra a URL do frontend.
- Digite uma tarefa e clique em "Adicionar".
- Ela é salva no PostgreSQL e aparece na lista.
- Atualize a página: os dados permanecem.
Próximos passos
Última atualização em