Build a Fire TV-Style Remote Web App

Build a real-time remote control web app using Node.js and Socket.IO.

Introduction

30 Second Summary

Ever picked up your phone to control your TV? Streaming apps like the Amazon Fire TV remote let you navigate shows, control playback, and switch apps from your phone.

In this project, you will build a two-screen web app inspired by the Fire TV remote. A TV Display runs on your computer while a Remote Control runs on your phone, with commands flowing between them in real-time.

What You'll Build

You will build a Node.js server powered by Express and Socket.IO that connects a TV Display on your computer to a Remote Control on your phone in real-time.

By the end of this project, you'll have:

  • A Node.js server powered by Express and Socket.IO that relays real-time commands between devices.
  • A TV Display screen with a streaming content grid, playback status bar, and real-time selection highlighting.
  • A mobile-friendly Remote Control with a circular D-pad, playback buttons, and app launcher shortcuts.
  • Secret Mission: Add a live keyboard search feature where text typed on your phone appears instantly on the TV.

Are there any prerequisites?

This project is designed for complete beginners. All required tools and setup instructions are included in Step 1.

Set Up the Project and Server

Every real-time app needs a server sitting between the devices that talk to each other. Your Fire TV remote is no different. The phone sends a command, the server receives it, and the server forwards it to the TV.

In this step, you'll create the project folder, install two key packages, and write a server that relays remote control commands between connected devices.

In this step, get ready to:
  • Initialize the project folder and install Express and Socket.IO.
  • Create a server that serves web pages and relays real-time events.
  • Verify the server starts successfully.
Initialize the project and install dependencies

Before writing any code, you need a project folder with the right packages installed. You'll use two packages for this project.

  • Express serves your HTML pages to the browser.
  • Socket.IO handles real-time communication between devices using WebSockets.
  • In VS Code, click Terminal in the top menu bar.
  • Click New Terminal.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir fire-remote
cd fire-remote
  • Initialize your project with npm by running this command:
npm init -y

What does npm init -y do?

This command creates a package.json file in your project folder. package.json tracks your project's name, version, and the packages it depends on.

The -y flag accepts all the default settings so you skip the interactive questionnaire.

  • Install Express and Socket.IO by running this command:
npm install express@4 socket.io@4.8.3

Why these two packages?

Express is a web framework for Node.js that makes it easy to serve HTML files and handle routes. Socket.IO is a library that adds real-time, bidirectional communication between the server and connected browsers.

Together, Express serves your TV and remote pages, and Socket.IO lets those pages send and receive events instantly.

Install failed?

If you see a network error, check your internet connection and try running the command again.

If you see a permissions error, make sure you are running the terminal from inside the fire-remote folder (not a system-protected directory).

Help me fix my npm install error.

  • Create the public folder by running this command:
mkdir public
  • Confirm your files exist by running:
ls

You should see node_modules, package.json, package-lock.json, and public in the output.

Now open this folder in VS Code so you can start writing code.

  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the fire-remote folder, and click Select Folder.

What if VS Code asks to trust the folder?

VS Code may ask if you trust the authors of the files in this folder. Click Yes, I trust the authors to continue. This is a standard security prompt for newly opened folders.

Create the server

The server is the brain of this project. It serves your TV and remote pages to the browser and relays commands between them in real-time.

  • In the Explorer sidebar on the left, hover over the FIRE-REMOTE heading and click the New File icon.
  • Type server.js and press Enter.
  • In server.js, paste the following code to set up Express, serve static files, and define the two page routes:
// Import required packages
const express = require('express');
const { createServer } = require('node:http');
const { join } = require('node:path');
const { Server } = require('socket.io');

// Initialize Express, the HTTP server, and Socket.IO
const app = express();
const server = createServer(app);
const io = new Server(server);

// Serve files from the 'public' folder (CSS, JS, HTML assets)
app.use(express.static(join(__dirname, 'public')));

// Serve the TV display when someone visits the root URL
app.get('/', (req, res) => {
  res.sendFile(join(__dirname, 'public', 'tv.html'));
});

// Serve the remote control page at /remote
app.get('/remote', (req, res) => {
  res.sendFile(join(__dirname, 'public', 'remote.html'));
});

What does this code do?

The first four lines import the packages you need. express handles web requests. node:http creates the underlying HTTP server. node:path builds file paths safely. socket.io adds real-time event support.

The three initialization lines create the Express app, wrap it in an HTTP server, and attach Socket.IO to that server. express.static tells Express to serve any file in the public folder automatically.

The two app.get routes define what happens when someone visits / (the TV page) or /remote (the remote control page).

  • Below the routes you just pasted, add the Socket.IO connection handler and server startup code:
// Handle new device connections
io.on('connection', (socket) => {
  console.log('Device connected:', socket.id);

  // Forward D-pad navigation commands to all other devices
  socket.on('navigate', (direction) => {
    socket.broadcast.emit('navigate', direction);
  });

  // Forward playback control commands to all other devices
  socket.on('playback', (action) => {
    socket.broadcast.emit('playback', action);
  });

  // Forward app launch commands to all other devices
  socket.on('app-launch', (appName) => {
    socket.broadcast.emit('app-launch', appName);
  });

  socket.on('disconnect', () => {
    console.log('Device disconnected:', socket.id);
  });
});

// Start the server on port 3000
server.listen(3000, () => {
  console.log('Fire Remote server running at http://localhost:3000');
  console.log('Open /remote on your phone to use the remote control');
});

What does this code do?

The io.on('connection') block runs every time a new device connects to the server. Inside it, the server listens for three custom events: navigate, playback, and app-launch.

Each handler uses socket.broadcast.emit to forward the event to all other connected devices except the sender. This is the core relay pattern. The remote sends a command, the server forwards it, and only the TV receives it.

