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.jsonNode.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:
| Project | Build Command | Start Command | Environment variable |
|---|---|---|---|
API (todo-api) | empty | npm start | DATABASE_URL with the PostgreSQL URI |
Frontend (todo-web) | npm run build | npm start | API_URL with the API public URL, such as https://<api>.clients.zenifra.com |
Deploy
- Push each repository, including
package-lock.json, to GitHub or Forgejo. - Create the API project first and copy its URL.
- Create the frontend project with
API_URLpointing to the API URL. - With Deployment mode set to Automatic by branch, each push to the selected branch publishes a new version of the matching project.
Expected behavior
- Open the frontend URL.
- Type a todo and click "Add".
- It saves to PostgreSQL and appears in the list.
- Refresh — data persists.
Next steps
Last updated on