From Idea to Live Landing Page: Build a Node.js MVP and Publish It on a VPS

From Idea to Live Landing Page: Build a Node.js MVP and Publish It on a VPS

Created by eneaslari 27/6/2026

node javascript webdev startup

You have an idea.

Maybe it is a SaaS product. Maybe it is a developer tool. Maybe it is a tiny AI thing. Maybe it is a new course, app, marketplace, newsletter, or productized service.

At this point, the most important question is not:

Can I build the whole product?

The real question is:

Does anybody care enough to give me their email?

That is why a landing page is one of the best first MVPs you can build.

Not a giant app. Not a dashboard. Not a login system. Not a microservice orchestra wearing a tiny DevOps hat.

A landing page.

One clear promise. One signup form. One database table. One path to launch.

In this article, we are going to build a complete landing page MVP with modern Node.js and publish it to a real VPS.

We will go from idea to live page.

By the end, you will have:

  • a product landing page
  • a subscriber form
  • backend validation
  • SQLite storage
  • duplicate email handling
  • simple spam protection
  • admin subscriber export
  • clean separated HTML, CSS, and JavaScript files
  • a systemd service to keep the app running
  • Caddy as a reverse proxy with HTTPS
  • a real deployment flow to a VPS

No giant framework. No build step. No “before we start, install 700 packages to render a button.”

Just a clean little MVP that helps you learn if your idea deserves more time.

Let’s build the thing.


The Product Idea

For this article, we will pretend we are launching a product called LaunchKit.

LaunchKit helps founders validate product ideas before they accidentally spend three months building settings pages.

The landing page will say:

Launch less. Learn more.

The page will collect:

  • email
  • name
  • what problem the person wants solved
  • source/referrer

That is already enough to start learning.

You do not need user accounts yet. You do not need payments yet. You do not need an admin panel with animated charts yet.

The MVP goal is simple:

Get interested people onto a list and understand why they are interested.

That is the tiny signal machine we are building.


Why Not Just Use a No-Code Tool?

You can.

For many products, a no-code landing page is fine.

But building your own small Node.js landing page gives you more control:

  • You own the code.
  • You own the data.
  • You can customize the flow.
  • You can deploy it anywhere.
  • You can extend it later into the real product.
  • You learn how your product infrastructure starts.

Also, sometimes it is nice to build something small, sharp, and useful without waking the overengineering goblin.

The goblin is already awake enough.


The Stack

We will use:

  • Node.js for the runtime
  • Fastify for the HTTP server
  • Zod for validation
  • SQLite for storage
  • better-sqlite3 for simple database access
  • @fastify/static to serve frontend files
  • Caddy as the public reverse proxy
  • systemd to keep the app alive on the VPS

This is a practical MVP stack.

It is small enough to understand, but real enough to publish.

The structure will look like this:

launchkit-landing/
├─ app.js
├─ package.json
├─ .env
├─ data/
│  └─ subscribers.db
└─ public/
   ├─ index.html
   ├─ styles.css
   └─ app.js

The backend lives in app.js.

The landing page lives in public/index.html.

The design lives in public/styles.css.

The browser form logic lives in public/app.js.


Step 1: Create the Project

Create a new folder:

mkdir launchkit-landing
cd launchkit-landing

Initialize Node:

npm init -y

Use ES modules:

npm pkg set type=module

Install dependencies:

npm install fastify @fastify/static zod better-sqlite3

Add a start script:

npm pkg set scripts.start="node --env-file=.env app.js"

Now create the folders:

mkdir public data
touch app.js public/index.html public/styles.css public/app.js .env

That is the skeleton.

No spaceship yet. Just the launchpad.


Step 2: Add Environment Variables

Open .env and add this:

PRODUCT_NAME="LaunchKit"
PRODUCT_TAGLINE="Validate your product idea before building the whole spaceship."
PORT=3000
HOST="127.0.0.1"
DB_FILE="./data/subscribers.db"
APP_SECRET="change-this-secret"
ADMIN_TOKEN="change-this-admin-token"

What these do:

PRODUCT_NAME      The name displayed on the page
PRODUCT_TAGLINE   The main description
PORT              The local port Node listens on
HOST              127.0.0.1 means only local machine access
DB_FILE           Where SQLite stores subscribers
APP_SECRET        Used to hash IP addresses
ADMIN_TOKEN       Used to protect admin endpoints

For local development, this is fine.

For production, change APP_SECRET and ADMIN_TOKEN to long random values.

Something like:

openssl rand -hex 32

Use that for your secrets.

Do not use password123.

That password has already emotionally retired.


Step 3: Build the Backend

Open app.js and paste this:

import Fastify from "fastify";
import fastifyStatic from "@fastify/static";
import Database from "better-sqlite3";
import { z } from "zod";
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
import { mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const PORT = Number(process.env.PORT ?? 3000);
const HOST = process.env.HOST ?? "127.0.0.1";

const PRODUCT_NAME = process.env.PRODUCT_NAME ?? "LaunchKit";
const PRODUCT_TAGLINE =
  process.env.PRODUCT_TAGLINE ??
  "Validate your product idea before building the whole spaceship.";

const DB_FILE = process.env.DB_FILE ?? "./data/subscribers.db";
const APP_SECRET = process.env.APP_SECRET ?? "dev-secret-change-me";
const ADMIN_TOKEN = process.env.ADMIN_TOKEN ?? "";

mkdirSync(dirname(DB_FILE), { recursive: true });

const app = Fastify({
  logger: true,
  trustProxy: true,
});

await app.register(fastifyStatic, {
  root: join(__dirname, "public"),
  prefix: "/",
});

const db = new Database(DB_FILE);

db.pragma("journal_mode = WAL");

db.exec(`
  CREATE TABLE IF NOT EXISTS subscribers (
    id TEXT PRIMARY KEY,
    email TEXT NOT NULL UNIQUE,
    name TEXT,
    interest TEXT,
    source TEXT,
    ip_hash TEXT,
    user_agent TEXT,
    created_at TEXT NOT NULL DEFAULT (datetime('now'))
  );

  CREATE INDEX IF NOT EXISTS idx_subscribers_created_at
  ON subscribers(created_at);
`);

const insertSubscriber = db.prepare(`
  INSERT INTO subscribers (
    id,
    email,
    name,
    interest,
    source,
    ip_hash,
    user_agent
  )
  VALUES (
    @id,
    @email,
    @name,
    @interest,
    @source,
    @ip_hash,
    @user_agent
  )
`);

const countSubscribers = db.prepare(`
  SELECT COUNT(*) AS total FROM subscribers
`);

const latestSubscribers = db.prepare(`
  SELECT
    created_at,
    email,
    name,
    interest,
    source
  FROM subscribers
  ORDER BY created_at DESC
  LIMIT @limit
`);

const SubscriberSchema = z.object({
  email: z.string().trim().toLowerCase().email().max(255),
  name: z.string().trim().max(80).optional().default(""),
  interest: z.string().trim().max(500).optional().default(""),
  source: z.string().trim().max(120).optional().default("website"),

  // Honeypot field.
  // Humans do not see it.
  // Bots often fill it because bots have no chill.
  website: z.string().max(200).optional().default(""),
});

const recentHits = new Map();

function hash(value) {
  return createHash("sha256")
    .update(`${APP_SECRET}:${value}`)
    .digest("hex");
}

function isRateLimited(ip) {
  const key = hash(ip).slice(0, 32);
  const now = Date.now();

  const windowMs = 60_000;
  const maxRequests = 6;

  const hits = (recentHits.get(key) ?? []).filter(
    (timestamp) => now - timestamp < windowMs
  );

  hits.push(now);
  recentHits.set(key, hits);

  return hits.length > maxRequests;
}

const cleanupTimer = setInterval(() => {
  const now = Date.now();
  const windowMs = 60_000;

  for (const [key, hits] of recentHits.entries()) {
    const freshHits = hits.filter((timestamp) => now - timestamp < windowMs);

    if (freshHits.length === 0) {
      recentHits.delete(key);
    } else {
      recentHits.set(key, freshHits);
    }
  }
}, 60_000);

cleanupTimer.unref();

function safeCompare(a, b) {
  const first = Buffer.from(String(a ?? ""));
  const second = Buffer.from(String(b ?? ""));

  return first.length === second.length && timingSafeEqual(first, second);
}

function escapeCsv(value) {
  const text = String(value ?? "");

  if (/[",\n\r]/.test(text)) {
    return `"${text.replaceAll('"', '""')}"`;
  }

  return text;
}

async function requireAdmin(request, reply) {
  if (!ADMIN_TOKEN) {
    return reply.code(404).send({
      ok: false,
      message: "Admin routes are disabled. Set ADMIN_TOKEN first.",
    });
  }

  const authorization = request.headers.authorization ?? "";

  const bearerToken = authorization.startsWith("Bearer ")
    ? authorization.slice("Bearer ".length)
    : "";

  const queryToken = request.query?.token ?? "";
  const token = bearerToken || queryToken;

  if (!safeCompare(token, ADMIN_TOKEN)) {
    return reply.code(401).send({
      ok: false,
      message: "Unauthorized.",
    });
  }
}

app.get("/config.js", async (_request, reply) => {
  const publicConfig = {
    productName: PRODUCT_NAME,
    productTagline: PRODUCT_TAGLINE,
  };

  return reply
    .type("application/javascript; charset=utf-8")
    .send(`window.APP_CONFIG = ${JSON.stringify(publicConfig)};`);
});

app.get("/api/stats", async () => {
  return {
    ok: true,
    total: countSubscribers.get().total,
  };
});

app.get("/health", async () => {
  return {
    ok: true,
    product: PRODUCT_NAME,
    uptime: process.uptime(),
  };
});

app.post("/api/subscribe", async (request, reply) => {
  if (isRateLimited(request.ip)) {
    return reply.code(429).send({
      ok: false,
      message: "Easy there, launch rocket. Try again in a minute.",
    });
  }

  const parsed = SubscriberSchema.safeParse(request.body ?? {});

  if (!parsed.success) {
    return reply.code(400).send({
      ok: false,
      message: "Please enter a valid email address.",
    });
  }

  const subscriber = parsed.data;

  if (subscriber.website) {
    return reply.send({
      ok: true,
      message: "Thanks! You are on the list.",
    });
  }

  try {
    insertSubscriber.run({
      id: randomUUID(),
      email: subscriber.email,
      name: subscriber.name || null,
      interest: subscriber.interest || null,
      source: subscriber.source || "website",
      ip_hash: hash(request.ip).slice(0, 32),
      user_agent: request.headers["user-agent"] ?? "",
    });

    return reply.code(201).send({
      ok: true,
      message: "You are in! We will send useful updates, not inbox confetti.",
    });
  } catch (error) {
    if (error?.code === "SQLITE_CONSTRAINT_UNIQUE") {
      return reply.send({
        ok: true,
        message: "You are already on the list. Excellent taste, twice.",
      });
    }

    request.log.error(error);

    return reply.code(500).send({
      ok: false,
      message: "Something broke. A tiny goblin has been notified.",
    });
  }
});

app.get(
  "/admin/subscribers",
  { preHandler: requireAdmin },
  async (request) => {
    const requestedLimit = Number.parseInt(request.query?.limit ?? "100", 10);

    const limit = Number.isFinite(requestedLimit)
      ? Math.min(Math.max(requestedLimit, 1), 500)
      : 100;

    return {
      ok: true,
      total: countSubscribers.get().total,
      subscribers: latestSubscribers.all({ limit }),
    };
  }
);

app.get(
  "/admin/subscribers.csv",
  { preHandler: requireAdmin },
  async (_request, reply) => {
    const rows = latestSubscribers.all({ limit: 10_000 });
    const header = ["created_at", "email", "name", "interest", "source"];

    const csv = [
      header.join(","),
      ...rows.map((row) =>
        header.map((key) => escapeCsv(row[key])).join(",")
      ),
    ].join("\n");

    return reply
      .header("content-type", "text/csv; charset=utf-8")
      .header(
        "content-disposition",
        `${PRODUCT_NAME.toLowerCase()}-subscribers.csv`
      )
      .send(csv);
  }
);

try {
  await app.listen({ port: PORT, host: HOST });
} catch (error) {
  app.log.error(error);
  process.exit(1);
}

This backend does a lot for a small file:

  • serves the landing page
  • exposes public product config
  • validates form submissions
  • saves subscribers
  • handles duplicate emails
  • adds a simple rate limiter
  • adds a honeypot spam field
  • gives you subscriber stats
  • gives you admin JSON export
  • gives you admin CSV export

Not bad for one backend file.

The goblin is annoyed.

Good.


Step 4: Build the HTML

Open public/index.html:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />

  <meta
    name="viewport"
    content="width=device-width, initial-scale=1"
  />

  <title>LaunchKit — Join the early list</title>

  <meta
    name="description"
    content="Join the early access list for LaunchKit."
  />

  <link rel="stylesheet" href="/styles.css" />
  <script src="/config.js"></script>
  <script type="module" src="/app.js"></script>
</head>

<body>
  <div class="page-shell">
    <header class="nav">
      <a class="brand" href="/">
        <span class="brand-mark"></span>
        <span data-product-name>LaunchKit</span>
      </a>

      <span class="nav-badge">Early access is open</span>
    </header>

    <main class="hero">
      <section class="hero-copy">
        <p class="eyebrow">
          <span></span>
          New product brewing
        </p>

        <h1>
          Launch less.
          <strong>Learn more.</strong>
        </h1>

        <p class="subtitle" data-product-tagline>
          Validate your product idea before building the whole spaceship.
        </p>

        <p class="description">
          Join the early list and help shape what we build next.
          No spam. No corporate fog machine. Just useful updates.
        </p>

        <div class="proof-grid">
          <article>
            <strong data-subscriber-count>0</strong>
            <span>people already curious</span>
          </article>

          <article>
            <strong>5 min</strong>
            <span>to tell us what hurts</span>
          </article>

          <article>
            <strong>0%</strong>
            <span>overengineered nonsense</span>
          </article>
        </div>
      </section>

      <section class="signup-card" aria-label="Early access signup form">
        <div class="card-glow"></div>

        <div class="card-content">
          <h2>Get early access</h2>

          <p>
            Tell us where to send the good stuff. Bonus points if you tell us
            what problem you want solved.
          </p>

          <form data-signup-form>
            <label>
              Email
              <input
                name="email"
                type="email"
                placeholder="you@example.com"
                autocomplete="email"
                required
              />
            </label>

            <label>
              Name
              <input
                name="name"
                type="text"
                placeholder="Ada Lovelace"
                autocomplete="name"
              />
            </label>

            <label>
              What are you trying to solve?
              <textarea
                name="interest"
                placeholder="I want to validate my idea before building the entire app..."
              ></textarea>
            </label>

            <input type="hidden" name="source" value="website" />

            <label class="honeypot">
              Website
              <input
                name="website"
                type="text"
                autocomplete="off"
                tabindex="-1"
              />
            </label>

            <button type="submit">Join the early list</button>

            <p class="status" data-status></p>
          </form>
        </div>
      </section>
    </main>

    <section class="idea-map" aria-label="Landing page process">
      <div class="orbit">
        <article class="bubble bubble-1">
          <strong>Idea</strong>
          <span>Start with a promise.</span>
        </article>

        <article class="bubble bubble-2">
          <strong>Page</strong>
          <span>Explain it simply.</span>
        </article>

        <article class="bubble bubble-3">
          <strong>Waitlist</strong>
          <span>Capture real signal.</span>
        </article>

        <article class="bubble bubble-4">
          <strong>Build</strong>
          <span>Only after people care.</span>
        </article>

        <div class="rocket">🚀</div>
      </div>
    </section>

    <footer class="footer">
      Built with Node.js, SQLite, and a healthy fear of unnecessary complexity.
    </footer>
  </div>
</body>
</html>

This is plain HTML.

No frontend framework. No compiler. No ritual dance around a build tool.

The browser can understand this all by itself, because the browser is not a confused potato.


Step 5: Add the CSS

Open public/styles.css:

:root {
  color-scheme: dark;

  --bg: #08111f;
  --bg-soft: #101b2f;
  --card: rgba(255, 255, 255, 0.08);
  --card-border: rgba(255, 255, 255, 0.14);
  --text: #f6f7fb;
  --muted: #a8b3c7;
  --green: #7ee787;
  --blue: #75b7ff;
  --orange: #ffbd6b;
  --red: #ff7b72;

  --shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-height: 100vh;
  color: var(--text);
  font-family:
    Inter,
    ui-sans-serif,
    system-ui,
    -apple-system,
    BlinkMacSystemFont,
    "Segoe UI",
    sans-serif;
  background:
    radial-gradient(circle at top left, rgba(126, 231, 135, 0.18), transparent 34rem),
    radial-gradient(circle at bottom right, rgba(117, 183, 255, 0.16), transparent 32rem),
    var(--bg);
}

button,
input,
textarea {
  font: inherit;
}

a {
  color: inherit;
  text-decoration: none;
}

.page-shell {
  width: min(1120px, calc(100% - 32px));
  margin: 0 auto;
}

.nav {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 28px 0;
}

.brand {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  font-weight: 900;
  letter-spacing: -0.04em;
}

.brand-mark {
  width: 38px;
  height: 38px;
  display: grid;
  place-items: center;
  border-radius: 14px;
  color: #06101f;
  background: linear-gradient(135deg, var(--green), var(--blue));
  box-shadow: 0 12px 40px rgba(126, 231, 135, 0.24);
}

.nav-badge {
  color: var(--muted);
  font-size: 14px;
  border: 1px solid var(--card-border);
  background: rgba(255, 255, 255, 0.05);
  padding: 8px 12px;
  border-radius: 999px;
}

.hero {
  display: grid;
  grid-template-columns: 1.05fr 0.95fr;
  gap: 52px;
  align-items: center;
  padding: 70px 0 86px;
}

.eyebrow {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  width: fit-content;
  margin: 0;
  color: var(--green);
  background: rgba(126, 231, 135, 0.09);
  border: 1px solid rgba(126, 231, 135, 0.22);
  padding: 8px 12px;
  border-radius: 999px;
  font-weight: 800;
  font-size: 14px;
}

.eyebrow span {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--green);
  box-shadow: 0 0 20px var(--green);
}

h1 {
  margin: 22px 0 18px;
  max-width: 720px;
  font-size: clamp(44px, 7vw, 82px);
  line-height: 0.94;
  letter-spacing: -0.075em;
}

h1 strong {
  display: block;
  font-weight: 900;
  background: linear-gradient(135deg, var(--text), var(--green), var(--blue));
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}

.subtitle,
.description {
  max-width: 620px;
  color: var(--muted);
  font-size: 20px;
  line-height: 1.7;
}

.description {
  font-size: 18px;
}

.proof-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 14px;
  margin-top: 28px;
}