The server.listen(3000) call starts everything up on port 3000.

  • Save server.js by pressing Ctrl+S (Windows) or Cmd+S (macOS).

✔️ Awesome, I've got everything!

Great. Double check you've saved server.js before moving on.

ⓧ I'd like to double check the full code

Here is the complete server.js file. Compare it with yours to make sure everything matches.

const express = require('express');
const { createServer } = require('node:http');
const { join } = require('node:path');
const { Server } = require('socket.io');

const app = express();
const server = createServer(app);
const io = new Server(server);

app.use(express.static(join(__dirname, 'public')));

app.get('/', (req, res) => {
  res.sendFile(join(__dirname, 'public', 'tv.html'));
});

app.get('/remote', (req, res) => {
  res.sendFile(join(__dirname, 'public', 'remote.html'));
});

io.on('connection', (socket) => {
  console.log('Device connected:', socket.id);

  socket.on('navigate', (direction) => {
    socket.broadcast.emit('navigate', direction);
  });

  socket.on('playback', (action) => {
    socket.broadcast.emit('playback', action);
  });

  socket.on('app-launch', (appName) => {
    socket.broadcast.emit('app-launch', appName);
  });

  socket.on('disconnect', () => {
    console.log('Device disconnected:', socket.id);
  });
});

server.listen(3000, () => {
  console.log('Fire Remote server running at http://localhost:3000');
  console.log('Open /remote on your phone to use the remote control');
});
Verify the server starts

Your server is written. Time to make sure it actually runs without errors.

  • Open a terminal in VS Code by clicking Terminal in the top menu bar, then New Terminal.
  • Start the server by running this command:
node server.js

Windows firewall prompt

Windows may show a firewall dialog asking to allow Node.js through the firewall. Click Allow access. This is required for your phone to connect to the server over your local network later in the project.

You should see two lines printed in the terminal:

Fire Remote server running at http://localhost:3000
Open /remote on your phone to use the remote control
  • Open your browser and go to http://localhost:3000.

You should see a blank page with no errors. The TV page does not exist yet, so a blank screen is expected.

  • Go back to your VS Code terminal and stop the server by pressing Ctrl+C.

Server won't start?

If you see an error mentioning a missing module, make sure you ran npm install express@4 socket.io@4.8.3 from inside the fire-remote folder.

If port 3000 is already in use, close any other applications that might be using it and try again.

Help me fix my server startup error.

Your server is ready to relay commands between devices. Next up, you'll build the TV display that reacts to those commands in real-time.

Build the TV Display

Your server is ready to relay commands between devices. Now it needs a page to serve.

In this step, you will build the TV Display screen. This is the page your computer's browser shows, simulating a streaming TV interface with a content grid, a playback status bar, and a connection indicator.

In this step, get ready to:
  • Create the TV page with a header, content grid, and playback footer.
  • Style the display with a dark theme and orange accent color.
  • Add real-time logic that reacts to Socket.IO events from the server.
Create the TV page structure

The TV page simulates a streaming interface with three sections. A header shows the app name and a connection status indicator. A content grid displays 8 cards representing movies and shows. A footer bar tracks playback status and the active app.

  • In the VS Code Explorer sidebar, right-click the public folder.
  • Select New File and name it tv.html.
  • Paste the following code into tv.html:
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Fire Remote - TV Display</title>
  <link rel="stylesheet" href="tv.css">
</head>
<body>
  <div id="tv-screen">
    <!-- Header with app name and connection status -->
    <header id="top-bar">
      <h1>FireStream</h1>
      <div id="connection-status">
        <span id="status-dot"></span>
        <span id="status-text">Connecting...</span>
      </div>
    </header>

    <!-- Content grid with 8 cards (4 columns x 2 rows) -->
    <section id="content-area">
      <h2 id="section-title">Trending Now</h2>
      <div id="content-grid">
        <!-- First card starts with the selected class -->
        <div class="card selected" data-index="0">
          <div class="card-image" style="background-color: #e50914;">
            <span class="card-icon">&#127916;</span>
          </div>
          <p class="card-title">Action Movie</p>
        </div>
        <div class="card" data-index="1">
          <div class="card-image" style="background-color: #221f1f;">
            <span class="card-icon">&#127911;</span>
          </div>
          <p class="card-title">Drama Series</p>
        </div>
        <div class="card" data-index="2">
          <div class="card-image" style="background-color: #1a472a;">
            <span class="card-icon">&#127758;</span>
          </div>
          <p class="card-title">Nature Doc</p>
        </div>
        <div class="card" data-index="3">
          <div class="card-image" style="background-color: #1c3d5a;">
            <span class="card-icon">&#128640;</span>
          </div>
          <p class="card-title">Sci-Fi Thriller</p>
        </div>
        <div class="card" data-index="4">
          <div class="card-image" style="background-color: #5c2d91;">
            <span class="card-icon">&#128123;</span>
          </div>
          <p class="card-title">Horror Night</p>
        </div>
        <div class="card" data-index="5">
          <div class="card-image" style="background-color: #b8860b;">
            <span class="card-icon">&#128516;</span>
          </div>
          <p class="card-title">Comedy Special</p>
        </div>
        <div class="card" data-index="6">
          <div class="card-image" style="background-color: #8b0000;">
            <span class="card-icon">&#9829;</span>
          </div>
          <p class="card-title">Romance</p>
        </div>
        <div class="card" data-index="7">
          <div class="card-image" style="background-color: #2f4f4f;">
            <span class="card-icon">&#128270;</span>
          </div>
          <p class="card-title">Mystery</p>
        </div>
      </div>
    </section>

    <!-- Footer showing current playback and active app -->
    <footer id="now-playing">
      <div id="playback-status">
        <span id="playback-icon">&#9654;</span>
        <span id="playback-text">Ready</span>
      </div>
      <div id="app-indicator">Home</div>
    </footer>
  </div>

  <!-- Socket.IO client library (auto-served by the server) -->
  <script src="/socket.io/socket.io.js"></script>
  <script src="tv.js"></script>
