What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Some links on this page are affiliate links: if you buy through them we may earn a commission, at no extra cost to you.

You can’t safely connect a plain HTML page directly to PostgreSQL. Instead, the page sends HTTP requests to a backend; the backend validates the data, runs SQL, and returns results. This tutorial builds a small guestbook with HTML, browser JavaScript, Node.js, Express, and PostgreSQL. The browser never receives the database password.

Request flow: browser form → fetch() → Node.js API → pg connection pool → PostgreSQL. The same server also serves the page, so the browser can call the API with a relative URL and you don’t need to configure CORS for local development.

What you’ll build

A guestbook with a name and message form. Submissions are stored in a PostgreSQL table and returned to the page as JSON. The app exposes two endpoints:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
  • GET /api/messages returns saved messages.
  • POST /api/messages validates and saves a message.

HTML provides the interface; JavaScript in the browser makes HTTP requests; Node.js is the trusted server-side layer. Putting a PostgreSQL connection string in browser JavaScript would expose it to anyone who opens the page. A backend API keeps credentials private and provides a place for validation and access control. See OWASP’s database security guidance.

#1 Best Overall
CanaKit Raspberry Pi 5 Starter Kit PRO - Turbine Black (128GB Edition) (8GB RAM)
  • Includes Raspberry Pi 5 with 2.4Ghz 64-bit quad-core CPU (8GB RAM)
  • Includes 128GB Micro SD Card pre-loaded with 64-bit Raspberry Pi OS, USB MicroSD Card Reader
  • CanaKit Turbine Black Case for the Raspberry Pi 5
  • CanaKit Low Noise Bearing System Fan
  • Mega Heat Sink - Black Anodized

Prerequisites

  • PostgreSQL running locally or a hosted PostgreSQL database.
  • Node.js and npm.
  • A terminal and code editor.
  • Basic familiarity with HTML forms, JavaScript promises, SQL, and environment variables.

Installing an HTML file alone is not enough: you need a server between the browser and PostgreSQL. PostgreSQL’s official tutorial covers the database basics used here.

1. Create the project

mkdir simple-postgres-site
cd simple-postgres-site
npm init -y
npm install express pg dotenv
mkdir public

Express handles HTTP routes and static files; pg (node-postgres) connects Node.js to PostgreSQL; and dotenv loads local environment variables from .env. Express is a convenience, not a requirement—other HTTP server frameworks or Node’s built-in HTTP module can fill the same role.

Create this structure:

simple-postgres-site/
├── public/
│   ├── index.html
│   └── app.js
├── server.js
├── schema.sql
├── .env
└── .gitignore

2. Create the database and table

Create a database from a terminal with:

createdb simple_site

If createdb isn’t available, connect to PostgreSQL using your usual administration method and run:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
CREATE DATABASE simple_site;

Connect to the new database, then save and run this SQL as schema.sql:

Rank #2
RasTech Raspberry Pi 5 8GB Kit 64GB Edition with Active Cooler,27W GaN 5.1V5A USB-C Power Supply,Pi5 8GB Board,64GB Card Readers Kit,Pi 5 Case,Dual 4K Micro HD Out Cables and User Manual
  • Pi5 8GB Pack: RasTech Pi 5 8GB kit includes 1 x Pi5 8GB board ,1 x 64GB Card, 2 x Card Readers,1 x Active Cooler,1 x Case for Pi5, 2 x 4K Micro HD Out Cable,1 x GaN 27W 5A USB-C Power supply,1 x Screwdriver and 1 x instructions.
  • Pi5 8GB Board: The Pi5 board is equipped with a 64-bit quad-core Arm Cortex-A76 processor running at 2.4GHz and an 800MHz VideoCore VII GPU with support for OpenGL ES 3.1 and Vulkan 1.2, which delivers a significant increase in graphics performance. Dual HD Out 4Kp60 display outputs and a built-in dual 4-channel MIPI camera/display transceiver provide state-of-the-art camera support. The Pi 5 offers a 2-3 times increase in CPU performance compare to Pi4.
  • Important Graphics Features: Equipped with an 800MHz VideoCore VII GPU and providing better graphics performance, suitable for multimedia applications,gaming,and graphics intensive tasks.Provides 1 UART interface,1 card slot that supports high-speed operation, 2 USB. 3 0.5 ports that support synchronous 0Gbps operation,2 USB 2.0 port ports,2 4Kp60 display outputs that support HDR.Built-in dedicated dual 4-channel 1Gbps MIPI DSI/CSI connectors,triple the total bandwidth.
  • Cooling Kit for Pi 5: Compatible with Active Cooler for Raspberry Pi5, It can provide Pi 5 board with better cooling effect in using. The Case can accurately access usb-c power jack,Micro HD Out ports, usb ports, Ethernet jack, card slot, power button, 4-lane MIPI DSI/CSI connectors and so on, and it also supports installation of cooling fan.
  • 64GB Card Kit and GaN 27W USB-C Power Supply: With extra 64GB card to store more files and card readers for multiple medium, keep better performance for Raspberry Pi 5, 27W USB C Power Supply is Compatible with Pi5 8GB, offers a variety of output voltage options, including 5.1V at 5A, 9.0V at 3.0A, 12.0V at 2.25A, and 15.0V at 1.8A, providing for different device requirements.