.proof-grid article {
  border: 1px solid var(--card-border);
  background: rgba(255, 255, 255, 0.06);
  border-radius: 22px;
  padding: 18px;
}

.proof-grid strong {
  display: block;
  font-size: 28px;
  letter-spacing: -0.05em;
}

.proof-grid span {
  display: block;
  margin-top: 5px;
  color: var(--muted);
  font-size: 13px;
  line-height: 1.4;
}

.signup-card {
  position: relative;
  overflow: hidden;
  border: 1px solid var(--card-border);
  background:
    linear-gradient(180deg, rgba(255, 255, 255, 0.11), rgba(255, 255, 255, 0.06));
  box-shadow: var(--shadow);
  border-radius: 30px;
  padding: 26px;
  backdrop-filter: blur(18px);
}

.card-glow {
  position: absolute;
  inset: -2px;
  pointer-events: none;
  background:
    radial-gradient(circle at top left, rgba(126, 231, 135, 0.18), transparent 16rem),
    radial-gradient(circle at bottom right, rgba(255, 189, 107, 0.14), transparent 14rem);
}

.card-content {
  position: relative;
  z-index: 1;
}

.signup-card h2 {
  margin: 0 0 8px;
  font-size: 28px;
  letter-spacing: -0.05em;
}

.signup-card p {
  color: var(--muted);
  line-height: 1.6;
}