</body>
</html>

What does this code do?

The page has three main sections stacked vertically inside a #tv-screen container.

  • The #top-bar header shows the app title and a connection status dot. The dot turns green once the page connects to the Socket.IO server.
  • The #content-grid holds 8 cards with emoji icons and titles. The first card has a selected class so the cursor starts there.
  • The #now-playing footer tracks the current playback state and which app is active.
  • The <script src="/socket.io/socket.io.js"> tag loads the Socket.IO client library, which the server auto-serves at that path.
  • Save tv.html.
  • You won't see the page in the browser yet. The next substep adds the CSS stylesheet that gives it the dark Fire TV look.

See a red squiggly line in VS Code?

  • Make sure the file is saved inside the public folder, not the project root. The file path should be public/tv.html.
  • Check that every opening tag has a matching closing tag. A missing </div> or </section> causes unexpected layout issues.
  • Still stuck? Help me figure out why my tv.html file has errors in VS Code.

✔️ Awesome, I've got everything!

Great. Double check you've saved tv.html inside the public folder.

ⓧ I'd like to double check the full code

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Fire Remote - TV Display</title>
  <link rel="stylesheet" href="tv.css">
</head>
<body>
  <div id="tv-screen">
    <header id="top-bar">
      <h1>FireStream</h1>
      <div id="connection-status">
        <span id="status-dot"></span>
        <span id="status-text">Connecting...</span>
      </div>
    </header>

    <section id="content-area">
      <h2 id="section-title">Trending Now</h2>
      <div id="content-grid">
        <div class="card selected" data-index="0">
          <div class="card-image" style="background-color: #e50914;">
            <span class="card-icon">&#127916;</span>
          </div>
          <p class="card-title">Action Movie</p>
        </div>
        <div class="card" data-index="1">
          <div class="card-image" style="background-color: #221f1f;">
            <span class="card-icon">&#127911;</span>
          </div>
          <p class="card-title">Drama Series</p>
        </div>
        <div class="card" data-index="2">
          <div class="card-image" style="background-color: #1a472a;">
            <span class="card-icon">&#127758;</span>
          </div>
          <p class="card-title">Nature Doc</p>
        </div>
        <div class="card" data-index="3">
          <div class="card-image" style="background-color: #1c3d5a;">
            <span class="card-icon">&#128640;</span>
          </div>
          <p class="card-title">Sci-Fi Thriller</p>
        </div>
        <div class="card" data-index="4">
          <div class="card-image" style="background-color: #5c2d91;">
            <span class="card-icon">&#128123;</span>
          </div>
          <p class="card-title">Horror Night</p>
        </div>
        <div class="card" data-index="5">
          <div class="card-image" style="background-color: #b8860b;">
            <span class="card-icon">&#128516;</span>
          </div>
          <p class="card-title">Comedy Special</p>
        </div>
        <div class="card" data-index="6">
          <div class="card-image" style="background-color: #8b0000;">
            <span class="card-icon">&#9829;</span>
          </div>
          <p class="card-title">Romance</p>
        </div>
        <div class="card" data-index="7">
          <div class="card-image" style="background-color: #2f4f4f;">
            <span class="card-icon">&#128270;</span>
          </div>
          <p class="card-title">Mystery</p>
        </div>
      </div>
    </section>

    <footer id="now-playing">
      <div id="playback-status">
        <span id="playback-icon">&#9654;</span>
        <span id="playback-text">Ready</span>
      </div>
      <div id="app-indicator">Home</div>
    </footer>
  </div>

  <script src="/socket.io/socket.io.js"></script>
  <script src="tv.js"></script>
</body>
</html>
Style the TV display

The TV display uses a dark theme with an orange accent color (#ff9900) to match the Fire TV look. The content cards sit in a 4-column grid, and the currently selected card gets an orange border with a glow effect.

  • Right-click the public folder in the Explorer sidebar.
  • Select New File and name it tv.css.
  • Add the layout and header styles by pasting the following code into tv.css:
/* Reset default browser styles */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

/* Full-screen dark background for the TV */
body {
  background-color: #0a0a0a;
  color: #ffffff;
  font-family: Arial, Helvetica, sans-serif;
  overflow: hidden;
  height: 100vh;
}

/* Main layout container */
#tv-screen {
  display: flex;
  flex-direction: column;
  height: 100vh;
  padding: 20px 40px;
}

/* Header bar with title and connection status */
#top-bar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding-bottom: 20px;
}

#top-bar h1 {
  font-size: 28px;
  color: #ff9900;
  letter-spacing: 2px;
}

/* Connection indicator in the top-right corner */
#connection-status {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 14px;
  color: #888888;
}

/* Small circle that turns green when connected */
#status-dot {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background-color: #888888;
}

#status-dot.connected {
  background-color: #00cc44;
}

What does this code do?

  • The * reset removes default browser margins and padding so the layout is consistent across browsers.
  • The body gets a near-black background (#0a0a0a) and white text, setting the dark theme for the entire page.
  • The #status-dot is a small grey circle. When the JavaScript adds the connected class, it turns green to show the server connection is active.
  • Save tv.css.
  • You won't see the full styled page yet. The next chunk adds the content grid and footer styles.
  • Add the content grid and footer styles by pasting the following code below the existing styles in tv.css:
/* Section heading above the content grid */
#section-title {
  font-size: 22px;
  margin-bottom: 20px;
  color: #cccccc;
}

/* Vertically center the content area */
#content-area {
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: center;
}

/* 4-column grid for content cards */
#content-grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 20px;
  max-width: 900px;
}

