DEV Community

Cover image for Build Your Own Linktree-Lite with Node.js, Express, and Mustache (API + Frontend Page)
Razvan
Razvan

Posted on

Build Your Own Linktree-Lite with Node.js, Express, and Mustache (API + Frontend Page)

Welcome back to Code in Action, the series where we build practical backend projects step by step.

In this tutorial, we’re going to recreate a simplified version of Linktree — the popular website that lets you share all your links from one single page.

Our version will be Linktree-Lite, built with Node.js + Express + Mustache, and it will include both:

  • A backend API to store and serve your links.
  • A simple frontend page that displays them in a clean list.

Why this project? Because it’s small enough to build in an afternoon, but realistic enough to show you how backends and frontends talk to each other.

You’ll practice API routes, Express basics, and even return a dynamic HTML page — skills that transfer directly into more advanced backend work.

By the end, you’ll have a working Linktree clone you can extend, customize, or add to your portfolio.

Step 1: Set Up the Project

Let’s start by creating a new directory named linktree-lite, and within it other directories named public/avatars for storing profile pictures and views for storing frontend templates.

$ mkdir -p linktree-lite/public/avatars linktree-lite/views
Enter fullscreen mode Exit fullscreen mode

Let’s then add a picture in the PNG format into the public/avatars directory.

$ cp ~/Downloads/learnbackend.png linktree-lite/public/avatars/.
Enter fullscreen mode Exit fullscreen mode

And let’s enter the project’s directory, initialize it (i.e. create the package.json file) and install the Express framework for building the API, as well as the Mustache template engine for converting templates into HTML files.

$ cd linktree-lite
$ npm init
$ npm install express mustache-express
Enter fullscreen mode Exit fullscreen mode

Step 2: Create the Server

Within the root directory, let’s create a new file named server.js:

$ touch server.js
Enter fullscreen mode Exit fullscreen mode

And within this file, let’s:

  1. Import the express package.
  2. Create a new server instance by invoking the top-level function exported by the package.
  3. Bind the server to the development port 5000 using the listen() method of the server instance.
const express = require('express');

const server = express();

server.listen(5000, () => {
  console.log('Server running on port 5000...');
});
Enter fullscreen mode Exit fullscreen mode

Step 3: Create a Database Object

In order to keep this project as simple as possible and focus on the logic of the endpoints, we’ll use an object literal as database with a single property named users, which will contains the entire list of user profiles.

let database = {
  users: {
    //
  }
};
Enter fullscreen mode Exit fullscreen mode

Each profile will be stored under its unique username, and contain the following properties:

  • bio: a string that contains a short description of the profile
  • links: an array of objects that contains a list of personal URLs
  • socials: an object that contains a list of social platform handles
  • stats: an object that contains statistics about client IP addresses and link clicks

For example:

const express = require('express');

const server = express();

let database = {
  users: {
    'learnbackend': {
      bio: 'A zero-to-hero roadmap to become a professional Node.js backend developer in 12 months.',
      links: [
        {
          label: 'Learn Backend',
          url: 'https://learnbackend.dev'
        },
        {
          label: 'Mastery Program',
          url: 'https://learnbackend.dev/get-started'
        },
        {
          label: 'Skill Surge Challenge',
          url: 'https://learnbackend.dev/challenges/skillsurge'
        },
      ],
      socials: {
        youtube: 'learnbackend',
        medium: 'learnbackend'
      },
      stats: {
        ips: [],
        clicks: {
          links: [],
          socials: {},
        }
      }
    }
  }
};

server.listen(5000, () => {
  console.log('Server running on port 5000...');
});
Enter fullscreen mode Exit fullscreen mode

Step 4: Render the Profile Page

Let’s declare a new HTTP GET endpoint in charge of retrieving a user profile based on its username, and serving it to the client in the form of an HTML file.

const express = require('express');

const server = express();

let database = {/* ... */};

server.get('/@:username', (req, res) => {
  //
});

server.listen(5000, () => {
  console.log('Server running on port 5000...');
});
Enter fullscreen mode Exit fullscreen mode