form {
  display: grid;
  gap: 14px;
  margin-top: 22px;
}

label {
  display: grid;
  gap: 7px;
  color: var(--muted);
  font-size: 14px;
  font-weight: 800;
}

input,
textarea {
  width: 100%;
  color: var(--text);
  border: 1px solid rgba(255, 255, 255, 0.14);
  background: rgba(8, 17, 31, 0.72);
  border-radius: 16px;
  padding: 14px 15px;
  outline: none;
}

textarea {
  min-height: 104px;
  resize: vertical;
}

input:focus,
textarea:focus {
  border-color: rgba(126, 231, 135, 0.72);
  box-shadow: 0 0 0 4px rgba(126, 231, 135, 0.11);
}

button {
  cursor: pointer;
  border: 0;
  border-radius: 16px;
  padding: 15px 18px;
  color: #06101f;
  background: linear-gradient(135deg, var(--green), var(--blue));
  font-weight: 950;
  letter-spacing: -0.03em;
  box-shadow: 0 16px 44px rgba(126, 231, 135, 0.2);
}

button:disabled {
  opacity: 0.68;
  cursor: not-allowed;
}

.status {
  min-height: 24px;
  margin: 4px 0 0;
  color: var(--muted);
  font-weight: 800;
}

.status[data-kind="success"] {
  color: var(--green);
}