/* Individual content card */
.card {
  border-radius: 8px;
  overflow: hidden;
  transition: transform 0.2s, box-shadow 0.2s;
  cursor: default;
  border: 3px solid transparent;
}

/* Orange highlight for the currently selected card */
.card.selected {
  border-color: #ff9900;
  transform: scale(1.08);
  box-shadow: 0 0 20px rgba(255, 153, 0, 0.4);
}

.card-image {
  height: 120px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.card-icon {
  font-size: 40px;
}

.card-title {
  padding: 10px;
  font-size: 14px;
  text-align: center;
  background-color: #1a1a1a;
}

/* Footer bar showing playback status */
#now-playing {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding-top: 20px;
  border-top: 1px solid #333333;
}

#playback-status {
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: 16px;
}

#playback-icon {
  font-size: 20px;
}

/* Orange-bordered badge showing the active app */
#app-indicator {
  font-size: 14px;
  color: #ff9900;
  padding: 6px 16px;
  border: 1px solid #ff9900;
  border-radius: 4px;
}

What does this code do?

  • #content-grid uses CSS Grid with repeat(4, 1fr) to lay out the 8 cards in a 4-column grid. The cards automatically wrap into 2 rows.
  • The .card.selected rule adds an orange border, scales the card up slightly, and adds an orange glow. This makes the remote control's cursor position obvious on the TV screen.
  • The #app-indicator in the footer is a small orange-bordered badge. It shows which streaming app is currently active (defaulting to Home).
  • Save tv.css.
  • Start the server by running this command in the terminal:
node server.js
  • Open your browser and go to http://localhost:3000.

You should see a dark TV interface with 8 content cards in a grid. The first card (Action Movie) has an orange border and is slightly larger than the others. The footer bar at the bottom shows a play icon with the text Ready.

See a blank page or unstyled text?

  • Check that both tv.html and tv.css are saved inside the public folder, not in the project root.
  • Verify the <link> tag in tv.html says href="tv.css". A typo here means the stylesheet never loads.
  • Make sure the server is running. If the terminal shows an error, stop it with Ctrl+C and run node server.js again.
  • Still stuck? Help me debug why my TV display page is not styled correctly.

✔️ Awesome, I've got everything!

Great. Keep the server running in the terminal. You can continue editing files in VS Code while it runs.

ⓧ I'd like to double check the full code

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  background-color: #0a0a0a;
  color: #ffffff;
  font-family: Arial, Helvetica, sans-serif;
  overflow: hidden;
  height: 100vh;
}

#tv-screen {
  display: flex;
  flex-direction: column;
  height: 100vh;
  padding: 20px 40px;
}

#top-bar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding-bottom: 20px;
}

#top-bar h1 {
  font-size: 28px;
  color: #ff9900;
  letter-spacing: 2px;
}

#connection-status {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 14px;
  color: #888888;
}

#status-dot {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background-color: #888888;
}

#status-dot.connected {
  background-color: #00cc44;
}

#section-title {
  font-size: 22px;
  margin-bottom: 20px;
  color: #cccccc;
}

#content-area {
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: center;
}

#content-grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 20px;
  max-width: 900px;
}

.card {
  border-radius: 8px;
  overflow: hidden;
  transition: transform 0.2s, box-shadow 0.2s;
  cursor: default;
  border: 3px solid transparent;
}

.card.selected {
  border-color: #ff9900;
  transform: scale(1.08);
  box-shadow: 0 0 20px rgba(255, 153, 0, 0.4);
}

.card-image {
  height: 120px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.card-icon {
  font-size: 40px;
}

.card-title {
  padding: 10px;
  font-size: 14px;
  text-align: center;
  background-color: #1a1a1a;
}

#now-playing {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding-top: 20px;
  border-top: 1px solid #333333;
}

#playback-status {
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: 16px;
}

#playback-icon {
  font-size: 20px;
}

#app-indicator {
  font-size: 14px;
  color: #ff9900;
  padding: 6px 16px;
  border: 1px solid #ff9900;
  border-radius: 4px;
}
Add the TV logic

The TV page looks the part, but it cannot react to commands yet. The JavaScript file connects to the Socket.IO server and listens for three types of events: navigation, playback, and app launch. Each event updates a different part of the DOM.

  • Right-click the public folder in the Explorer sidebar.
  • Select New File and name it tv.js.
  • Set up the Socket.IO connection and status indicators by pasting the following code into tv.js:
// Connect to the Socket.IO server
const socket = io();

// Grid dimensions for navigation
const COLS = 4;
const totalCards = document.querySelectorAll('.card').length;
let selectedIndex = 0;

// Update the status indicator when connected
socket.on('connect', () => {
  document.getElementById('status-dot').classList.add('connected');
  document.getElementById('status-text').textContent = 'Connected';
});

// Update the status indicator when disconnected
socket.on('disconnect', () => {
  document.getElementById('status-dot').classList.remove('connected');
  document.getElementById('status-text').textContent = 'Disconnected';
});

What does this code do?

  • io() creates a Socket.IO client that automatically connects to the server that served the page. No URL is needed because the client library detects it.
  • COLS, totalCards, and selectedIndex track the grid layout. The navigation handler (coming next) uses these to move the selection cursor between cards.
  • The connect and disconnect listeners toggle the connected CSS class on the status dot, turning it green or grey.
  • Save tv.js.
  • Refresh your browser at http://localhost:3000. The status dot in the top-right corner turns green and the text changes to Connected.

Status dot still grey?

  • Make sure the server is still running in your terminal. If it stopped, run node server.js again.
  • Check that tv.js is saved inside the public folder. The <script src="tv.js"> tag in your HTML looks for it there.
  • Open the browser console (F12 or Cmd+Option+I on macOS) and look for error messages in the Console tab.
  • Still stuck? Help me figure out why my Socket.IO connection status dot is not turning green.
  • Add the three event handlers by pasting the following code below the existing code in tv.js:
// Handle navigation commands from the remote
socket.on('navigate', (direction) => {
  const cards = document.querySelectorAll('.card');
  cards[selectedIndex].classList.remove('selected');

  switch (direction) {
    case 'up':
      if (selectedIndex >= COLS) selectedIndex -= COLS;
      break;
    case 'down':
      if (selectedIndex + COLS < totalCards) selectedIndex += COLS;
      break;
    case 'left':
      if (selectedIndex % COLS > 0) selectedIndex--;
      break;
    case 'right':
      if (selectedIndex % COLS < COLS - 1 && selectedIndex + 1 < totalCards) selectedIndex++;
      break;
    case 'select':
      const title = cards[selectedIndex].querySelector('.card-title').textContent;
      document.getElementById('playback-text').textContent = 'Now Playing: ' + title;
      document.getElementById('playback-icon').textContent = '\u25B6';
      break;
  }

  cards[selectedIndex].classList.add('selected');
});

// Handle playback commands from the remote
socket.on('playback', (action) => {
  const playbackIcon = document.getElementById('playback-icon');
  const playbackText = document.getElementById('playback-text');

  switch (action) {
    case 'play':
      playbackIcon.textContent = '\u25B6';
      playbackText.textContent = playbackText.textContent.replace('Paused: ', 'Now Playing: ');
      break;
    case 'pause':
      playbackIcon.textContent = '\u23F8';
      playbackText.textContent = playbackText.textContent.replace('Now Playing: ', 'Paused: ');
      break;
    case 'rewind':
      playbackIcon.textContent = '\u23EA';
      setTimeout(() => { playbackIcon.textContent = '\u25B6'; }, 500);
      break;
    case 'forward':
      playbackIcon.textContent = '\u23E9';
      setTimeout(() => { playbackIcon.textContent = '\u25B6'; }, 500);
      break;
  }
});

// Handle app launch commands from the remote
socket.on('app-launch', (appName) => {
  document.getElementById('app-indicator').textContent = appName;
  document.getElementById('section-title').textContent = appName + ' - Trending';
});

What does this code do?

These three listeners handle every command the remote can send.

  • The navigate handler uses a grid index system. The 8 cards are arranged in 4 columns, so pressing up/down shifts the index by 4 (one full row) and left/right shifts by 1. Boundary checks prevent the cursor from going off-screen. The select direction grabs the card title and shows it in the footer as "Now Playing."
  • The playback handler swaps the footer icon and status text for play, pause, rewind, and fast-forward. Rewind and fast-forward icons reset to the play icon after 500 milliseconds.
  • The app-launch handler updates the app badge in the footer and changes the section heading to show the launched app name.
  • Save tv.js.
  • You won't see a change on the TV yet. These handlers respond to events from the remote control, which you will build in the next step.

✔️ Awesome, I've got everything!

Great. Double check you've saved tv.js inside the public folder and that the status dot is green after refreshing.

ⓧ I'd like to double check the full code

const socket = io();

const COLS = 4;
const totalCards = document.querySelectorAll('.card').length;
let selectedIndex = 0;

socket.on('connect', () => {
  document.getElementById('status-dot').classList.add('connected');
  document.getElementById('status-text').textContent = 'Connected';
});

socket.on('disconnect', () => {
  document.getElementById('status-dot').classList.remove('connected');
  document.getElementById('status-text').textContent = 'Disconnected';
});

socket.on('navigate', (direction) => {
  const cards = document.querySelectorAll('.card');
  cards[selectedIndex].classList.remove('selected');

  switch (direction) {
    case 'up':
      if (selectedIndex >= COLS) selectedIndex -= COLS;
      break;
    case 'down':
      if (selectedIndex + COLS < totalCards) selectedIndex += COLS;
      break;
    case 'left':
      if (selectedIndex % COLS > 0) selectedIndex--;
      break;
    case 'right':
      if (selectedIndex % COLS < COLS - 1 && selectedIndex + 1 < totalCards) selectedIndex++;
      break;
    case 'select':
      const title = cards[selectedIndex].querySelector('.card-title').textContent;
      document.getElementById('playback-text').textContent = 'Now Playing: ' + title;
      document.getElementById('playback-icon').textContent = '\u25B6';
      break;
  }

  cards[selectedIndex].classList.add('selected');
});

socket.on('playback', (action) => {
  const playbackIcon = document.getElementById('playback-icon');
  const playbackText = document.getElementById('playback-text');

  switch (action) {
    case 'play':
      playbackIcon.textContent = '\u25B6';
      playbackText.textContent = playbackText.textContent.replace('Paused: ', 'Now Playing: ');
      break;
    case 'pause':
      playbackIcon.textContent = '\u23F8';
      playbackText.textContent = playbackText.textContent.replace('Now Playing: ', 'Paused: ');
      break;
    case 'rewind':
      playbackIcon.textContent = '\u23EA';
      setTimeout(() => { playbackIcon.textContent = '\u25B6'; }, 500);
      break;
    case 'forward':
      playbackIcon.textContent = '\u23E9';
      setTimeout(() => { playbackIcon.textContent = '\u25B6'; }, 500);
      break;
  }
});

socket.on('app-launch', (appName) => {
  document.getElementById('app-indicator').textContent = appName;
  document.getElementById('section-title').textContent = appName + ' - Trending';
});

Your TV Display is live and waiting for commands. Next up, you will build the Remote Control page that sends those commands from your phone.

Build the Remote Control

Your TV display is live and waiting for commands. Now it is time to build the other half of the experience: the remote control that sends those commands.

The remote will be a mobile-first web page designed to look and feel like a real TV remote. It will have a D-pad for navigation, playback buttons, and app launcher shortcuts. Every button tap fires a Socket.IO event that the server forwards to the TV.