CREATE TABLE messages (
  id BIGSERIAL PRIMARY KEY,
  name TEXT NOT NULL CHECK (char_length(trim(name)) BETWEEN 1 AND 100),
  message TEXT NOT NULL CHECK (char_length(trim(message)) BETWEEN 1 AND 2000),
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

For example, if local command-line access is configured, run psql -d simple_site -f schema.sql. The table constraints provide a second line of defense if a write reaches the database without valid text. TIMESTAMPTZ stores timezone-aware timestamps.

3. Configure the database connection

Create a local .env file. Substitute the username, password, host, port, and database name for your installation:

DATABASE_URL=postgresql://postgres:your_password@localhost:5432/simple_site
PORT=3000

Port 5432 is PostgreSQL’s conventional default, but use the port configured on your machine or provided by your host. A hosted database may require a provider-specific connection string, SSL settings, or a pooler; follow that provider’s instructions rather than assuming one SSL configuration works everywhere. Providers may expose a DATABASE_URL or separate variables such as PGHOST and PGPORT; Railway documents its PostgreSQL connection variables.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Add a .gitignore file:

node_modules/
.env

Never put DATABASE_URL in public/app.js or commit .env. Values sent to a browser should be treated as public.

Rank #3
CanaKit Raspberry Pi 5 Essentials Starter Kit (4GB RAM)
  • CanaKit Raspberry Pi 5 Essentials Starter Kit

4. Build the backend API

Save the following as server.js:

require("dotenv").config();

const path = require("node:path");
const express = require("express");
const { Pool } = require("pg");

const app = express();
const port = process.env.PORT || 3000;
const pool = new Pool({
  connectionString: process.env.DATABASE_URL
  // Add provider-specific SSL settings only if your database host requires them.
});

app.use(express.json());
app.use(express.static(path.join(__dirname, "public")));

app.get("/api/messages", async (req, res) => {
  try {
    const result = await pool.query(`
      SELECT id, name, message, created_at
      FROM messages
      ORDER BY created_at DESC
    `);
    res.json(result.rows);
  } catch (error) {
    console.error(error);
    res.status(500).json({ error: "Could not load messages" });
  }
});

app.post("/api/messages", async (req, res) => {
  const name = typeof req.body.name === "string" ? req.body.name.trim() : "";
  const message = typeof req.body.message === "string" ? req.body.message.trim() : "";

  if (!name || name.length > 100 || !message || message.length > 2000) {
    return res.status(400).json({
      error: "Name and message are required and must be within the allowed limits."
    });
  }

  try {
    const result = await pool.query(
      `INSERT INTO messages (name, message)
       VALUES ($1, $2)
       RETURNING id, name, message, created_at`,
      [name, message]
    );
    res.status(201).json(result.rows[0]);
  } catch (error) {
    console.error(error);
    res.status(500).json({ error: "Could not save message" });
  }
});

app.listen(port, () => {
  console.log(`Server running at http://localhost:${port}`);
});

The single Pool is created when the server starts and reused for queries. Pooling avoids repeatedly setting up connections and limits simultaneous connections; it is generally preferable to creating a new database client for every request. See the node-postgres pooling guide.

The insert uses $1 and $2 placeholders, with values passed separately. Do not build SQL by inserting user input into a string. Parameterized queries keep data separate from SQL structure and are a primary defense against SQL injection; see node-postgres query documentation and OWASP’s prevention guidance. Parameters are for values, not table or column names; use a strict allowlist if an application must choose identifiers dynamically.

The server returns a generic error to visitors and logs details on the server. Do not send raw database errors to the browser: they can expose implementation details. The JSON parser must run before the routes so that req.body is available.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

5. Add the HTML form

Save as public/index.html:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Simple PostgreSQL Guestbook</title>
</head>
<body>
  <main>
    <h1>Guestbook</h1>
    <form id="message-form">
      <label>
        Name
        <input id="name" name="name" maxlength="100" required>
      </label>
      <label>
        Message
        <textarea id="message" name="message" maxlength="2000" required></textarea>
      </label>
      <button type="submit">Post message</button>
      <p id="status" role="status"></p>
    </form>
    <section>
      <h2>Recent messages</h2>
      <ul id="messages"></ul>
    </section>
  </main>
  <script src="/app.js"></script>
</body>
</html>

Labels make the controls understandable to assistive technology, and the status region can announce updates. Browser attributes such as required and maxlength help users, but can be bypassed; they do not replace server-side validation.

Rank #4
CanaKit Raspberry Pi 5 Starter Kit PRO - Turbine Black (128GB Edition) (4GB RAM)
  • Includes Raspberry Pi 5 with 2.4Ghz 64-bit quad-core CPU (4GB RAM)
  • Includes 128GB Micro SD Card pre-loaded with 64-bit Raspberry Pi OS, USB MicroSD Card Reader
  • CanaKit Turbine Black Case for the Raspberry Pi 5
  • CanaKit Low Noise Bearing System Fan
  • CanaKit Mega Heat Sink - Black Anodized

6. Submit and display messages with Fetch

Save as public/app.js:

const form = document.querySelector("#message-form");
const nameInput = document.querySelector("#name");
const messageInput = document.querySelector("#message");
const statusText = document.querySelector("#status");
const messagesList = document.querySelector("#messages");

function addMessageToPage(message) {
  const item = document.createElement("li");
  const heading = document.createElement("strong");
  heading.textContent = message.name;
  const body = document.createElement("p");
  body.textContent = message.message;
  const date = document.createElement("small");
  date.textContent = new Date(message.created_at).toLocaleString();
  item.append(heading, body, date);
  messagesList.append(item);
}

async function loadMessages() {
  const response = await fetch("/api/messages");
  if (!response.ok) throw new Error("Failed to load messages");
  const messages = await response.json();
  messagesList.replaceChildren();
  messages.forEach(addMessageToPage);
}

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  statusText.textContent = "Saving…";

  try {
    const response = await fetch("/api/messages", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        name: nameInput.value,
        message: messageInput.value
      })
    });
    const result = await response.json();
    if (!response.ok) throw new Error(result.error || "Could not save message");

    form.reset();
    statusText.textContent = "Message saved.";
    await loadMessages();
  } catch (error) {
    console.error(error);
    statusText.textContent = error.message;
  }
});