.status[data-kind="error"] {
  color: var(--red);
}

.honeypot {
  position: absolute;
  left: -9999px;
  opacity: 0;
  pointer-events: none;
}

.idea-map {
  display: grid;
  place-items: center;
  padding: 12px 0 90px;
}

.orbit {
  width: min(100%, 680px);
  min-height: 420px;
  position: relative;
  overflow: hidden;
  border: 1px solid var(--card-border);
  border-radius: 42px;
  background:
    linear-gradient(135deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.035));
  box-shadow: var(--shadow);
}

.orbit::before {
  content: "";
  position: absolute;
  width: 310px;
  height: 310px;
  left: 50%;
  top: 50%;
  border-radius: 999px;
  border: 1px dashed rgba(255, 255, 255, 0.2);
  transform: translate(-50%, -50%);
}

.bubble {
  position: absolute;
  width: 170px;
  border: 1px solid var(--card-border);
  background: rgba(8, 17, 31, 0.78);
  border-radius: 22px;
  padding: 16px;
  box-shadow: 0 18px 54px rgba(0, 0, 0, 0.24);
}

.bubble strong {
  display: block;
  margin-bottom: 5px;
}

.bubble span {
  color: var(--muted);
  font-size: 13px;
}

.bubble-1 {
  left: 52px;
  top: 64px;
}

.bubble-2 {
  right: 52px;
  top: 88px;
}

.bubble-3 {
  left: 88px;
  bottom: 64px;
}

.bubble-4 {
  right: 82px;
  bottom: 54px;
}

.rocket {
  position: absolute;
  inset: 50%;
  width: 132px;
  height: 132px;
  display: grid;
  place-items: center;
  border-radius: 34px;
  font-size: 54px;
  background: linear-gradient(135deg, var(--green), var(--orange));
  transform: translate(-50%, -50%);
  box-shadow: 0 0 80px rgba(126, 231, 135, 0.25);
}

.footer {
  border-top: 1px solid rgba(255, 255, 255, 0.09);
  padding: 28px 0;
  color: var(--muted);
  font-size: 14px;
}

@media (max-width: 880px) {
  .hero {
    grid-template-columns: 1fr;
    padding-top: 36px;
  }

  .proof-grid {
    grid-template-columns: 1fr;
  }

  .orbit {
    min-height: 520px;
  }

  .bubble {
    width: 150px;
  }

  .bubble-1 {
    left: 22px;
    top: 42px;
  }

  .bubble-2 {
    right: 22px;
    top: 96px;
  }

  .bubble-3 {
    left: 24px;
    bottom: 80px;
  }

  .bubble-4 {
    right: 24px;
    bottom: 40px;
  }
}

Now the landing page has a real design.

Dark background. Bright accents. Big headline. Clean form. No corporate stock photo of five people pointing at a laptop.

We are safe.


Step 6: Add the Frontend JavaScript

Open public/app.js:

const config = window.APP_CONFIG ?? {};

const productNameElements = document.querySelectorAll("[data-product-name]");
const productTaglineElement = document.querySelector("[data-product-tagline]");
const subscriberCountElement = document.querySelector("[data-subscriber-count]");

for (const element of productNameElements) {
  element.textContent = config.productName ?? "LaunchKit";
}

if (productTaglineElement) {
  productTaglineElement.textContent =
    config.productTagline ??
    "Validate your product idea before building the whole spaceship.";
}

document.title = `${config.productName ?? "LaunchKit"} — Join the early list`;

async function updateStats() {
  try {
    const response = await fetch("/api/stats");
    const result = await response.json();

    if (result.ok && subscriberCountElement) {
      subscriberCountElement.textContent = result.total;
    }
  } catch {
    // Stats are nice to have, not critical.
  }
}

updateStats();

const form = document.querySelector("[data-signup-form]");
const status = document.querySelector("[data-status]");

if (form && status) {
  const sourceInput = form.querySelector('[name="source"]');

  const params = new URLSearchParams(window.location.search);
  const source = params.get("ref") || document.referrer || "website";

  sourceInput.value = source.slice(0, 120);

  form.addEventListener("submit", async (event) => {
    event.preventDefault();

    const button = form.querySelector("button");
    const formData = Object.fromEntries(new FormData(form));

    status.textContent = "Adding you to the list...";
    status.dataset.kind = "";
    button.disabled = true;

    try {
      const response = await fetch("/api/subscribe", {
        method: "POST",
        headers: {
          "content-type": "application/json",
        },
        body: JSON.stringify(formData),
      });

      const result = await response.json();

      status.textContent = result.message || "Done.";
      status.dataset.kind = result.ok ? "success" : "error";

      if (result.ok) {
        form.reset();
        sourceInput.value = source.slice(0, 120);
        updateStats();
      }
    } catch {
      status.textContent = "Could not connect. Please try again.";
      status.dataset.kind = "error";
    } finally {
      button.disabled = false;
    }
  });
}