In this step, get ready to:
  • Create the remote control HTML page with a D-pad, playback buttons, and app launchers.
  • Style the remote with a dark mobile-friendly theme and a CSS grid D-pad.
  • Wire up the remote logic and test it end-to-end from your phone to the TV.
Create the remote control page

The remote control page is designed as a mobile-first layout with four sections stacked vertically. A header shows the connection status, a row of app buttons provides quick launchers, a circular D-pad handles navigation, and playback buttons control media.

Each button stores its command in a data- attribute. The JavaScript you write later reads these attributes and sends them to the server as Socket.IO events.

  • Create a new file called remote.html inside the public folder by pasting the following code:
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <!-- Prevent accidental zoom when tapping buttons on mobile -->
  <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
  <title>Fire Remote</title>
  <link rel="stylesheet" href="remote.css">
</head>
<body>
  <div id="remote">
    <!-- Connection status header -->
    <header id="remote-header">
      <h1>Fire Remote</h1>
      <div id="remote-status">
        <span id="remote-dot"></span>
        <span id="remote-status-text">Connecting...</span>
      </div>
    </header>

    <!-- App launcher shortcuts -->
    <section id="app-buttons">
      <button class="app-btn" data-app="Netflix">Netflix</button>
      <button class="app-btn" data-app="Prime Video">Prime</button>
      <button class="app-btn" data-app="YouTube">YouTube</button>
      <button class="app-btn" data-app="Disney+">Disney+</button>
    </section>

    <!-- D-pad navigation with 5 directional buttons -->
    <section id="dpad-container">
      <div id="dpad">
        <button class="dpad-btn" id="btn-up" data-direction="up">&#9650;</button>
        <button class="dpad-btn" id="btn-left" data-direction="left">&#9664;</button>
        <button class="dpad-btn" id="btn-select" data-direction="select">OK</button>
        <button class="dpad-btn" id="btn-right" data-direction="right">&#9654;</button>
        <button class="dpad-btn" id="btn-down" data-direction="down">&#9660;</button>
      </div>
    </section>

    <!-- Playback controls -->
    <section id="playback-controls">
      <button class="playback-btn" data-action="rewind">&#9194;</button>
      <button class="playback-btn" data-action="play">&#9654;</button>
      <button class="playback-btn" data-action="pause">&#9208;</button>
      <button class="playback-btn" data-action="forward">&#9193;</button>
    </section>
  </div>

  <!-- Socket.IO client library (auto-served by the server) -->
  <script src="/socket.io/socket.io.js"></script>
  <script src="remote.js"></script>
</body>
</html>

What does this code do?

The page has four stacked sections, each wrapping a group of buttons.

  • The app-buttons section holds four buttons, each with a data-app attribute storing the streaming service name (Netflix, Prime Video, YouTube, Disney+).
  • The dpad div has five buttons with data-direction attributes (up, down, left, right, select). These map directly to the navigation events the TV listens for.
  • The playback-controls section has four buttons with data-action attributes (rewind, play, pause, forward).
  • The viewport meta tag includes user-scalable=no to prevent accidental zoom when tapping buttons quickly on a phone.
  • Save remote.html.

You will not see a styled page yet. The next substep adds the CSS to bring the remote to life.

File not saving?

Make sure you created remote.html inside the public folder, not in the project root. The server route for /remote looks specifically in public/remote.html.

Help me place my remote.html file in the right location

Style the remote for mobile

The remote uses a dark navy theme to match the Fire TV aesthetic. The D-pad uses a CSS grid to place buttons in a cross pattern, and every button flashes orange when tapped for tactile feedback.

  • Create a new file called remote.css inside the public folder.
  • Start by adding the layout foundation and header styles:
/* Reset default browser styles */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

/* Dark navy background, centered layout, prevent text selection on tap */
body {
  background-color: #1a1a2e;
  color: #ffffff;
  font-family: Arial, Helvetica, sans-serif;
  min-height: 100vh;
  display: flex;
  justify-content: center;
  -webkit-user-select: none;
  user-select: none;
}

/* Constrain remote width for phone-sized screens */
#remote {
  width: 100%;
  max-width: 400px;
  padding: 20px;
  display: flex;
  flex-direction: column;
  gap: 24px;
}

/* Header section */
#remote-header {
  text-align: center;
}

#remote-header h1 {
  font-size: 22px;
  color: #ff9900;
  margin-bottom: 8px;
}

/* Connection status indicator */
#remote-status {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 6px;
  font-size: 13px;
  color: #888888;
}

#remote-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background-color: #888888;
}

/* Dot turns green when connected to the server */
#remote-dot.connected {
  background-color: #00cc44;
}

What does this code do?

  • max-width: 400px on #remote keeps the layout phone-sized even on a wide screen.
  • user-select: none on the body prevents text from getting highlighted when you tap buttons quickly on a phone.
  • The #remote-dot.connected selector turns the status dot green when Socket.IO connects, matching the TV display's connection indicator.
  • Save remote.css.

You will not see the buttons yet. The next chunk adds the app buttons and D-pad.

  • Add the app button and D-pad styles below the existing CSS in remote.css:
/* App launcher button row */
#app-buttons {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 8px;
}

.app-btn {
  background-color: #16213e;
  color: #ffffff;
  border: 1px solid #333355;
  border-radius: 8px;
  padding: 12px 4px;
  font-size: 12px;
  font-weight: bold;
  cursor: pointer;
  transition: background-color 0.15s;
}

/* Orange flash when tapped */
.app-btn:active {
  background-color: #ff9900;
  color: #000000;
}

/* Center the D-pad on screen */
#dpad-container {
  display: flex;
  justify-content: center;
  padding: 10px 0;
}

/* 3x3 grid creates the cross-shaped D-pad layout */
#dpad {
  display: grid;
  grid-template-columns: 70px 70px 70px;
  grid-template-rows: 70px 70px 70px;
  gap: 4px;
}

