Build a feedback form

A public feedback form that saves submissions to SQLite and a private admin page — protected by a token — where you read them. No email service required, and we're honest about why.

A feedback form is the smallest useful web app: a page anyone can fill in, and a private place where you read what they wrote. This build does exactly that with Express and better-sqlite3 — a public form that saves to a database, and a token-protected /admin page that lists every submission. No third-party email service, no signup, one file of logic.

At a glance
You need A server running the Node.js application
Plan Any plan — free runs while your session timer has time left, premium runs 24/7
Time About twenty minutes
No server yet? Create your first app server

New to the Node.js application? Node.js on Falix explains the index.js entry file, the automatic npm install, and the SERVER_PORT rule this build relies on.

📬 Honest heads up: This form saves feedback — it does not email it to you. Sending email reliably means a mail provider, API keys, and deliverability headaches that don't belong in a first build. Instead you read submissions on a private admin page. That's simpler, it never loses a message, and there's nothing to keep online but your own server.

Step 1 — Create the files

In the File Manager, create three files in your server's root folder.

package.json — the dependency list, so Falix installs Express and the database driver on start:

{
  "name": "feedback-form",
  "version": "1.0.0",
  "private": true,
  "main": "index.js",
  "dependencies": {
    "better-sqlite3": "^11.8.1",
    "dotenv": "^16.4.7",
    "express": "^4.21.2"
  }
}

.env — your admin password, kept out of the code. Pick something long and random:

ADMIN_TOKEN=change-me-to-a-long-random-string

index.js — the whole app:

require('dotenv').config();
const express = require('express');
const Database = require('better-sqlite3');

const db = new Database('feedback.db');
db.exec(`
  CREATE TABLE IF NOT EXISTS feedback (
    id      INTEGER PRIMARY KEY AUTOINCREMENT,
    name    TEXT NOT NULL,
    message TEXT NOT NULL,
    created TEXT NOT NULL DEFAULT (datetime('now'))
  )
`);

const insert = db.prepare('INSERT INTO feedback (name, message) VALUES (?, ?)');
const listAll = db.prepare('SELECT * FROM feedback ORDER BY id DESC');

function esc(s) {
  return String(s).replace(/[&<>"']/g, (c) =>
    ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}

const app = express();
app.use(express.urlencoded({ extended: false }));

app.get('/', (req, res) => {
  res.send(`<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Send feedback</title></head>
<body style="font-family: system-ui, sans-serif; max-width: 34rem; margin: 3rem auto;">
  <h1>Send us feedback</h1>
  <form method="post" action="/feedback">
    <p><input name="name" placeholder="Your name" maxlength="80" required style="width:100%"></p>
    <p><textarea name="message" placeholder="What's on your mind?" maxlength="2000" rows="5" required style="width:100%"></textarea></p>
    <button type="submit">Send</button>
  </form>
</body>
</html>`);
});

app.post('/feedback', (req, res) => {
  const name = (req.body.name || '').trim();
  const message = (req.body.message || '').trim();
  if (!name || !message) {
    return res.status(400).send('Name and message are both required. <a href="/">Go back</a>');
  }
  insert.run(name.slice(0, 80), message.slice(0, 2000));
  res.send('Thanks — your feedback was saved. <a href="/">Send another</a>');
});

app.get('/admin', (req, res) => {
  if (!process.env.ADMIN_TOKEN || req.query.token !== process.env.ADMIN_TOKEN) {
    return res.status(401).send('Unauthorized');
  }
  const rows = listAll.all();
  const items = rows.map((r) =>
    `<li><strong>${esc(r.name)}</strong> <small>(${esc(r.created)})</small><br>${esc(r.message)}</li>`
  ).join('\n');
  res.send(`<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Feedback inbox</title></head>
<body style="font-family: system-ui, sans-serif; max-width: 40rem; margin: 3rem auto;">
  <h1>Feedback inbox (${rows.length})</h1>
  <ul>${items || '<li>No feedback yet.</li>'}</ul>
</body>
</html>`);
});

const PORT = process.env.SERVER_PORT || 8080;
app.listen(PORT, '0.0.0.0', () => console.log(`Listening on port ${PORT}`));

Step 2 — How it works

Four pieces carry the whole app:

  • The table is created once with CREATE TABLE IF NOT EXISTS — it's a no-op on every start after the first, so the app is safe to restart.
  • The form at / is plain HTML. express.urlencoded(...) is the middleware that reads the submitted fields into req.body.
  • POST /feedback validates that both fields are present, trims and length-caps them, and saves the row with a prepared statement (insert.run(...)). Prepared statements keep user input as data, never as SQL — that's your protection against injection.
  • GET /admin is gated: it compares ?token= against ADMIN_TOKEN from .env and returns 401 if it doesn't match. Only with the right token does it list submissions. The esc() helper HTML-escapes every value so a message containing <script> shows as text instead of running.

💡 Tip: better-sqlite3 stores everything in a single file — feedback.db — created next to your code. There's no database server to run or connect to. It ships prebuilt binaries, so it installs and works on the Node.js application with nothing extra.

Step 3 — Start it and try it

Press Start on the Console page. The first boot runs npm install, then prints Listening on port … — the line that flips your server to online. Open your server's address (from the Network page) in a browser:

  • The root page shows the form. Fill it in and submit — you get the "Thanks" message.
  • Visit /admin?token=YOUR_TOKEN (the value from .env). You see every submission, newest first.
  • Visit /admin with no token, or a wrong one, and you get Unauthorized. That's the gate working.
Route Method What it does
/ GET Shows the feedback form
/feedback POST Saves a submission (400 if a field is empty)
/admin?token=… GET Lists submissions — 401 without the right token

Where the data lives (and how to keep it safe)

Your submissions are in feedback.db in the server's file system. That has one consequence worth knowing:

⚠️ Heads up: A reinstall — or switching the server to a different application — wipes the server's files, and feedback.db goes with them. The database file is not rebuilt for you (unlike node_modules, which npm install recreates on the next start).

For a small personal form that's usually fine — take a backup before anything risky. If the feedback really matters, move storage to a managed database, which lives on a separate always-on host and survives reinstalls. Connect your app to a database shows the swap; the form logic stays the same.

Make it yours

  • Add a field (say, an email address): add a column to the CREATE TABLE, add the input to the form, and include it in the insert statement and the /admin list. Three matching edits.
  • Add another package — anything off npm — from the Packages page in your server menu: type the name, press Install, restart. It updates package.json for you.
  • Put it behind a real domain with HTTPS using the Network page's Reverse Proxy — see Domains and HTTPS.

Troubleshooting

  • Cannot find module 'express' — the install didn't finish or package.json is missing a dependency. Read the npm output at the top of the console; the Packages page can install it in one click.
  • /admin always says Unauthorized — the ?token= in the URL must match ADMIN_TOKEN in .env exactly, and you must restart after editing .env so the new value loads.
  • Page won't load at all — a port or bind problem, not a code one. The listen call must use SERVER_PORT and 0.0.0.0; see I can't reach my app.
  • Server starts then stops — an error above the first prompt, usually a typo in index.js. The first error in the console is the real one; see My app won't start.

Everything past this is standard Express and SQL — the official guide at expressjs.com takes routing, validation, and middleware from here.


Next steps

Was this guide helpful?