Retrieve user data

Within the controller, let’s extract the username variable from the req.params query string parameters object, and convert it to lowercase using its toLowerCase() method.

// ...

server.get('/@:username', (req, res) => {
  const username = req.params.username.toLowerCase();
});

// ...
Enter fullscreen mode Exit fullscreen mode

Let’s then use the username as key to retrieve the corresponding data from the database.users object, and respond with an HTTP 404 Not Found using the sendStatus() method of the response object if the username is not a valid key.

// ...

server.get('/@:username', (req, res) => {
  const username = req.params.username.toLowerCase();
  const user = database.users[username];

  if (!user) {
    return res.sendStatus(404);
  }
});

// ...
Enter fullscreen mode Exit fullscreen mode

Format user data

If the username exists, let’s first declare an object named data that will contain the formatted data displayed on the HTML profile page, and let’s add the user’s username, bio, avatar URL, and personal links to it.

// ...

server.get('/@:username', (req, res) => {
  const username = req.params.username.toLowerCase();
  const user = database.users[username];

  if (!user) {
    return res.sendStatus(404);
  }

  let data = {
    username,
    bio: user.bio,
    avatar: `/avatars/${username}.png`,
    links: user.links
  };
});

// ...
Enter fullscreen mode Exit fullscreen mode

Let’s then declare a socialUrlsMap object that implements a list of helper functions that take a social media handle and return the full URL to the profile on that platform.

// ...

server.get('/@:username', (req, res) => {
  const username = req.params.username.toLowerCase();
  const user = database.users[username];

  if (!user) {
    return res.sendStatus(404);
  }

  let data = {
    username,
    bio: user.bio,
    avatar: `/avatars/${username}.png`,
    links: user.links
  };

  const socialUrlsMap = {
    facebook: handle => `https://facebook.com/${handle}`,
    instagram: handle => `https://instagram.com/${handle}`,
    youtube: handle => `https://youtube.com/@${handle}`,
    x: handle => `https://x.com/${handle}`,
    medium: handle => `https://medium.com/@${handle}`
  };
});

// ...
Enter fullscreen mode Exit fullscreen mode

Let’s use the methods of the socialUrlsMap object to map each account handle to its corresponding URL on the platform, and add it to the data object.

// ...

server.get('/@:username', (req, res) => {
  const username = req.params.username.toLowerCase();
  const user = database.users[username];

  if (!user) {
    return res.sendStatus(404);
  }

  let data = {
    username,
    bio: user.bio,
    avatar: `/avatars/${username}.png`,
    links: user.links
  };

  const socialUrlsMap = {
    facebook: handle => `https://facebook.com/${handle}`,
    instagram: handle => `https://instagram.com/${handle}`,
    youtube: handle => `https://youtube.com/@${handle}`,
    x: handle => `https://x.com/${handle}`,
    medium: handle => `https://medium.com/@${handle}`
  };

  data.socials = Object.keys(user.socials).map(platform => ({
    platform,
    url: socialUrlsMap[platform](user.socials[platform])
  }));
});

// ...
Enter fullscreen mode Exit fullscreen mode

Finally, let’s temporarily respond to the client with an HTTP 200 OK containing the data object in the JSON format using the json() method of the response object until the rest of the implementation is complete.

// ...

server.get('/@:username', (req, res) => {
  const username = req.params.username.toLowerCase();
  const user = database.users[username];

  if (!user) {
    return res.sendStatus(404);
  }

  let data = {
    username,
    bio: user.bio,
    avatar: `/avatars/${username}.png`,
    links: user.links
  };

  const socialUrlsMap = {
    facebook: handle => `https://facebook.com/${handle}`,
    instagram: handle => `https://instagram.com/${handle}`,
    youtube: handle => `https://youtube.com/@${handle}`,
    x: handle => `https://x.com/${handle}`,
    medium: handle => `https://medium.com/@${handle}`
  };

  data.socials = Object.keys(user.socials).map(platform => ({
    platform,
    url: socialUrlsMap[platform](user.socials[platform])
  }));

  res.json({ data });
});