loadMessages().catch((error) => {
  console.error(error);
  statusText.textContent = "Could not load messages.";
});

The browser sends JSON with Content-Type: application/json, checks the HTTP response status, and parses the JSON response. Fetch does not treat every HTTP error status as a rejected promise, so check response.ok. The MDN Fetch guide explains request bodies, response handling, and cross-origin behavior.

Notice that user-submitted content is added with textContent, not innerHTML. This displays text rather than interpreting a message as markup or executable HTML, reducing the risk of cross-site scripting.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

7. Run and verify it

Start the server from the project directory:

node server.js

Open http://localhost:3000. Initially, the list should be empty. Submit a name and message: the browser sends a POST request, the API inserts the row, and the page fetches the list again without a full-page reload. Successful creation returns HTTP 201; invalid input returns 400; unexpected server or database failures return 500.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Test the API independently of the page to narrow down problems:

Best Value
UCTRONICS 19” 1U Rack Mount for Raspberry Pi with SSD Mounting Brackets, Thumbscrews Front Removable Bracket Supports Up to 4 Raspberry Pi 5, 3B/3B+, 4B and 4 SSDs, Option SD Card Adapter
  • Design for Raspberry Pi: Supports installation of 4 Raspberry Pis and 4 ssds, compatible with any 2.5” Solid State Drive (7mm/9mm) and Rpi 4B/3B+, and other B/B+ models.
  • The SSD mounting bracket also has two holes reserved for the SD card extension adapter ASIN: B09CKRDFTH, which allows you to access the SD card from the front of the rack.
  • Easy to Setup: Just use two included thumbscrews to mount the rackmount, which adopts a screw-in design, which helps you install and replace quickly and easily, no tools needed!
  • Applications: This is a hardware solution to get ingenious use of the Raspberry Pi, with this kit and open source software OpenMediaVault, you can use the Pi as a NAS Server, Surveillance station, or even a Web server.
  • Optional accessories: Single mounting bracket: B09GFQLPTY; Micro SD card extension adapter ASIN: B09CKRDFTH. I/O Panel: B09FXRQPFM
curl http://localhost:3000/api/messages

curl -X POST http://localhost:3000/api/messages 
  -H "Content-Type: application/json" 
  -d '{"name":"Ada","message":"Hello from PostgreSQL"}'

Inspect the stored rows with:

psql "$DATABASE_URL" -c 
"SELECT id, name, message, created_at FROM messages ORDER BY created_at DESC;"

In Windows PowerShell, you can submit a test message with:

Invoke-RestMethod -Method Post `
  -Uri http://localhost:3000/api/messages `
  -ContentType "application/json" `
  -Body '{"name":"Ada","message":"Hello from PostgreSQL"}'

Troubleshooting by symptom

Symptom Likely cause and next check
ECONNREFUSED PostgreSQL may not be running, or the host, port, listening interface, firewall, or container network may be wrong. Try psql "$DATABASE_URL" first; if that fails, fix the database connection before debugging the browser.
Password authentication failed Check the username and password, confirm the server is loading the intended .env, and test with psql. A password with reserved characters may need correct URL encoding in a connection string. Never print the password while debugging.
relation "messages" does not exist The schema may not have run, may have run against another database, or the app may use a different connection string. Check tables with psql "$DATABASE_URL" -c "\dt" and run schema.sql against the same database used by the app.
Cannot GET / Confirm index.html is inside public/ and static middleware points there. The example uses __dirname so its path does not depend on the terminal’s working directory.
req.body is undefined Ensure app.use(express.json()) appears before the route definitions and the browser sends a JSON content type.
Nothing appears, or the page shows an unexpected value Open the browser developer tools’ Network panel. Check the request URL and method, status code, request body, response body, and content type. Confirm the frontend parses JSON with response.json() and expects an array for the GET response.
CORS error The page and API are probably served from different origins. For this tutorial, serve both through Node and use the relative URL /api/messages. CORS controls browser access to cross-origin responses; it does not secure a database. Avoid mode: "no-cors" as a fix: it yields an opaque response that JavaScript cannot inspect.
SSL error after deployment Check the hosting provider’s connection instructions. SSL requirements and settings vary by provider and connection path; do not disable certificate verification just to hide a production error.

Security and deployment notes

This example demonstrates useful safeguards, but it is a learning app, not a complete production service. Before accepting public traffic:

  • Use a database role with only the permissions the app needs, and keep secrets in deployment environment variables.
  • Keep parameterized queries, server-side validation, and database constraints. Add a maximum JSON body size and rate limiting or other abuse controls to public write endpoints.
  • Serve the site over HTTPS. Add authentication and authorization before exposing private records or allowing edits and deletes. If you later use cookie-based authentication, consider CSRF protections.
  • Plan for logging, monitoring, backups, and schema migrations. For multi-query transactions, check out one client from the pool and release it in a finally block; avoid creating pools per request or leaking checked-out clients.
  • Follow your host’s connection and pooling guidance. Serverless deployments or many app instances may need a provider-supported pooler; the appropriate connection mode depends on the provider and workload.

A static host can serve index.html and app.js, but it cannot by itself securely connect those files to PostgreSQL. You still need a backend API or a managed service designed for browser access, with its authorization rules configured correctly. Supabase, for example, documents both browser-oriented Data API access and server-side PostgreSQL connection modes, including poolers; see its connection guide. Other hosting platforms, including Render and Railway, offer PostgreSQL deployment options. Compare current pricing, backups, storage, compute, regions, connection limits, and network charges on each provider’s official pages rather than assuming one is universally best.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Where to go next

Once the guestbook works, useful extensions include pagination for long lists, edit and delete routes, authentication, automated API tests, and schema migrations. Add those features one at a time, preserving the same boundary: browser requests go to your server, and only the server uses database credentials.

Quick Recap

Bestseller No. 1
CanaKit Raspberry Pi 5 Starter Kit PRO - Turbine Black (128GB Edition) (8GB RAM)
CanaKit Raspberry Pi 5 Starter Kit PRO - Turbine Black (128GB Edition) (8GB RAM)
Includes Raspberry Pi 5 with 2.4Ghz 64-bit quad-core CPU (8GB RAM); CanaKit Turbine Black Case for the Raspberry Pi 5
$259.95
Bestseller No. 3
CanaKit Raspberry Pi 5 Essentials Starter Kit (4GB RAM)
CanaKit Raspberry Pi 5 Essentials Starter Kit (4GB RAM)
CanaKit Raspberry Pi 5 Essentials Starter Kit
$189.99
Bestseller No. 4
CanaKit Raspberry Pi 5 Starter Kit PRO - Turbine Black (128GB Edition) (4GB RAM)
CanaKit Raspberry Pi 5 Starter Kit PRO - Turbine Black (128GB Edition) (4GB RAM)
Includes Raspberry Pi 5 with 2.4Ghz 64-bit quad-core CPU (4GB RAM); CanaKit Turbine Black Case for the Raspberry Pi 5
$209.99

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.