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
Let’s then add a picture in the PNG format into the public/avatars
directory.
$ cp ~/Downloads/learnbackend.png linktree-lite/public/avatars/.
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
Step 2: Create the Server
Within the root directory, let’s create a new file named server.js
:
$ touch server.js
And within this file, let’s:
- Import the express package.
- Create a new server instance by invoking the top-level function exported by the package.
- Bind the server to the development port
5000
using thelisten()
method of the server instance.
const express = require('express');
const server = express();
server.listen(5000, () => {
console.log('Server running on port 5000...');
});
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: {
//
}
};
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...');
});
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...');
});
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();
});
// ...
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);
}
});
// ...
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
};
});
// ...
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}`
};
});
// ...
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])
}));
});
// ...
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 });
});
// ...
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:
- Register the Mustache rendering function as the template engine.
- Set
'mustache'
as the file extension used for templates. - 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'));
// ...
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'));
// ...
Create the profile template
Within the ./views
directory, let’s create a new file named profile.mustache
.
$ touch views/profile.mustache
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>
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...');
});
Test the endpoint
You can now start the server by passing the server.js
file as argument of the node
utility.
$ node server.js
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);
// ...
});
// ...
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) => {
//
});
// ...
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);
});
// ...
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);
}
});
// ...
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);
}
});
// ...
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;
});
// ...
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}`);
});
// ...
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}`
}))
};
// ...
});
// ...
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) => {
//
});
// ...
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();
});
// ...
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);
}
});
// ...
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);
}
});
// ...
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]));
});
// ...
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);
});
// ...
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) => {
//
});
// ...
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();
});
// ...
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);
}
});
// ...
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
};
});
// ...
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
}
});
});
// ...
Test the endpoint
You can now press CTRL+C
to stop the server and restart it with the following command.
$ node server.js
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}}}}
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)