// ...
Enter fullscreen mode Exit fullscreen mode

Set Up the Template Engine

In order to convert a template into a valid HTML page and serve it to the client, we need to tell Express which engine to use and how to use it.

So, let’s import the Node.js core path module and the mustache-express module, and:

  1. Register the Mustache rendering function as the template engine.
  2. Set 'mustache' as the file extension used for templates.
  3. Set the ./views folder as the templates folder.
const express = require('express');
const path = require('node:path');
const mustacheExpress = require('mustache-express');

const server = express();

let database = {/* ... */};

server.engine('mustache', mustacheExpress());
server.set('view engine', 'mustache');
server.set('views', path.join(process.cwd(), 'views'));

// ...
Enter fullscreen mode Exit fullscreen mode

And let’s not forget to tell Express to serve static assets, such as images, from the ./public directory, which will be used to include the avatar pictures in the profile page.

const express = require('express');
const path = require('node:path');
const mustacheExpress = require('mustache-express');

const server = express();

let database = {/* ... */};

server.engine('mustache', mustacheExpress());
server.set('view engine', 'mustache');
server.set('views', path.join(process.cwd(), 'views'));

server.use(express.static('public'));

// ...
Enter fullscreen mode Exit fullscreen mode

Create the profile template

Within the ./views directory, let’s create a new file named profile.mustache.

$ touch views/profile.mustache
Enter fullscreen mode Exit fullscreen mode