.dpad-btn {
  background-color: #16213e;
  color: #ffffff;
  border: 1px solid #333355;
  border-radius: 50%;
  font-size: 20px;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: background-color 0.15s;
}

.dpad-btn:active {
  background-color: #ff9900;
  color: #000000;
}

/* Position each D-pad button in the cross pattern */
#btn-up {
  grid-column: 2;
  grid-row: 1;
}

#btn-left {
  grid-column: 1;
  grid-row: 2;
}

/* OK button sits in the center with a distinct color */
#btn-select {
  grid-column: 2;
  grid-row: 2;
  background-color: #0f3460;
  font-size: 16px;
  font-weight: bold;
}

#btn-right {
  grid-column: 3;
  grid-row: 2;
}

#btn-down {
  grid-column: 2;
  grid-row: 3;
}

How does the CSS grid D-pad work?

The D-pad is a 3x3 CSS grid where only the cross-shaped positions are filled. The Up button sits at column 2, row 1 (top center). Left, OK, and Right fill row 2. Down sits at column 2, row 3 (bottom center).

The four corner cells of the grid stay empty, creating the cross shape. The :active pseudo-class makes buttons flash orange the moment you tap them, giving instant visual feedback.

  • Save remote.css.

You will not see the playback buttons yet. The next chunk finishes the styling.

  • Add the playback control styles below the existing CSS in remote.css:
/* Playback button row */
#playback-controls {
  display: flex;
  justify-content: center;
  gap: 16px;
}

.playback-btn {
  background-color: #16213e;
  color: #ffffff;
  border: 1px solid #333355;
  border-radius: 50%;
  width: 56px;
  height: 56px;
  font-size: 18px;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: background-color 0.15s;
}

.playback-btn:active {
  background-color: #ff9900;
  color: #000000;
}

What does this code do?

The playback buttons are circular (56px by 56px with border-radius: 50%) and laid out in a horizontal row using flexbox. They follow the same orange :active flash pattern as the D-pad and app buttons.

  • Save remote.css.
  • Make sure your server is running. If it is not, run node server.js in your terminal.
  • Open http://localhost:3000/remote in your browser.

You should see a dark navy page with the "Fire Remote" title in orange, four app launcher buttons, a cross-shaped D-pad, and four circular playback buttons. The buttons do not do anything yet because the JavaScript is not connected.

Page looks unstyled or blank?

  • Check that remote.css is saved inside the public folder, right next to remote.html.
  • Make sure the <link> tag in your HTML says href="remote.css" (no path prefix).
  • If the D-pad buttons appear in a single column instead of a cross, check that the grid position rules (#btn-up, #btn-left, etc.) are present in your CSS.

My remote control page does not look right in the browser

✔️ Awesome, I've got everything!

Great. Double check you have saved remote.css before moving on.

ⓧ I'd like to double check the full code

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  background-color: #1a1a2e;
  color: #ffffff;
  font-family: Arial, Helvetica, sans-serif;
  min-height: 100vh;
  display: flex;
  justify-content: center;
  -webkit-user-select: none;
  user-select: none;
}

#remote {
  width: 100%;
  max-width: 400px;
  padding: 20px;
  display: flex;
  flex-direction: column;
  gap: 24px;
}

#remote-header {
  text-align: center;
}

#remote-header h1 {
  font-size: 22px;
  color: #ff9900;
  margin-bottom: 8px;
}

#remote-status {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 6px;
  font-size: 13px;
  color: #888888;
}

#remote-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background-color: #888888;
}

#remote-dot.connected {
  background-color: #00cc44;
}

#app-buttons {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 8px;
}

.app-btn {
  background-color: #16213e;
  color: #ffffff;
  border: 1px solid #333355;
  border-radius: 8px;
  padding: 12px 4px;
  font-size: 12px;
  font-weight: bold;
  cursor: pointer;
  transition: background-color 0.15s;
}

.app-btn:active {
  background-color: #ff9900;
  color: #000000;
}

#dpad-container {
  display: flex;
  justify-content: center;
  padding: 10px 0;
}

#dpad {
  display: grid;
  grid-template-columns: 70px 70px 70px;
  grid-template-rows: 70px 70px 70px;
  gap: 4px;
}

.dpad-btn {
  background-color: #16213e;
  color: #ffffff;
  border: 1px solid #333355;
  border-radius: 50%;
  font-size: 20px;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: background-color 0.15s;
}

.dpad-btn:active {
  background-color: #ff9900;
  color: #000000;
}

#btn-up {
  grid-column: 2;
  grid-row: 1;
}

#btn-left {
  grid-column: 1;
  grid-row: 2;
}

#btn-select {
  grid-column: 2;
  grid-row: 2;
  background-color: #0f3460;
  font-size: 16px;
  font-weight: bold;
}

#btn-right {
  grid-column: 3;
  grid-row: 2;
}

#btn-down {
  grid-column: 2;
  grid-row: 3;
}

#playback-controls {
  display: flex;
  justify-content: center;
  gap: 16px;
}

.playback-btn {
  background-color: #16213e;
  color: #ffffff;
  border: 1px solid #333355;
  border-radius: 50%;
  width: 56px;
  height: 56px;
  font-size: 18px;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: background-color 0.15s;
}

.playback-btn:active {
  background-color: #ff9900;
  color: #000000;
}
Add the remote logic and test end-to-end

The remote control needs JavaScript to connect to the Socket.IO server and send events when you tap buttons. Each button group uses a different event name: navigate for the D-pad, playback for media controls, and app-launch for the app shortcuts.

  • Create a new file called remote.js inside the public folder by pasting the following code:
const socket = io();

// Update connection status dot and text
socket.on('connect', () => {
  document.getElementById('remote-dot').classList.add('connected');
  document.getElementById('remote-status-text').textContent = 'Connected';
});