This file does four useful things:

  1. Loads product name and tagline from the backend.
  2. Fetches the subscriber count.
  3. Sends the signup form to the API.
  4. Shows friendly success/error messages.

That is enough.

The MVP does not need a frontend state management library.

The current state is: “Did the person subscribe?”

We can manage that without summoning a dragon.


Step 7: Run the App Locally

Start the app:

npm start

Open:

http://localhost:3000

You should see your landing page.

Try submitting the form.

Then test the API:

curl -X POST http://localhost:3000/api/subscribe \
  -H "content-type: application/json" \
  -d '{
    "email": "ada@example.com",
    "name": "Ada",
    "interest": "I want to validate product ideas faster.",
    "source": "curl"
  }'

You should get:

{
  "ok": true,
  "message": "You are in! We will send useful updates, not inbox confetti."
}

Submit the same email again.

You should get:

{
  "ok": true,
  "message": "You are already on the list. Excellent taste, twice."
}

That means duplicate handling works.

A database constraint happened, but the user did not see a terrifying error.

This is called being polite.


Step 8: View Subscribers Locally

Use your admin token from .env:

curl http://localhost:3000/admin/subscribers \
  -H "Authorization: Bearer change-this-admin-token"

You should see something like:

{
  "ok": true,
  "total": 1,
  "subscribers": [
    {
      "created_at": "2026-06-28 12:00:00",
      "email": "ada@example.com",
      "name": "Ada",
      "interest": "I want to validate product ideas faster.",
      "source": "curl"
    }
  ]
}

Export CSV:

curl http://localhost:3000/admin/subscribers.csv \
  -H "Authorization: Bearer change-this-admin-token" \
  -o subscribers.csv

Now you have leads in a spreadsheet-friendly format.

This is the first tiny version of your customer database.

Do not lose it.

The first people who care about your idea are precious little internet diamonds.


Step 9: Prepare for Git

Create a .gitignore file:

touch .gitignore

Add:

node_modules
.env
data/*.db
data/*.db-shm
data/*.db-wal
npm-debug.log
.DS_Store

Why?

Because you do not want to commit:

  • dependencies
  • secrets
  • production databases
  • random local files

Commit the project:

git init
git add .
git commit -m "Build landing page MVP"

Now your app is ready to deploy.


Step 10: Get a VPS

You can use any VPS provider.

For a small landing page, the tiny machine is usually enough:

1 CPU
1 GB RAM
Ubuntu 24.04 LTS

You do not need a monster server for a landing page.

If your waitlist needs a bigger server on day one, congratulations, you have a different problem.

A better problem.

After creating the VPS, you should have:

Server IP address
Root SSH access
Ubuntu installed

Connect to the server:

ssh root@YOUR_SERVER_IP

Update packages:

apt update
apt upgrade -y

Create a non-root user:

adduser deploy
usermod -aG sudo deploy

Switch to that user:

su - deploy

From now on, avoid deploying everything as root.

Root is powerful.

Root is also how small mistakes become exciting incidents.


Step 11: Install Node.js on the VPS

Install basic tools:

sudo apt install -y curl git build-essential

The build-essential package is useful because better-sqlite3 may need native compilation depending on your environment.

Now install Node.js.

One simple way is using NodeSource or another trusted installation method. After installing Node, confirm:

node -v
npm -v

You want a modern Node version.

Node 20 or newer is a good target because we are using modern JavaScript and --env-file.


Step 12: Upload the Project to the VPS

There are two common ways.

Option A: Clone from Git

If your project is on GitHub or another Git server:

mkdir -p ~/apps
cd ~/apps
git clone YOUR_REPOSITORY_URL launchkit-landing
cd launchkit-landing

Option B: Copy Files with SCP

From your local machine:

scp -r ./launchkit-landing deploy@YOUR_SERVER_IP:/home/deploy/apps/

Then SSH into the server:

ssh deploy@YOUR_SERVER_IP
cd ~/apps/launchkit-landing

Install dependencies:

npm install --omit=dev

Create the data folder:

mkdir -p data

Create production .env:

nano .env

Add:

PRODUCT_NAME="LaunchKit"
PRODUCT_TAGLINE="Validate your product idea before building the whole spaceship."
PORT=3000
HOST="127.0.0.1"
DB_FILE="./data/subscribers.db"
APP_SECRET="paste-a-long-random-secret-here"
ADMIN_TOKEN="paste-a-different-long-random-token-here"

Generate proper secrets:

openssl rand -hex 32
openssl rand -hex 32

Use one for APP_SECRET.

Use the other for ADMIN_TOKEN.

Do not reuse the same one.

Secrets are like toothbrushes.

Sharing them is weird.


Step 13: Test the App on the VPS

From inside the project folder:

npm start

In another SSH session, test it:

curl http://127.0.0.1:3000/health

Expected response:

{
  "ok": true,
  "product": "LaunchKit",
  "uptime": 10.123
}

Stop the running app with:

Ctrl + C

Now we know the app works.

But we do not want to keep it alive manually.

We are not going to SSH into the server and whisper “please keep running” every night.

That is systemd’s job.


Step 14: Create a systemd Service

Create a service file:

sudo nano /etc/systemd/system/launchkit.service

Add this:

[Unit]
Description=LaunchKit Landing Page
After=network.target

[Service]
Type=simple
User=deploy
WorkingDirectory=/home/deploy/apps/launchkit-landing
ExecStart=/usr/bin/node --env-file=.env app.js
Restart=always
RestartSec=5
Environment=NODE_ENV=production

[Install]
WantedBy=multi-user.target

Important: check your Node path:

which node

If it is not /usr/bin/node, update ExecStart.

For example, if Node is installed somewhere else, use that path.

Now reload systemd:

sudo systemctl daemon-reload

Enable the service:

sudo systemctl enable launchkit

Start it:

sudo systemctl start launchkit

Check status:

sudo systemctl status launchkit

View logs:

journalctl -u launchkit -f

Your Node app is now supervised.

If it crashes, systemd restarts it.

The app has a babysitter now.

A serious Linux babysitter.


Step 15: Install Caddy

Your Node app listens on:

127.0.0.1:3000

That is good.

It means the app is not directly exposed to the internet.

Now Caddy will sit in front of it and handle public traffic, HTTPS, and reverse proxying.

Install Caddy using the official installation instructions for your server OS.

After installing, check:

caddy version

Then edit the Caddyfile:

sudo nano /etc/caddy/Caddyfile

Add:

yourdomain.com {
  reverse_proxy 127.0.0.1:3000
}

Replace:

yourdomain.com

with your real domain.

Reload Caddy:

sudo systemctl reload caddy

Caddy will handle HTTPS automatically when your domain points to the server correctly.

This is one of the reasons Caddy is lovely.

It does the certificate work while you drink coffee and pretend deployment was always this calm.


Step 16: Point Your Domain to the VPS

Go to your domain registrar or DNS provider.

Create an A record:

Type: A
Name: @
Value: YOUR_SERVER_IP
TTL: Auto

For www, either create another A record:

Type: A
Name: www
Value: YOUR_SERVER_IP
TTL: Auto

Or create a CNAME:

Type: CNAME
Name: www
Value: yourdomain.com
TTL: Auto

DNS can take a little time to update.

You can check it with:

dig yourdomain.com

Or:

ping yourdomain.com

Once the domain points to your VPS, visit:

https://yourdomain.com

If everything is correct, you should see your landing page live on the internet.

Congratulations.

Your idea has left your laptop.

This is a big moment.

The internet is now allowed to judge it.

Politely, we hope.


Step 17: Test Production Signup

Submit the form on the live site.

Then check the admin endpoint:

curl https://yourdomain.com/admin/subscribers \
  -H "Authorization: Bearer YOUR_ADMIN_TOKEN"

Export CSV:

curl https://yourdomain.com/admin/subscribers.csv \
  -H "Authorization: Bearer YOUR_ADMIN_TOKEN" \
  -o subscribers.csv

You now have a live landing page and a working subscriber system.

That is a real MVP.

Small, yes.

But real.


Step 18: Add Basic Firewall Rules

On Ubuntu, you can use UFW.

Allow SSH:

sudo ufw allow OpenSSH

Allow web traffic:

sudo ufw allow 80
sudo ufw allow 443

Enable firewall:

sudo ufw enable

Check status:

sudo ufw status

You do not need to expose port 3000.

Caddy talks to Node locally.

The outside world talks to Caddy.

This is cleaner and safer.

The outside world does not need to know your Node app is sitting there in sweatpants.


Step 19: Add Backups

Your subscriber database is just a file:

data/subscribers.db

That is convenient.

It also means you should back it up.

Create a backup folder:

mkdir -p ~/backups

Manual backup:

cp ~/apps/launchkit-landing/data/subscribers.db \
  ~/backups/subscribers-$(date +%F-%H%M).db

You can later automate this with cron.

For example:

crontab -e

Add:

0 3 * * * cp /home/deploy/apps/launchkit-landing/data/subscribers.db /home/deploy/backups/subscribers-$(date +\%F-\%H\%M).db

That creates a backup every night at 03:00.

Your future self will thank you.

Future self is usually tired and holding coffee.

Be kind to them.


Step 20: Updating the Landing Page

When you change your local code, commit it:

git add .
git commit -m "Improve landing page copy"
git push

On the server:

cd ~/apps/launchkit-landing
git pull
npm install --omit=dev
sudo systemctl restart launchkit

Check logs:

journalctl -u launchkit -f

If you only change HTML or CSS, you still restart the service to keep the workflow simple.

Could you make this more advanced?

Yes.

Should you?

Not yet.

We are validating an idea, not operating a space station.


What to Put on the Landing Page

The code matters, but the copy matters more.

A landing page should answer these questions quickly:

What is this?
Who is it for?
What problem does it solve?
Why should I care now?
What should I do next?

A good structure:

Headline:
  The main promise.

Subtitle:
  A simple explanation.

Benefits:
  3 things the user gets.

Form:
  Email + one useful question.

Social proof:
  Subscriber count, testimonials, screenshots, or founder note.

Call to action:
  Join the list.

For example:

Launch less. Learn more.

Validate your product idea before building the whole spaceship.

Join the early list and get practical tools for testing demand,
collecting feedback, and deciding what to build next.

That is clear.

Nobody needs to decode it like ancient developer poetry.


What Question Should You Ask Subscribers?

Do not only ask for email.

Ask one useful question.

Good examples:

What are you trying to solve?
What is the most annoying part of this problem?
How do you solve this today?
What would make this useful for you?
What kind of product are you building?

That one extra field can teach you more than analytics.

Analytics tells you what people clicked.

Their answer tells you what they care about.

That is gold.

Tiny messy human gold.


What to Do After Publishing

Once your landing page is live, your job changes.

You are no longer just coding.

Now you are learning.

Share the page:

  • on X/Twitter
  • on LinkedIn
  • in niche communities
  • with friends who fit the audience
  • in founder groups
  • in developer communities
  • in newsletters
  • in relevant Reddit communities, carefully and respectfully
  • directly with people who have the problem

Do not spam.

Do not run into communities yelling “CHECK OUT MY STARTUP.”

That is not marketing.

That is a digital seagull attack.

Instead, be useful.

Say what you are building, who it is for, and what kind of feedback you want.

Example:

I am testing an idea for indie hackers who want to validate product ideas before building the full app.

I made a tiny landing page and I am collecting early feedback.

If you have tried validating a product idea before, I would love to know what made it painful.

That is much better.

Human. Clear. Not weird.


How to Know If the Landing Page Is Working

Do not only count page views.

Page views are nice, but they can lie.

Better signals:

  • people subscribe
  • people write thoughtful answers
  • people reply to your follow-up email
  • people ask when it will launch
  • people describe the pain clearly
  • people share the page
  • people ask about pricing
  • people say, “Can I try it?”

The strongest signal is not:

Cool idea.

The strongest signal is:

I need this. When can I use it?

That is the sound of an MVP doing its job.


What Not to Add Yet

Please do not immediately add:

  • user accounts
  • OAuth
  • subscriptions
  • dashboards
  • admin roles
  • Kubernetes
  • queues
  • microservices
  • GraphQL
  • dark/light/system theme preference syncing across devices
  • a plugin marketplace
  • a mascot with lore

You are building a landing page.

The page has one job:

Find out if people care.

Everything else is optional until the signal is strong.

The overengineering goblin will whisper:

But what if we need scale?

Tell the goblin:

If we need scale, that means the landing page worked.

Then continue.


When to Improve the MVP

Improve the MVP when you learn something.

For example:

If visitors do not subscribe, improve the headline.

If visitors subscribe but write vague answers, improve the question.

If people ask the same thing repeatedly, add that to the page.

If people want pricing, add a pricing question.

If people ask for a demo, build a tiny demo.

If nobody cares, change the idea or audience.

That is the loop:

Publish
Share
Measure
Talk
Learn
Improve
Repeat

This loop is more important than the tech stack.

The tech stack is just the bicycle.

The learning loop is the road.


Production Checklist

Before you share the page widely, check this:

[ ] Domain points to VPS
[ ] HTTPS works
[ ] Form submits successfully
[ ] Duplicate emails are handled
[ ] Admin token is not the default value
[ ] APP_SECRET is not the default value
[ ] SQLite database is stored in data/
[ ] Firewall allows only SSH, HTTP, HTTPS
[ ] systemd service restarts app
[ ] Caddy proxies to 127.0.0.1:3000
[ ] CSV export works
[ ] You have a backup plan
[ ] The headline is clear
[ ] The call to action is obvious

This is enough for an MVP.

Not perfect.

Enough.

Perfect is where shipping goes to die wearing a nice suit.


Final Thoughts

A landing page is not “just a page.”

It is a test.

It tests whether your idea makes sense to people who are not trapped inside your head.

That is valuable.

With this small Node.js MVP, you can:

  • explain your idea
  • collect interested subscribers
  • store useful feedback
  • export leads
  • publish to a VPS
  • run behind HTTPS
  • keep the app alive with systemd
  • avoid building a giant thing too early

This is how you go from:

I have an idea.

to:

People are signing up.

That is a meaningful step.

Not because the code is fancy.

Because the idea is finally touching reality.

So build the page.

Publish it.

Share it.

Talk to the people who sign up.

Then build the product they are already trying to pull out of you.

And whatever you do, do not feed the overengineering goblin after midnight.

Actually, do not feed it at all.

More to read


Build a Modern Node.js API Without Overengineering It
27/6/2026

Learn how to build a clean, modern Node.js backend API using Fastify, TypeScript, Zod, MongoDB, Mongoose, and Docker. Simple enough to understand, structured enough to grow.

What Is Artificial Intelligence?
22/6/2026

Artificial intelligence, or AI, is one of those terms people hear everywhere, but it can sound more complicated than it really is.

Create a Peer-to-Peer Chat App with WebRTC
19/6/2026

In this tutorial, we will build a simple peer-to-peer chat app using **WebRTC** and **Socket.IO**. The idea is simple: two users open the same page, join the same room, connect directly through WebRTC, and then send messages to each other using a **data channel**.

The Pomodoro Technique: How I Learned to Focus as a Programmer, One Tomato at a Time
19/6/2026

A personal and practical guide to using the Pomodoro Technique as a programmer. This article explains how working in short, focused sessions can help reduce distractions, improve concentration, make complex coding tasks feel manageable, and turn productivity into a simple, motivating habit.