Exemplos

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

O 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:

ProjetoComando BuildComando StartVariável de ambiente
API (todo-api)vazionpm startDATABASE_URL com a URI do PostgreSQL
Frontend (todo-web)npm run buildnpm startAPI_URL com a URL pública da API, como https://<api>.clients.zenifra.com

Publicação

  1. Envie cada repositório, incluindo o package-lock.json, para o GitHub ou Forgejo.
  2. Crie primeiro o projeto da API e copie a URL dele.
  3. Crie o projeto do frontend com API_URL apontando para a URL da API.
  4. 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

  1. Abra a URL do frontend.
  2. Digite uma tarefa e clique em "Adicionar".
  3. Ela é salva no PostgreSQL e aparece na lista.
  4. Atualize a página: os dados permanecem.

Próximos passos

Última atualização em

Nessa página