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) =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 intoreq.body. POST /feedbackvalidates 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 /adminis gated: it compares?token=againstADMIN_TOKENfrom.envand returns 401 if it doesn't match. Only with the right token does it list submissions. Theesc()helper HTML-escapes every value so a message containing<script>shows as text instead of running.
💡 Tip:
better-sqlite3stores 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
/adminwith 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.dbgoes with them. The database file is not rebuilt for you (unlikenode_modules, whichnpm installrecreates 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 theinsertstatement and the/adminlist. 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.jsonfor 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 orpackage.jsonis missing a dependency. Read the npm output at the top of the console; the Packages page can install it in one click./adminalways says Unauthorized — the?token=in the URL must matchADMIN_TOKENin.envexactly, and you must restart after editing.envso the new value loads.- Page won't load at all — a port or bind problem, not a code one. The
listencall must useSERVER_PORTand0.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.