socket.on('disconnect', () => {
  document.getElementById('remote-dot').classList.remove('connected');
  document.getElementById('remote-status-text').textContent = 'Disconnected';
});

// D-pad buttons send navigation direction to the server
document.querySelectorAll('.dpad-btn').forEach((btn) => {
  btn.addEventListener('click', () => {
    const direction = btn.dataset.direction;
    socket.emit('navigate', direction);
  });
});

// Playback buttons send the action (play, pause, rewind, forward)
document.querySelectorAll('.playback-btn').forEach((btn) => {
  btn.addEventListener('click', () => {
    const action = btn.dataset.action;
    socket.emit('playback', action);
  });
});

// App buttons send the app name for the TV to display
document.querySelectorAll('.app-btn').forEach((btn) => {
  btn.addEventListener('click', () => {
    const appName = btn.dataset.app;
    socket.emit('app-launch', appName);
  });
});

What does this code do?

The script follows the same pattern for all three button groups: select all buttons of a class, loop through them, and attach a click listener that reads the button's data- attribute and sends it as a Socket.IO event.

  • socket.emit('navigate', direction) sends the D-pad direction to the server, which broadcasts it to the TV.
  • The connection handlers update remote-dot and remote-status-text so you can see whether the remote is connected to the server.
  • Save remote.js.

Now it is time to test the full experience across two devices. You will open the TV on your computer and the remote on your phone.

  • Stop the server if it is running by pressing Ctrl+C in the terminal.
  • Restart the server by running:
node server.js
  • Open http://localhost:3000 in your computer's browser. You should see the TV display from earlier.

To connect your phone, you need your computer's local IP address. Your phone and computer must be on the same Wi-Fi network.

  • Open a new terminal window (keep the server running in the first one).
  • Find your local IP address by running:

macOS

ipconfig getifaddr en0

This prints your local IP address directly (something like 192.168.x.x). If nothing appears, your Wi-Fi may use a different interface. Check your IP in System Settings under Wi-Fi instead.

Windows

ipconfig

Look for the IPv4 Address under your active Wi-Fi or Ethernet adapter. It looks like 192.168.x.x.

  • On your phone, make sure it is connected to the same Wi-Fi network as your computer.
  • Open your phone's browser and go to:
http://[[LOCAL_IP="your computer's local IP address"]]:3000/remote

What should I see?

The remote control page loads on your phone with the status dot turning green ("Connected"). On your computer, the TV display's status dot also turns green. Both devices are now connected to the same Socket.IO server.

Windows firewall prompt

Windows may show a firewall prompt asking to allow Node.js through the firewall. Click Allow access so your phone can reach the server over your local network.

  • Tap the D-pad buttons on your phone and watch the orange highlight move between content cards on the TV.
  • Tap OK and watch the footer on the TV update to "Now Playing" with the selected title.
  • Tap the playback buttons (play, pause, rewind, fast-forward) and watch the footer icon change on the TV.
  • Tap an app button (like Netflix) and watch the app indicator and section title change on the TV.

Phone can't connect to the server?

  • Make sure your phone and computer are on the same Wi-Fi network. A phone on mobile data cannot reach your local server.
  • Double check you typed the IP address correctly. It should be your computer's IP (not your phone's), followed by :3000/remote.
  • If your firewall is blocking the connection, check your Windows Firewall settings and allow Node.js for private networks.

My phone cannot connect to the Fire Remote server on my local network

Secret mission

Add Live Keyboard Search

Your remote can navigate, play, and switch apps. But what about searching for content? The real Fire TV remote app lets you type on your phone and see the text appear live on the TV. Can you add a search overlay to the TV and a keyboard input to the remote that streams every keystroke in real-time?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally on your computer, so there are no ongoing costs or cloud resources to worry about.

Decide whether to keep your project files, stop the server to free up resources, or delete everything entirely.

Resources you used:

  • The fire-remote project folder on your Desktop (including server.js, the public/ folder, node_modules/, and package.json).
  • The Node.js server process (if still running in your terminal).

Keep everything running

No action needed. Choose this if you want to keep experimenting with your Fire Remote app or extend it further.

  • Your fire-remote folder stays on your Desktop, ready to use anytime.
  • To restart the server later, open a terminal, navigate to the fire-remote folder, and run node server.js.

Pause - I'll come back to this later

Stop the running server to free up your terminal, but keep all your project files for later.

  • Switch to the terminal where your server is running.
  • Press Ctrl+C to stop the Node.js server.
  • Your project files remain in the fire-remote folder on your Desktop. Run node server.js from that folder whenever you want to pick up again.

Delete - I don't want to use this again

Remove all project files and start fresh. This cannot be undone.

  • If the server is still running, switch to that terminal and press Ctrl+C to stop it.
  • Delete the entire project folder by running one of these commands depending on your operating system.

macOS

  • Delete the project folder by running this command:
rm -rf ~/Desktop/fire-remote

Windows

  • Delete the project folder by running this command:
rmdir /s /q %USERPROFILE%\Desktop\fire-remote
  • Confirm the folder is gone by checking your Desktop. The fire-remote folder should no longer appear.

Nice Work!

Nice Work!

Nice work. You just built a real-time, multi-device web app that turns your phone into a remote control for a simulated TV display.

You've learned how to:

  • Build a Node.js server with Express and Socket.IO that relays real-time commands between two devices over your local network.
  • Create a TV Display with a content grid, selection cursor, playback status, and app switching controlled entirely from another device.
  • Create a mobile-friendly Remote Control with a D-pad, playback controls, and app launcher buttons that communicate through WebSocket-based real-time events.
  • Secret Mission: Added a live keyboard search feature with a TV overlay and real-time text input from your phone.

Ready to quiz yourself?