Examples

Example — Full-Stack App

A full-stack app with a Next.js frontend, Express API, and PostgreSQL database.

Project structure

The API and the frontend become two HTTP projects. In this example, they live in separate repositories. If you prefer a single repository (monorepo), create both projects from it and set each one's Root directory to the matching folder, such as todo-api and todo-web; see Root directory and 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

Node.js project builds install dependencies with npm ci, which requires package-lock.json. Run npm install in each repository and push the generated file.

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());

// The console URI uses sslmode=verify-full&sslrootcert=system.
// node-postgres does not read sslrootcert=system; with ssl: true it validates
// the certificate and hostname using the Node.js trusted authorities.
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="en">
      <body>{children}</body>
    </html>
  );
}

todo-web/app/api/todos/route.ts

The browser calls /api/todos on the frontend itself, and this route forwards the call to the API using the API_URL variable:

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="Add a todo..."
          style={{ flex: 1, padding: 8 }}
        />
        <button onClick={addTodo}>Add</button>
      </div>
      <ul>
        {todos.map(todo => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </main>
  );
}

Console configuration

First create a managed PostgreSQL and copy the connection URI. Then create both HTTP projects with Git repository as Project source and GitHub or Forgejo as Git provider, runtime Node.js 24, and port 3000:

ProjectBuild CommandStart CommandEnvironment variable
API (todo-api)emptynpm startDATABASE_URL with the PostgreSQL URI
Frontend (todo-web)npm run buildnpm startAPI_URL with the API public URL, such as https://<api>.clients.zenifra.com

Deploy

  1. Push each repository, including package-lock.json, to GitHub or Forgejo.
  2. Create the API project first and copy its URL.
  3. Create the frontend project with API_URL pointing to the API URL.
  4. With Deployment mode set to Automatic by branch, each push to the selected branch publishes a new version of the matching project.

Expected behavior

  1. Open the frontend URL.
  2. Type a todo and click "Add".
  3. It saves to PostgreSQL and appears in the list.
  4. Refresh — data persists.

Next steps

Last updated on

On this page