Within this file, let’s write the following Mustache template, where expressions such as {{username}} or {{platform}} will be replaced by the Mustache engine with actual values from the data object, such as learnbackend or instagram.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>@{{username}} | Linktree-Lite</title>
  <style>
    body { font: 16px/1.4 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background:#0b0f14; color:#e6e6e6; display:grid; place-items:center; min-height:100vh; margin:0; }
    main { width: 560px; max-width: 92vw; padding: 60px 24px; border-radius: 16px; background:#121821; box-shadow: 0 8px 32px rgba(0,0,0,.35); }
    header { text-align:center; margin-bottom: 30px; }
    .avatar { width: 88px; height: 88px; border-radius: 999px; display:block; margin: 0 auto 8px auto; object-fit: cover; }
    .username { margin: 0 0 12px; font-size: 20px; }
    .bio { margin: 0 0 16px; color:#b8c1cc; }
    .socials { display:flex; gap:8px; justify-content:center; margin-top: 10px; margin-bottom: 40px; }
    .socials a { padding: 8px 10px; border-radius: 999px; background:#1b2431; text-decoration:none; color:#e6e6e6; }
    .socials a:hover { background:#232e3e; }
    .links a { display:block; margin: 10px 0; padding: 12px 14px; border-radius: 10px; background:#1b2431; text-decoration:none; color:#e6e6e6; text-align:center; }
    .links a:hover { background:#232e3e; }
  </style>
</head>
<body>
  <main>
    <header>
      <img class="avatar" src="{{avatar}}" alt="@{{username}} avatar" />
      <h1 class="username">@{{username}}</h1>
      <p class="bio">{{bio}}</p>
    </header>

    <div class="socials">
      {{#socials}}
        <a href="{{url}}">{{platform}}</a>
      {{/socials}}
    </div>

    <div class="links">
      {{#links}}
        <a href="{{url}}">{{label}}</a>
      {{/links}}
    </div>
  </main>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Render the template

Now that everything is set, let’s render the template and send an actual HTML profile page to the client by replacing the call to the json() method by the render() method, and pass it as arguments the name of the template file (without its extension) and the data object.

// ...

server.get('/@:username', (req, res) => {
  // ...

  res.render('profile', data);
});

server.listen(5000, () => {
  console.log('Server running on port 5000...');
});
Enter fullscreen mode Exit fullscreen mode

Test the endpoint

You can now start the server by passing the server.js file as argument of the node utility.

$ node server.js
Enter fullscreen mode Exit fullscreen mode

And open the following URL in your web browser 127.0.0.1:5000/@learnbackend, which should display a similar page.

Step 5: Count Profile Visits

To create simple statistics about profile visits, let’s store the IP address of each client into the stats.ips array of the user in the database object every time a request is routed to the HTTP GET /@:username endpoint.

// ...

server.get('/@:username', (req, res) => {
  const username = req.params.username.toLowerCase();
  const user = database.users[username];

  if (!user) {
    return res.sendStatus(404);
  }

  database.users[username].stats.ips.push(req.ip);

  // ...
});

// ...
Enter fullscreen mode Exit fullscreen mode

Step 6: Count Links Clicked

To count how many times the links of a profile page have been clicked, we’ll use a mechanism called redirection.

Instead of directly displaying the personal and social media links to the profile page, we’re going to generate custom links that point to special API endpoints in charge of counting the clicks, then redirecting the users to the original URLs.

Count personal links clicked

Let’s declare a new HTTP GET endpoint in charge of counting how many times each personal link is clicked, and redirecting the user to the original URL.

// ...

server.get('/redirect/:username/link/:index', (req, res) => {
  //
});

// ...
Enter fullscreen mode Exit fullscreen mode

Let’s extract and normalize the username and the index variables from the query string parameters object.

// ...

server.get('/redirect/:username/link/:index', (req, res) => {
  const username = req.params.username.toLowerCase();
  const index = parseInt(req.params.index, 10);
});

// ...
Enter fullscreen mode Exit fullscreen mode

Let’s retrieve the user corresponding to the username in the database, and respond to the client with an HTTP 404 Not Found if the username doesn’t exist in the database.

// ...

server.get('/redirect/:username/link/:index', (req, res) => {
  const username = req.params.username.toLowerCase();
  const index = parseInt(req.params.index, 10);
  const user = database.users[username];

  if (!user) {
    return res.sendStatus(404);
  }
});

// ...
Enter fullscreen mode Exit fullscreen mode

Let’s retrieve the original URL located at the specified index in the links array, and respond to the client with an HTTP 404 Not Found if the index doesn’t point to an existing element.

// ...

server.get('/redirect/:username/link/:index', (req, res) => {
  const username = req.params.username.toLowerCase();
  const index = parseInt(req.params.index, 10);
  const user = database.users[username];

  if (!user) {
    return res.sendStatus(404);
  }

  const url = user.links[parseInt(index)]?.url;

  if (!url) {
    return res.sendStatus(404);
  }
});

// ...
Enter fullscreen mode Exit fullscreen mode

Let’s then increment the clicks counter of the corresponding element in the stats object by 1.

// ...

server.get('/redirect/:username/link/:index', (req, res) => {
  const username = req.params.username.toLowerCase();
  const index = parseInt(req.params.index, 10);
  const user = database.users[username];

  if (!user) {
    return res.sendStatus(404);
  }

  const url = user.links[parseInt(index)]?.url;

  if (!url) {
    return res.sendStatus(404);
  }

  user.stats.clicks.links[index] = (user.stats.clicks.links[index] || 0) + 1;
});

// ...
Enter fullscreen mode Exit fullscreen mode

And finally, let’s check if the original URL starts with the string 'https://', prefix the URL with it if it doesn’t, and redirect the user to the URL using the redirect() method of the response object.

// ...

server.get('/redirect/:username/link/:index', (req, res) => {
  const username = req.params.username.toLowerCase();
  const index = parseInt(req.params.index, 10);
  const user = database.users[username];

  if (!user) {
    return res.sendStatus(404);
  }

  const url = user.links[parseInt(index)].url;

  if (!url) {
    return res.sendStatus(404);
  }

  user.stats.clicks.links[index] = (user.stats.clicks.links[index] || 0) + 1;

  res.redirect(/^https?:\/\//.test(url) ? url : `https://${url}`);
});

// ...
Enter fullscreen mode Exit fullscreen mode

Update personal links in the template

Let’s now update the data.links array in the first endpoint, so that instead of including the personal URLs, it includes a list of special links that point to the previously defined API endpoint.

// ...

server.get('/@:username', (req, res) => {
  const username = req.params.username.toLowerCase();
  const user = database.users[username];

  if (!user) {
    return res.sendStatus(404);
  }

  let data = {
    username,
    bio: user.bio,
    avatar: `/avatars/${username}.png`,
    links: user.links.map((link, index) => ({
      label: link.label,
      url: `/redirect/${username}/link/${index}`
    }))
  };

  // ...
});

// ...
Enter fullscreen mode Exit fullscreen mode

Count social media links clicked

Let’s declare a new HTTP GET endpoint in charge of counting how many times each social media link is clicked, and redirecting the user to the specified platform.

// ...

server.get('/redirect/:username/social/:platform', (req, res) => {
  //
});

// ...
Enter fullscreen mode Exit fullscreen mode

Let’s extract and normalize the username and the platform variables from the query string parameters object.

// ...

server.get('/redirect/:username/social/:platform', (req, res) => {
  const username = req.params.username.toLowerCase();
  const platform = req.params.platform.toLowerCase();
});

// ...
Enter fullscreen mode Exit fullscreen mode

Let’s retrieve the user corresponding to the username in the database, and respond to the client with an HTTP 404 Not Found if the username doesn’t exist in the database.

// ...

server.get('/redirect/:username/social/:platform', (req, res) => {
  const username = req.params.username.toLowerCase();
  const platform = req.params.platform.toLowerCase();
  const user = database.users[username];

  if (!user) {
    return res.sendStatus(404);
  }
});

// ...
Enter fullscreen mode Exit fullscreen mode

Let’s remove the socialUrlsMap object from the HTTP GET /@:username endpoint and add it to this one instead, and respond with an HTTP 404 Not Found if the socialUrlsMap of the user’ socials object doesn't include the specified platform.

// ...

server.get('/redirect/:username/social/:platform', (req, res) => {
  const username = req.params.username.toLowerCase();
  const platform = req.params.platform.toLowerCase();
  const user = database.users[username];

  if (!user) {
    return res.sendStatus(404);
  }

  const socialUrlsMap = {
    facebook: handle => `https://facebook.com/${handle}`,
    instagram: handle => `https://instagram.com/${handle}`,
    youtube: handle => `https://youtube.com/@${handle}`,
    x: handle => `https://x.com/${handle}`,
    medium: handle => `https://medium.com/@${handle}`
  };

  if (!Object.keys(socialUrlsMap).includes(platform) || !user.socials[platform]) {
    return res.sendStatus(404);
  }
});

// ...
Enter fullscreen mode Exit fullscreen mode

Finally, let’s increment the clicks counter of the corresponding social platform in the stats object by 1, and redirect the user to their profile on that platform.

// ...

server.get('/redirect/:username/social/:platform', (req, res) => {
  const username = req.params.username.toLowerCase();
  const platform = req.params.platform.toLowerCase();
  const user = database.users[username];

  if (!user) {
    return res.sendStatus(404);
  }

  const socialUrlsMap = {
    facebook: handle => `https://facebook.com/${handle}`,
    instagram: handle => `https://instagram.com/${handle}`,
    youtube: handle => `https://youtube.com/@${handle}`,
    x: handle => `https://x.com/${handle}`,
    medium: handle => `https://medium.com/@${handle}`
  };

  if (!Object.keys(socialUrlsMap).includes(platform) || !user.socials[platform]) {
    return res.sendStatus(404);
  }

  user.stats.clicks.socials[platform] = (user.stats.clicks.socials[platform] || 0) + 1;

  res.redirect(socialUrlsMap[platform](user.socials[platform]));
});

// ...
Enter fullscreen mode Exit fullscreen mode

Update social links in the template

Let’s now update the first endpoint, so that the data.socials object contains an array of custom links that point to the new endpoint we’ve just defined.

// ...

server.get('/@:username', (req, res) => {
  const username = req.params.username.toLowerCase();
  const user = database.users[username];

  if (!user) {
    return res.sendStatus(404);
  }

  database.users[username].stats.ips.push(req.ip);

  let data = {
    username,
    bio: user.bio,
    avatar: `/avatars/${username}.png`,
    links: user.links.map((link, index) => ({
      label: link.label,
      url: `/redirect/${username}/link/${index}`
    })),
    socials: Object.keys(user.socials).map(platform => ({
      platform,
      url: `/redirect/${username}/social/${platform}`
    }))
  };

  res.render('profile', data);
});

// ...
Enter fullscreen mode Exit fullscreen mode

Step 7: Retrieve User Visits and Clicks

Let’s declare a new HTTP GET endpoint in charge of retrieving a user statistics based on its username, and serving it to the client in the form of a JSON object.

// ...

server.get('/stats/:username', (req, res) => {
  //
});

// ...
Enter fullscreen mode Exit fullscreen mode

Within the controller, let’s extract and normalize the username variable from the query string parameters object.

// ...

server.get('/stats/:username', (req, res) => {
  const username = req.params.username.toLowerCase();
});

// ...
Enter fullscreen mode Exit fullscreen mode

Let’s use the username as key to retrieve its stats object, and respond with an HTTP 404 Not Found if the returned value is null.

// ...

server.get('/stats/:username', (req, res) => {
  const username = req.params.username.toLowerCase();
  let stats = database.users[username]?.stats;

  if (!stats) {
    return res.sendStatus(404);
  }
});

// ...
Enter fullscreen mode Exit fullscreen mode

Let’s add a new property to the stats object called visits, and within it two properties called total and unique, where:

  • total: the total number of client IP addresses that connected to the server and loaded the profile page.
  • unique: the total number of unique IP address.
// ...

server.get('/stats/:username', (req, res) => {
  const username = req.params.username.toLowerCase();
  let stats = database.users[username]?.stats;

  if (!stats) {
    return res.sendStatus(404);
  }

  stats.visits = {
    total: stats.ips.length,
    unique: new Set(stats.ips).size
  };
});

// ...
Enter fullscreen mode Exit fullscreen mode

Finally, let’s respond with an HTTP 200 OK containing the number of visits and clicks in the JSON format using the json() method of the response object.

// ...

server.get('/stats/:username', (req, res) => {
  const username = req.params.username.toLowerCase();
  let stats = database.users[username]?.stats;

  if (!stats) {
    return res.sendStatus(404);
  }

  stats.visits = {
    total: stats.ips.length,
    unique: new Set(stats.ips).size
  };

  return res.json({
    stats: {
      visits: stats.visits,
      clicks: stats.clicks
    }
  });
});

// ...
Enter fullscreen mode Exit fullscreen mode

Test the endpoint

You can now press CTRL+C to stop the server and restart it with the following command.

$ node server.js
Enter fullscreen mode Exit fullscreen mode

Then, you can run the following cURL command in your terminal, which should produce a similar output.

$ curl 127.0.0.1:5000/stats/learnbackend
{"stats":{"visits":{"total":10,"unique":3},"clicks":{"links":[1,2],"socials":{"youtube":2,"medium":1}}}}
Enter fullscreen mode Exit fullscreen mode

Final thoughts

Congratulations!

You just shipped a working Linktree-Lite: one SSR profile page, clean JSON view-modeling, and click/visit analytics powered by server-side redirects.

Along the way you practiced Express routing, template rendering, data shaping, and simple tracking — exactly the building blocks real backends are made of.

Development ideas:

  • Add rate-limits to visits/clicks and ignore rapid repeats.
  • Persist data and create a tiny admin form to edit links.
  • Support themes and a “featured” link.

What’s next?

👉 Want to run this project on your machine? Download the source code for free at https://learnbackend.dev/downloads/code-in-action/linktree-lite

👉 Ready to go pro with backend development? Join the Learn Backend Mastery Program today — a zero-to-hero roadmap to become a professional Node.js backend developer and land your first job in 12 months.

Top comments (0)