Only show guides for your app
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.
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}`));
Four pieces carry the whole app:
CREATE TABLE IF NOT EXISTS — it's a no-op on every start after the first, so the app is safe to restart./ 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-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.
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:
/admin?token=YOUR_TOKEN (the value from .env). You see every submission, newest first./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 |
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.
CREATE TABLE, add the input to the form, and include it in the insert statement and the /admin list. Three matching edits.package.json for you.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.listen call must use SERVER_PORT and 0.0.0.0; see I can't reach my app.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.