Build a Real-Time Collaborative Board

Build a live Kanban board that syncs across browsers using WebSockets.

Introduction

30 Second Summary

Think about the last time you edited a Google Doc with a teammate. You typed a sentence and they saw it appear on their screen instantly, without anyone refreshing the page.

In this project, you will build SyncBoard, a real-time collaborative task board where multiple users see live updates without refreshing. You will create a full-stack app, connect it to a database, and deploy it with a live demo link ready for your resume.

What You'll Build

Open SyncBoard in two browser tabs, create a task in one, and watch it appear in the other tab instantly.

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

  • A three-column board that syncs across all connected browsers in real time, powered by the WebSocket protocol and Socket.IO.
  • Tasks stored in Neon PostgreSQL that persist across page refreshes and server restarts. Your board always picks up where you left off.
  • A full-stack TypeScript app with React and Node.js, deployed to Render with a live URL for your resume.
  • Secret Mission: Add user presence indicators showing who else is viewing the board right now.

Are there any prerequisites?

Basic familiarity with React, JavaScript, and Git is helpful for following along. TypeScript experience is a bonus but not required.

Before We Start

Before you write a single line of code, take a moment to lock in what you're about to build and why it matters for your portfolio.

Set Up Your Development Environment

Building SyncBoard requires a few key tools before you can write any code. You need Node.js to run JavaScript outside the browser, a project folder structure, and backend packages installed.

In this step, you'll install Node.js, scaffold the project, and configure TypeScript so your server is ready to build on in the next step.

In this step, get ready to:
  • Install Node.js v24.19.0 LTS and verify it works.
  • Create the project structure and install backend dependencies.
  • Configure TypeScript and confirm the server starts.
Install Node.js v24.19.0 LTS

Node.js is the runtime that lets you run JavaScript on your computer, outside of a browser. This project pins to v24.19.0 LTS for stability.

  • Open a terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
  • Check if Node.js is already installed by running:
node --version

✔️ I see v24 or higher

Nice, Node.js is ready to go.

  • Also confirm npm is available by running:
npm --version

You should see a version number printed. Both tools are ready.

ⓧ I see an older version

You have Node.js installed, but you need v24 or higher for this project.

  • Go to https://nodejs.org/en/download in your browser.
  • The page defaults to the LTS version (v24.19.0 at time of writing). Download the installer for your operating system.
  • Run the installer and accept all defaults. This replaces your older version.
  • Close and reopen your terminal, then run node --version again to confirm it now shows v24.

Why do I need to restart the terminal?

Your terminal caches the path to the old Node.js binary when it opens. Closing and reopening it forces the terminal to pick up the newly installed version.

ⓧ Command not found

Node.js is not installed yet. Let's fix that.

  • Go to https://nodejs.org/en/download in your browser.
  • The page defaults to the LTS version (v24.19.0 at time of writing). Download the installer for your operating system.
  • Run the downloaded installer and accept all defaults.
  • Close and reopen your terminal, then run node --version again.

You should now see a version number starting with v24.

  • Also confirm npm is available by running:
npm --version

You should see a version number printed. Both tools are ready.

Still seeing command not found?

Make sure you fully closed and reopened your terminal after installation. On Windows, try opening a new Command Prompt window. On macOS, try opening a new Terminal window.

If it still doesn't work, restart your computer and try again.

Help me troubleshoot my Node.js installation.

Create the project structure and install dependencies

SyncBoard is a full-stack project with two halves: a server directory for the backend and a client directory for the React frontend.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project folder with both subdirectories and move into it by running these commands:
mkdir syncboard
cd syncboard
mkdir server client
  • Confirm the folders were created by running:
ls

You should see client and server listed in the output.

  • Move into the server folder and initialize a new Node.js project by running:
cd server
npm init -y

You should see a JSON object printed in the terminal. This is the generated package.json file that tracks your project's dependencies and scripts.

  • Install the backend dependencies by running:
npm install express@5.2.1 socket.io@4.8.3 @neondatabase/serverless@1.1.0 dotenv@16.5.0 cors@2.8.5

What are these packages?

  • express is a web server framework for handling HTTP requests.
  • socket.io adds real-time, bidirectional communication between the server and browsers.
  • @neondatabase/serverless connects to a Neon PostgreSQL database.
  • dotenv loads environment variables from a .env file so you can keep secrets out of your code.
  • cors allows your frontend to make requests to the server from a different port.

You should see npm download and install the packages. The terminal prints a summary showing the number of packages added.

Now it's time to create the first source file. Let's open VS Code with this project.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the syncboard folder, then click Open.

You should see client and server listed in the Explorer sidebar on the left.

  • In the Explorer sidebar, right-click on the server folder.
  • Select New Folder and name it src.
  • Right-click on the new src folder.
  • Select New File and name it index.ts.

VS Code opens the empty index.ts file in the editor.

  • Create a minimal Express server by pasting the following code into index.ts:
// Import required packages
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';

// Load environment variables from .env file
dotenv.config();

// Create Express app and enable middleware
const app = express();
app.use(cors());
app.use(express.json());

// Start the server on port 3001 (or PORT from environment)
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

What does this code do?

  • dotenv.config() loads any environment variables from a .env file into process.env. You'll use this for your database connection string later.
  • app.use(cors()) allows requests from a different origin. Your React frontend will run on port 5173 while this server runs on port 3001.
  • app.use(express.json()) tells Express to automatically parse incoming JSON request bodies.
  • app.listen(PORT, ...) starts the server on port 3001 and prints a confirmation message.
  • Save index.ts by pressing Cmd+S (macOS) or Ctrl+S (Windows).

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

Here is the complete server/src/index.ts file. Make sure yours matches:

import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';

dotenv.config();

const app = express();
app.use(cors());
app.use(express.json());

const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});
Set up TypeScript and start the server

The server code is written in TypeScript, but Node.js only runs JavaScript. You need a TypeScript compiler and a tool called tsx that compiles and runs TypeScript files on the fly during development.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
  • Make sure you're in the server directory. If not, navigate there by running:
cd server
  • Install the TypeScript development dependencies by running:
npm install -D typescript @types/node @types/express @types/cors tsx

You should see npm add five packages to your devDependencies.

What are devDependencies?

Dependencies your app needs at runtime (like express) go in dependencies. Tools you only need while developing (like the TypeScript compiler) go in devDependencies. The -D flag tells npm to put them in the right place.

Next, TypeScript needs a configuration file to know how to compile your code.

  • In the Explorer sidebar, right-click on the server folder.
  • Select New File and name it tsconfig.json.
  • Paste the following configuration into tsconfig.json:
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"]
}

What does this config do?

  • outDir tells the compiler to write compiled JavaScript files to a dist folder. This keeps your source and output separate.
  • rootDir tells TypeScript that all your source code lives inside src/.
  • esModuleInterop lets you use import express from 'express' syntax instead of the older require() style.
  • Save tsconfig.json.

The last setup task is adding scripts to package.json so you can start your server with a single command.

  • In the Explorer sidebar, click on package.json inside the server folder to open it.
  • Find the "name" field near the top. It currently says:
"name": "server",
  • Change it to:
"name": "syncboard-server",
  • Now find the "scripts" section. It currently has a default test script:
"scripts": {
  "test": "echo \"Error: no test specified\" && exit 1"
},
  • Replace the entire scripts block with these three scripts:
"scripts": {
  "dev": "tsx watch src/index.ts",
  "build": "tsc",
  "start": "node dist/index.js"
},

What do these scripts do?

  • dev uses tsx watch to run your TypeScript server and automatically restart it whenever you save a change.
  • build compiles TypeScript to JavaScript for production deployment.
  • start runs the compiled JavaScript in production.
  • Save package.json.

✔️ Awesome, I've got everything!

Great. Double check you've saved both tsconfig.json and package.json.

ⓧ I'd like to double check the full code

Here is the complete server/tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"]
}

And here is the complete server/package.json. Your dependency versions may differ slightly for the devDependencies, but the scripts and name should match:

{
  "name": "syncboard-server",
  "version": "1.0.0",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  },
  "dependencies": {
    "@neondatabase/serverless": "1.1.0",
    "cors": "2.8.5",
    "dotenv": "16.5.0",
    "express": "5.2.1",
    "socket.io": "4.8.3"
  },
  "devDependencies": {
    "@types/cors": "^2.8.17",
    "@types/express": "^5.0.0",
    "@types/node": "^22.0.0",
    "tsx": "^4.19.0",
    "typescript": "^5.7.0"
  }
}

Time to see if everything works. Before you run the server, what do you expect to see in the terminal?

  • Start the server by running this command in the VS Code terminal:
npm run dev

You should see Server running on port 3001 printed in the terminal. Your Express server is live.

Keep this terminal running

The tsx watch process stays active and watches for file changes. Leave it running. When you need a second terminal later, click the + button in the VS Code terminal panel to open a new one.

Not seeing the server start?

Make sure your terminal is inside the server directory. Run pwd (macOS) or cd (Windows) to check. The path should end with syncboard/server.

If you see a TypeScript error, double check that your tsconfig.json matches the code in the double-check tab above.

Help me troubleshoot my Express server not starting.

Your development environment is locked in. You have a running Express server with TypeScript, and the project structure is ready for both the backend and the frontend. Next up, you'll build the board UI with React so you have something to see in the browser.

Build the Board UI with React

Your Express server is running and ready to handle requests. Now you need something for users to actually interact with.

In this step, you'll scaffold a React frontend with Vite and build a three-column Kanban board. By the end, you'll be able to create tasks and move them between columns. But there's a catch you'll discover along the way.

In this step, get ready to:
  • Scaffold a React frontend with Vite.
  • Build Board, Column, and TaskCard components.
  • Create and move tasks between columns.
Scaffold the React frontend with Vite

Vite is a modern build tool that scaffolds a ready-to-run React project with TypeScript support in one command. It also runs a dev server with hot module reload, so your browser updates instantly every time you save a file.

  • Your server terminal is running tsx watch from Step 1. Open a new terminal in VS Code by pressing Ctrl+Shift+` (macOS and Windows).
  • Navigate to the syncboard project root by running this command:
cd ~/Desktop/syncboard
  • Scaffold the React frontend inside the client folder by running this command:
npm create vite@latest client -- --template react-ts

What does this command do?

The npm create command downloads and runs the create-vite scaffolding tool. The client argument names the project folder, and --template react-ts tells Vite to set up React with TypeScript.

Seeing a prompt to install create-vite?

If npm asks whether to install the create-vite package, type y and press Enter. This only happens the first time you run the command.

Help me with the create-vite prompt

  • Move into the client folder and install its dependencies by running these commands:
cd client
npm install
  • Start the Vite dev server by running:
npm run dev
  • Open http://localhost:5173 in your browser.

You should see the Vite + React starter page with a counter button.

Port 5173 already in use?

If another process is using port 5173, Vite automatically picks the next available port and prints it in the terminal. Use whatever URL Vite displays.

Help me troubleshoot my Vite dev server

Create the task type and board components

Every task on the board needs a consistent shape: an ID, a title, a status, and a timestamp. You'll define that shape first, then build components from the bottom up.

  • In VS Code's Explorer sidebar, right-click the src folder inside client and select New File.
  • Name the file types.ts and paste this code:
// Define the shape of a task on the board
export interface Task {
  id: number;
  title: string;
  status: 'todo' | 'in-progress' | 'done';
  created_at: string;
}

What is this Task interface?

The Task interface tells TypeScript exactly what properties every task must have. The status field uses a union type, so only the three valid column values are allowed.

  • Save types.ts.
  • Right-click the src folder again and select New Folder.
  • Name the folder components.
  • Confirm the new file and folder by running this in your terminal:
ls src/

You should see components and types.ts alongside the Vite starter files.

  • Right-click the components folder and select New File.
  • Name the file TaskCard.tsx and paste these imports and type definitions at the top:
import type { Task } from '../types';

interface TaskCardProps {
  task: Task;
  currentStatus: Task['status'];
  onMoveTask: (id: number, status: Task['status']) => void;
}

// Define the three column statuses in left-to-right order
const STATUS_ORDER: Task['status'][] = ['todo', 'in-progress', 'done'];
  • Below the STATUS_ORDER constant, add the TaskCard function:
export function TaskCard({ task, currentStatus, onMoveTask }: TaskCardProps) {
  // Determine which directions this card can move
  const currentIndex = STATUS_ORDER.indexOf(currentStatus);
  const canMoveLeft = currentIndex > 0;
  const canMoveRight = currentIndex < STATUS_ORDER.length - 1;

  return (
    <div className="task-card">
      <p className="task-title">{task.title}</p>
      <div className="task-actions">
        {canMoveLeft && (
          <button
            onClick={() => onMoveTask(task.id, STATUS_ORDER[currentIndex - 1])}
            title="Move left"
          >
            &larr;
          </button>
        )}
        {canMoveRight && (
          <button
            onClick={() => onMoveTask(task.id, STATUS_ORDER[currentIndex + 1])}
            title="Move right"
          >
            &rarr;
          </button>
        )}
      </div>
    </div>
  );
}

What does TaskCard do?

Each TaskCard displays a task title and up to two arrow buttons. The STATUS_ORDER array defines the column sequence (To Do, In Progress, Done).

The component checks the task's current position in that sequence. If the task is not in the first column, a left arrow appears. If it is not in the last column, a right arrow appears. Clicking an arrow calls onMoveTask with the adjacent status.

  • Save TaskCard.tsx.
  • Confirm the file exists by running:
ls src/components/

You should see TaskCard.tsx listed.

Seeing red underlines in TaskCard.tsx?

Make sure types.ts is saved in the src folder (not inside components). The import path '../types' goes up one level from components to src.

Help me fix TypeScript errors in TaskCard.tsx

  • Right-click the components folder and create a new file called Column.tsx.
  • Paste these imports and type definition at the top:
import { TaskCard } from './TaskCard';
import type { Task } from '../types';

interface ColumnProps {
  label: string;
  status: Task['status'];
  tasks: Task[];
  onMoveTask: (id: number, status: Task['status']) => void;
}
  • Below the interface, add the Column function:
// Render a column header and its list of task cards
export function Column({ label, status, tasks, onMoveTask }: ColumnProps) {
  return (
    <div className="column">
      <h2 className="column-header">
        {label} <span className="task-count">{tasks.length}</span>
      </h2>
      <div className="task-list">
        {tasks.map((task) => (
          <TaskCard
            key={task.id}
            task={task}
            currentStatus={status}
            onMoveTask={onMoveTask}
          />
        ))}
        {tasks.length === 0 && (
          <p className="empty-message">No tasks</p>
        )}
      </div>
    </div>
  );
}

What does Column do?

Each Column renders a header showing its label and a live task count badge. Below the header, it maps over its tasks and renders a TaskCard for each one. If the column is empty, it shows a "No tasks" placeholder.

  • Save Column.tsx.
  • Confirm both component files exist by running:
ls src/components/

You should see Column.tsx and TaskCard.tsx.

  • Create one more file in the components folder called Board.tsx.
  • Paste the imports, type definition, and column config at the top:
import { useState } from 'react';
import { Column } from './Column';
import type { Task } from '../types';

interface BoardProps {
  tasks: Task[];
  onCreateTask: (title: string) => void;
  onMoveTask: (id: number, status: Task['status']) => void;
}

// Map each column to its status value and display label
const COLUMNS: { status: Task['status']; label: string }[] = [
  { status: 'todo', label: 'To Do' },
  { status: 'in-progress', label: 'In Progress' },
  { status: 'done', label: 'Done' },
];

export function Board({ tasks, onCreateTask, onMoveTask }: BoardProps) {
  const [newTitle, setNewTitle] = useState('');

  // Handle form submission to create a new task
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (newTitle.trim()) {
      onCreateTask(newTitle.trim());
      setNewTitle('');
    }
  };
  • Directly below the handleSubmit function, add the return statement that renders the form and columns:
  return (
    <div>
      <form className="task-form" onSubmit={handleSubmit}>
        <input
          type="text"
          value={newTitle}
          onChange={(e) => setNewTitle(e.target.value)}
          placeholder="Add a new task..."
        />
        <button type="submit">Add Task</button>
      </form>
      <div className="board">
        {COLUMNS.map((col) => (
          <Column
            key={col.status}
            label={col.label}
            status={col.status}
            tasks={tasks.filter((t) => t.status === col.status)}
            onMoveTask={onMoveTask}
          />
        ))}
      </div>
    </div>
  );
}

What does Board do?

The Board component owns the task creation form. When the learner submits the form, handleSubmit passes the trimmed title up to the parent and clears the input.

Below the form, it loops through the COLUMNS array and renders one Column for each status. The tasks.filter() call gives each column only the tasks that belong to it.

  • Save Board.tsx.
  • Confirm all three components exist by running:
ls src/components/

You should see Board.tsx, Column.tsx, and TaskCard.tsx.

Wire up the app, add styles, and test the board

All three components are ready but nothing imports them yet. The browser is still showing the Vite starter page. Time to connect everything in App.tsx and replace the default styles.

  • Open src/App.tsx in VS Code.
  • Select all the existing code and delete it.
  • Paste this code, which sets up the imports and task handlers:
import { useState } from 'react';
import { Board } from './components/Board';
import type { Task } from './types';
import './App.css';

function App() {
  // Store all tasks in a single array
  const [tasks, setTasks] = useState<Task[]>([]);

  // Create a new task with a unique ID and 'todo' status
  const handleCreateTask = (title: string) => {
    const newTask: Task = {
      id: Date.now(),
      title,
      status: 'todo',
      created_at: new Date().toISOString(),
    };
    setTasks((prev) => [...prev, newTask]);
  };

  // Move a task by updating its status to a different column
  const handleMoveTask = (id: number, status: Task['status']) => {
    setTasks((prev) =>
      prev.map((t) => (t.id === id ? { ...t, status } : t))
    );
  };
  • Directly below handleMoveTask, add the return statement and export to complete the App function:
  return (
    <div className="app">
      <header className="header">
        <h1>SyncBoard</h1>
      </header>
      <Board
        tasks={tasks}
        onCreateTask={handleCreateTask}
        onMoveTask={handleMoveTask}
      />
    </div>
  );
}

export default App;

What does App.tsx do?

The App component is the single source of truth for all tasks. It stores them in a useState array and passes two handler functions down to Board.

The handleCreateTask function generates a unique ID using Date.now() and defaults the status to 'todo'. The handleMoveTask function finds the task by ID and updates its status to the new column.

  • Save App.tsx.

Check your browser. You should see the "SyncBoard" heading, an input field, and three columns with "No tasks" messages. It looks unstyled because the default CSS is still loaded.

Browser showing an error overlay?

If you see a red error screen, check that every file is saved and that Board.tsx is inside src/components/ (not directly in src/). Also confirm the Board function's closing brace } is present at the end of Board.tsx.

Help me fix the error in my React app

Now replace the default styles. The Vite starter includes CSS files with demo styles that will interfere with yours.

  • Open src/index.css and delete all its contents.
  • Save index.css (the empty file is fine because main.tsx imports it).
  • Open src/App.css, select all the existing code, and delete it.
  • Paste these base layout styles:
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background-color: #f0f2f5;
  color: #1a1a2e;
}

.app {
  max-width: 1200px;
  margin: 0 auto;
  padding: 20px;
}

.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 24px;
}

.header h1 {
  font-size: 28px;
  font-weight: 700;
}
  • Save App.css and check your browser.

The background should turn light gray and the heading should appear at the top. The form and columns are still unstyled.

  • Add these form styles below the existing code in App.css:
.task-form {
  display: flex;
  gap: 8px;
  margin-bottom: 24px;
}

.task-form input {
  flex: 1;
  padding: 10px 14px;
  border: 1px solid #d1d5db;
  border-radius: 8px;
  font-size: 14px;
}

.task-form button {
  padding: 10px 20px;
  background-color: #3b82f6;
  color: white;
  border: none;
  border-radius: 8px;
  font-size: 14px;
  cursor: pointer;
}

.task-form button:hover {
  background-color: #2563eb;
}
  • Save App.css and check your browser.

The input and button should now sit side by side with rounded corners and a blue Add Task button.

  • Add these column styles below the form styles in App.css:
.board {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 16px;
}

.column {
  background-color: #e5e7eb;
  border-radius: 12px;
  padding: 16px;
  min-height: 400px;
}

.column-header {
  font-size: 16px;
  font-weight: 600;
  margin-bottom: 12px;
  display: flex;
  align-items: center;
  gap: 8px;
}

.task-count {
  background-color: #d1d5db;
  color: #374151;
  font-size: 12px;
  padding: 2px 8px;
  border-radius: 12px;
}
  • Save App.css and check your browser.

Three gray columns should appear in a row, each with a header (To Do, In Progress, Done) and a "0" count badge. The board is starting to look like a real Kanban board.

  • Add these task card and list styles below the column styles in App.css:
.task-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.task-card {
  background-color: white;
  border-radius: 8px;
  padding: 12px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.task-title {
  font-size: 14px;
}

.task-actions {
  display: flex;
  gap: 4px;
}
  • Add these final styles for the move buttons and empty column message below the previous code:
.task-actions button {
  background: none;
  border: 1px solid #d1d5db;
  border-radius: 4px;
  padding: 4px 8px;
  cursor: pointer;
  font-size: 12px;
}

.task-actions button:hover {
  background-color: #f3f4f6;
}

.empty-message {
  color: #9ca3af;
  font-size: 13px;
  text-align: center;
  padding: 20px 0;
}

What do these styles do?

The card styles give each task a white background with a subtle shadow, making it stand out from the gray column. The action buttons get minimal styling so they feel like inline controls rather than primary buttons.

The .empty-message class shows a centered "No tasks" placeholder in any column that has no cards.

  • Save App.css.

Your browser should now show a fully styled three-column Kanban board with a form at the top and "No tasks" in each column.

Columns stacking vertically instead of side by side?

Make sure your .board class has display: grid and grid-template-columns: repeat(3, 1fr). Also confirm you cleared the contents of index.css earlier. Leftover Vite demo styles can override your grid layout.

Help me fix my board layout

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files before moving on.

ⓧ I'd like to double check the full code

Since each file is spread across multiple code blocks above, scroll up and confirm the following files each contain all the code from the chunks you pasted, in order:

  • client/src/types.ts (1 chunk: the Task interface).
  • client/src/components/TaskCard.tsx (2 chunks: imports and interface, then the function).
  • client/src/components/Column.tsx (2 chunks: imports and interface, then the function).
  • client/src/components/Board.tsx (2 chunks: imports through handleSubmit, then the return JSX).
  • client/src/App.tsx (2 chunks: imports through handleMoveTask, then the return JSX and export).
  • client/src/App.css (5 chunks: base layout, form, columns, task cards, then actions and empty state).

Make sure src/index.css is empty (all default contents deleted).

The board is styled and ready. Time to put it through its paces.

  • Click the input field at the top of the board and type Design landing page.
  • Click Add Task.

A white card with "Design landing page" should appear in the To Do column. The task count badge should update to 1.

  • Add a second task called Set up database.
  • On the "Design landing page" card, click the right arrow button to move it to In Progress.
  • Click the right arrow on "Design landing page" again to move it to Done.

You should see one task in To Do, zero in In Progress, and one in Done. The count badges update with each move.

Before you do anything else, predict: what do you think will happen to your tasks if you refresh the page?

  • Refresh the page in your browser.

Both tasks are gone. Every column is back to "No tasks" with a count of 0.

Why did the tasks disappear?

Your tasks lived entirely in React's useState hook. That state only exists in your browser's memory. The moment you refresh, React rebuilds the component tree from scratch with an empty array.

This is exactly the gap the next step fixes. You need a database so tasks survive page refreshes, server restarts, and multiple users.

Your Kanban board looks great and handles task creation and movement. But losing everything on refresh is a dealbreaker for a real collaboration tool. Next up, you'll connect to a PostgreSQL database on Neon so your tasks persist across sessions and users.

Connect to Neon PostgreSQL and Persist Tasks

Your board looks great. You can create tasks, move them between columns, and see the counts update. There is one serious problem though.

Refresh the page and every task vanishes. Tasks only live in React state, which means they are wiped the moment the browser reloads. To make the board usable, you need a database to store tasks so they survive refreshes, restarts, and multiple users.

In this step, get ready to:
  • Create a free Neon PostgreSQL database and save your connection string.
  • Build a database module that creates the tasks table on startup.
  • Add REST API endpoints and connect the React frontend so tasks persist across refreshes.
Create a Neon account and set up your database

Neon is a serverless PostgreSQL provider that handles all the database infrastructure for you. The free tier is generous and requires no credit card.

  • Open https://console.neon.tech/signup in your browser.
  • Sign up for a free account using GitHub, Google, or email. No credit card is required.
  • After signing in, click New Project.
  • Enter syncboard as the Project name.
  • Leave Postgres version and Region as their defaults.
  • Click Create Project.

What is Neon?

Neon is a serverless PostgreSQL database that scales to zero when idle and resumes in under a second. The free tier includes up to 100 projects and 0.5 GB of storage per project, which is more than enough for this project.

After your project is created, Neon takes you to the Project Dashboard.

  • Click Connect on the Project Dashboard to open the Connect to your database modal.
  • The modal displays a connection string starting with postgresql://. Click the copy icon next to it to copy the full string.

Keep this string safe

The connection string contains your database password. Do not share it or commit it to Git. You will paste it into a local file in the next step.

Now save the connection string in your project so your server can use it.

  • Switch back to VS Code.
  • In the server directory, create a new file called .env.
  • Paste this into .env, replacing your-neon-connection-string with the string you just copied:
DATABASE_URL=your-neon-connection-string
  • Create another file in the server directory called .gitignore.
  • Add this line to prevent your credentials from being committed to Git:
.env

Why a .gitignore?

The .env file contains your database password. Adding it to .gitignore ensures Git never tracks or uploads it to GitHub.

  • Save both files.
  • Confirm the files exist by opening a terminal in the server directory and running:
ls -a

You should see .env and .gitignore listed alongside package.json, src, and tsconfig.json.

Don't see .env?

Files starting with . are hidden by default. The -a flag in ls shows hidden files. Make sure you created .env inside the server folder, not the project root.

Help me find my .env file

Build the database connection and create the tasks table

Your server needs a way to talk to Neon. The @neondatabase/serverless package (already installed in Step 1) provides a neon() function that connects over HTTP, so no extra WebSocket configuration is needed.

  • Create a new file at server/src/db.ts and add the following code:
import { neon } from '@neondatabase/serverless';

// Connect to Neon using the DATABASE_URL from .env
export const sql = neon(process.env.DATABASE_URL!);

// Create the tasks table if it doesn't exist yet
export async function initDb() {
  await sql`
    CREATE TABLE IF NOT EXISTS tasks (
      id SERIAL PRIMARY KEY,
      title TEXT NOT NULL,
      status TEXT NOT NULL DEFAULT 'todo',
      created_at TIMESTAMP DEFAULT NOW()
    )
  `;
  console.log('Database initialized');
}

What does this code do?

neon(process.env.DATABASE_URL!) creates an SQL query function using the connection string you saved in .env. The ! tells TypeScript the value will not be undefined at runtime.

initDb() runs a CREATE TABLE IF NOT EXISTS query so the table is created on first run and safely skipped on subsequent starts. Each task gets an auto-incrementing id, a title, a status defaulting to todo, and a created_at timestamp.

Now connect this module to your server so the table is created on startup.

  • Open server/src/index.ts and find this line near the top:
import dotenv from 'dotenv';
  • Add a new import directly below it:
import { sql, initDb } from './db';
  • Scroll to the bottom of the file and find the server startup code:
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});
  • Replace the app.listen(...) block (keep the const PORT line) with this, so the database is initialized before the server starts:
initDb().then(() => {
  app.listen(PORT, () => {
    console.log(`Server running on port ${PORT}`);
  });
});

Why initialize before listening?

Wrapping app.listen inside initDb().then(...) ensures the tasks table exists before the server accepts any requests. If a request arrives before the table is ready, the query would fail.

  • Save db.ts and index.ts.
  • Return to your server terminal. Start the server by running:
npm run dev

You should see two messages printed in order: Database initialized followed by Server running on port 3001.

Seeing a connection error?

Double-check that your .env file is in the server directory (not the project root) and that DATABASE_URL is set to the full connection string starting with postgresql://.

Make sure there are no extra spaces or line breaks around the connection string in .env.

Help me troubleshoot my database connection

Now verify the table was actually created in Neon.

  • Go back to the Neon console in your browser.
  • In the left sidebar, click SQL Editor.
  • Run this query to confirm the table exists:
SELECT * FROM tasks;

You should see an empty result set with columns id, title, status, and created_at. The table is ready.

Add REST endpoints and wire up the frontend

The database is ready, but the server has no API for the frontend to talk to. You need endpoints that let the React app fetch, create, and update tasks.

  • In server/src/index.ts, find this line:
const PORT = process.env.PORT || 3001;
  • Add these three route handlers directly above that line:
// Fetch all tasks, sorted by creation date
app.get('/api/tasks', async (_req, res) => {
  const tasks = await sql`SELECT * FROM tasks ORDER BY created_at ASC`;
  res.json(tasks);
});

// Create a new task with 'todo' status
app.post('/api/tasks', async (req, res) => {
  const { title } = req.body;
  const result = await sql`
    INSERT INTO tasks (title, status) VALUES (${title}, 'todo') RETURNING *
  `;
  res.json(result[0]);
});

// Update a task's status (move between columns)
app.patch('/api/tasks/:id', async (req, res) => {
  const { id } = req.params;
  const { status } = req.body;
  const result = await sql`
    UPDATE tasks SET status = ${status} WHERE id = ${Number(id)} RETURNING *
  `;
  res.json(result[0]);
});

What do these endpoints do?

  • GET /api/tasks returns every task from the database, sorted oldest first.
  • POST /api/tasks inserts a new row with the given title and a default status of todo. RETURNING * sends the newly created row back to the frontend so it gets the real database id and created_at.
  • PATCH /api/tasks/:id updates a task's status when you move it between columns.
  • Save index.ts. Your terminal should show tsx automatically restarting the server.

Confirm the API works before touching the frontend.

  • Open a new terminal tab and test the endpoint by running:
curl http://localhost:3001/api/tasks

You should see [] (an empty JSON array) because the table has no tasks yet.

Seeing an error from curl?

Make sure the server is still running in the other terminal tab. If tsx crashed after your edits, check for syntax errors in index.ts.

Help me debug my API endpoint

✔️ Awesome, I've got everything!

Great. Double check you saved index.ts and move on to updating the frontend.

ⓧ I'd like to double check the full code

Your server/src/index.ts should now contain these sections, in order:

  • Four imports: express, cors, dotenv, and { sql, initDb } from ./db.
  • dotenv.config() and the Express app setup with cors() and express.json().
  • Three route handlers: GET /api/tasks, POST /api/tasks, PATCH /api/tasks/:id.
  • const PORT line followed by initDb().then(...) wrapping app.listen.

Scroll up and confirm each section is present in the order listed above.

Now update the React frontend to use these endpoints instead of in-memory state.

  • Open client/src/App.tsx and find the first import line:
import { useState } from 'react';
  • Add useEffect to the import so the line reads:
import { useState, useEffect } from 'react';
  • Inside the App function, find the handleCreateTask function. Select everything from const handleCreateTask through its closing }; and replace it, along with the space above it, with this code that fetches tasks on load and creates them via the API:
  // Fetch all tasks from the server when the component loads
  useEffect(() => {
    fetch(`${import.meta.env.VITE_SERVER_URL || 'http://localhost:3001'}/api/tasks`)
      .then((res) => res.json())
      .then((data) => setTasks(data));
  }, []);

  // Send a POST request to create a new task on the server
  const handleCreateTask = async (title: string) => {
    const res = await fetch(`${import.meta.env.VITE_SERVER_URL || 'http://localhost:3001'}/api/tasks`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title }),
    });
    const newTask = await res.json();
    setTasks((prev) => [...prev, newTask]);
  };

What changed?

useEffect runs a fetch call when the component first loads, pulling all existing tasks from the server into React state.

handleCreateTask now sends a POST request to the server instead of generating a fake ID with Date.now(). The server returns the real database row, which gets added to state.

  • Save App.tsx and switch to your browser at http://localhost:5173.
  • Type a task title and click Add Task. The task appears in the To Do column.
  • Refresh the page. The task is still there.

Tasks now survive page refreshes. The last piece is making the move buttons work with the database too.

  • Back in client/src/App.tsx, find the handleMoveTask function and replace it with this version that sends a PATCH request:
  // Send a PATCH request to update a task's column
  const handleMoveTask = async (id: number, status: Task['status']) => {
    const res = await fetch(`${import.meta.env.VITE_SERVER_URL || 'http://localhost:3001'}/api/tasks/${id}`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ status }),
    });
    const updatedTask = await res.json();
    setTasks((prev) => prev.map((t) => (t.id === updatedTask.id ? updatedTask : t)));
  };

What does this change?

Previously, handleMoveTask only updated React state. Now it sends the new status to the server first, waits for the database to confirm the update, then updates state with the server's response. The move is persisted.

  • Save App.tsx.

✔️ Awesome, I've got everything!

Great. Double check you saved App.tsx and move on to the final verification.

ⓧ I'd like to double check the full code

Your client/src/App.tsx should now contain these sections, in order:

  • Import line: import { useState, useEffect } from 'react' plus the other three imports.
  • const [tasks, setTasks] = useState<Task[]>([]) state declaration.
  • useEffect hook that fetches /api/tasks on load.
  • Async handleCreateTask with a POST fetch.
  • Async handleMoveTask with a PATCH fetch.
  • The return JSX (unchanged from Step 2).

Scroll up and confirm each section is present in the order listed above.

Time for the real test. Before you run this, what do you think will happen if you create a task, stop the server completely, restart it, and refresh the browser?

  • Switch to your browser at http://localhost:5173.
  • Create a new task (or use the one from earlier).
  • Move it to the In Progress column using the arrow button.
  • Go to your server terminal and press Ctrl+C to stop the server.
  • Restart it by running:
npm run dev
  • Refresh your browser.

Your task is still there, sitting in the In Progress column exactly where you left it. The database kept it safe through the restart.

Tasks disappeared after restart?

Make sure your useEffect in App.tsx is calling the correct URL (http://localhost:3001/api/tasks). Check the browser console for any network errors.

If the server terminal shows an error about the tasks table, go to the Neon SQL Editor and run SELECT * FROM tasks to verify your data is actually in the database.

Help me debug why my tasks disappeared

Your board now saves every task in a real database. Refreshes, restarts, even closing the browser entirely. The data persists. But try opening the board in two browser tabs and creating a task in one. The other tab stays frozen until you manually refresh it. That real-time gap is exactly what the next step closes with Socket.IO.

Add Real-Time Updates with Socket.IO

Your board now persists tasks in Neon PostgreSQL. Create a task, restart the server, refresh the page, and it's still there.

But right now, each browser tab is its own isolated world. If you open the board in two tabs and create a task in one, the other tab has no idea until you manually refresh. Socket.IO fixes this by opening a persistent connection between each browser and the server, so updates flow in both directions instantly.

In this step, get ready to:
  • Set up Socket.IO on the Express server to broadcast task changes.
  • Install the Socket.IO client and wire it into the React frontend.
  • Test real-time sync across two browser tabs.

Before writing any code, see this limitation for yourself.

  • Open your board at http://localhost:5173 in two browser tabs, side by side.
  • In Tab 1, create a new task (type anything and click Add Task).
  • Look at Tab 2.

What do you expect to see in Tab 2?

Tab 2 shows nothing new. The task you just created only appears in the tab that created it. You have to manually refresh Tab 2 to see it. That is the gap this step closes.

Why doesn't the board update automatically?

HTTP follows a request-response model. The browser asks the server for data, and the server responds. But the server can never reach out to the browser on its own. If another user creates a task, your browser has no way to know about it until it sends a fresh request.

The WebSocket protocol solves this by opening a persistent, two-way connection. Once connected, the server can push data to the browser at any time. Socket.IO builds on top of WebSocket and adds auto-reconnection, event-based messaging, and broadcast support out of the box.

Set up Socket.IO on the server

Socket.IO needs a raw Node.js HTTP server to attach to. Right now, Express creates one internally when you call app.listen(). You need to create the HTTP server explicitly with createServer(app) so Socket.IO can share it.

This means restructuring server/src/index.ts. You will replace the file contents in three parts.

  • Clear everything in server/src/index.ts and paste in this server setup code:
import express from 'express';
import { createServer } from 'http';
import { Server } from 'socket.io';
import cors from 'cors';
import dotenv from 'dotenv';
import { neon } from '@neondatabase/serverless';

dotenv.config();

// Create Express app and wrap it in an HTTP server
const app = express();
const server = createServer(app);
// Attach Socket.IO with CORS for the frontend
const io = new Server(server, {
  cors: {
    origin: process.env.CLIENT_URL || 'http://localhost:5173',
    methods: ['GET', 'POST'],
  },
});

app.use(cors());
app.use(express.json());

// Connect to Neon PostgreSQL
const sql = neon(process.env.DATABASE_URL!);

What does this code do?

  • createServer(app) wraps the Express app in a raw HTTP server that Socket.IO can attach to.
  • new Server(server, { cors: ... }) creates the Socket.IO server and binds it to the same HTTP server. The cors option allows your React frontend on localhost:5173 to connect.
  • The neon() connection is now defined directly in this file instead of imported from db.ts. The db.ts file from Step 3 is no longer imported, but you can leave it in place.
  • Directly below the code you just pasted, add the database initialization function:
// Create the tasks table if it doesn't exist
async function initDb() {
  await sql`
    CREATE TABLE IF NOT EXISTS tasks (
      id SERIAL PRIMARY KEY,
      title TEXT NOT NULL,
      status TEXT NOT NULL DEFAULT 'todo',
      created_at TIMESTAMP DEFAULT NOW()
    )
  `;
  console.log('Database initialized');
}

Why is initDb here now?

This is the same function that lived in db.ts. Moving it into index.ts keeps all server logic in one file, which makes the real-time event flow easier to follow.

  • Next, add back the three REST endpoints. These are the same routes from Step 3:
// Fetch all tasks for initial page load
app.get('/api/tasks', async (_req, res) => {
  const tasks = await sql`SELECT * FROM tasks ORDER BY created_at ASC`;
  res.json(tasks);
});

app.post('/api/tasks', async (req, res) => {
  const { title } = req.body;
  const result = await sql`
    INSERT INTO tasks (title, status) VALUES (${title}, 'todo') RETURNING *
  `;
  res.json(result[0]);
});

app.patch('/api/tasks/:id', async (req, res) => {
  const { id } = req.params;
  const { status } = req.body;
  const result = await sql`
    UPDATE tasks SET status = ${status} WHERE id = ${Number(id)} RETURNING *
  `;
  res.json(result[0]);
});

Why keep the REST endpoints?

The GET /api/tasks endpoint still handles the initial page load. When a user first opens the board, the React app fetches all existing tasks over HTTP. Socket.IO takes over from there for live updates.

  • Below the REST endpoints, add the Socket.IO connection handler:
io.on('connection', (socket) => {
  console.log(`User connected: ${socket.id}`);

  // Save new task to database, then broadcast to all clients
  socket.on('task:create', async (data: { title: string }) => {
    const result = await sql`
      INSERT INTO tasks (title, status) VALUES (${data.title}, 'todo') RETURNING *
    `;
    io.emit('task:created', result[0]);
  });

  // Update task status, then broadcast to all clients
  socket.on('task:move', async (data: { id: number; status: string }) => {
    const result = await sql`
      UPDATE tasks SET status = ${data.status} WHERE id = ${data.id} RETURNING *
    `;
    io.emit('task:moved', result[0]);
  });

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

What does this code do?

  • io.on('connection') fires every time a browser connects. Each browser gets its own socket object.
  • socket.on('task:create') listens for a create event from that specific browser. It saves the task to the database, then calls io.emit('task:created') to broadcast the new task to every connected browser.
  • The same pattern applies to task:move and task:moved. One client sends the event, the server saves and broadcasts, and all clients receive the update.
  • Finally, find the startup code at the very bottom of the file. It currently looks like this:
const PORT = process.env.PORT || 3001;

initDb().then(() => {
  app.listen(PORT, () => {
    console.log(`Server running on port ${PORT}`);
  });
});
  • Replace app.listen with server.listen, so the block looks like this:
const PORT = process.env.PORT || 3001;

initDb().then(() => {
  server.listen(PORT, () => {
    console.log(`Server running on port ${PORT}`);
  });
});

Why server.listen instead of app.listen?

app.listen() creates its own internal HTTP server. Socket.IO is attached to the server variable you created with createServer(app). If you call app.listen() instead, Express starts a different HTTP server and Socket.IO never receives any connections.

  • Save server/src/index.ts.
  • Switch to the terminal where your server is running. Because tsx watch detects file changes, the server should automatically restart.
  • You should see Database initialized followed by Server running on port 3001 in the terminal output.

Server not restarting or showing errors?

  • Check that you replaced app.listen with server.listen at the bottom of the file. Using app.listen means Socket.IO will not work.
  • Make sure you have all six import lines at the top, including { createServer } from 'http' and { Server } from 'socket.io'.
  • Confirm your .env file still has the DATABASE_URL variable. A missing connection string causes a crash on startup.

Having trouble with the server setup?

✔️ Awesome, I've got everything!

Great. Make sure you have saved server/src/index.ts and the server restarted successfully.

ⓧ I'd like to double check the full code

Your server/src/index.ts file should contain five sections in this order. Scroll up and confirm each block is present:

  • Six import lines (express, createServer, Server, cors, dotenv, neon), followed by dotenv.config(), app/server/io creation, middleware, and const sql.
  • The initDb function with the CREATE TABLE IF NOT EXISTS tasks query.
  • Three REST endpoints: GET /api/tasks, POST /api/tasks, and PATCH /api/tasks/:id.
  • The io.on('connection') block with handlers for task:create, task:move, and disconnect.
  • The startup block using server.listen (not app.listen).
Install the Socket.IO client and update the React app

The server is ready to accept connections. Now you need the React frontend to connect to it and listen for events.

  • Switch to the terminal where your client dev server is running. Stop it with Ctrl+C.
  • Install the Socket.IO client library by running:
npm install socket.io-client@4.8.3
  • Restart the client dev server by running:
npm run dev
  • Confirm the client is running again at http://localhost:5173 and the board still loads.

Install failed?

  • Make sure you are in the client directory, not the server or syncboard root directory. Run pwd to check.

Need help installing the Socket.IO client?

  • Create a new file called socket.ts inside client/src/ and paste in this code:
import { io } from 'socket.io-client';

// Connect to the server (uses env variable or localhost default)
const SERVER_URL = import.meta.env.VITE_SERVER_URL || 'http://localhost:3001';

export const socket = io(SERVER_URL);

What does this file do?

This creates a single Socket.IO client instance and exports it. Every component that imports socket shares the same connection to the server. The io() function connects automatically when the module loads.

  • Save socket.ts.
  • Check your server terminal. You should see User connected: followed by a socket ID. This confirms the frontend connected to the server.

Now update App.tsx to use Socket.IO for real-time events instead of REST calls.

  • In client/src/App.tsx, find the import block at the top. It currently looks like this:
import { useState, useEffect } from 'react';
import { Board } from './components/Board';
  • Add the socket import between these two lines, so the imports now look like this:
import { useState, useEffect } from 'react';
import { socket } from './socket';
import { Board } from './components/Board';
  • Scroll down to the useEffect block. Find the line that says .then((data) => setTasks(data));.
  • Add the following socket event listeners after that line, before the closing }, []);:
    // Listen for new tasks from other users
    socket.on('task:created', (task: Task) => {
      setTasks((prev) => [...prev, task]);
    });

    // Listen for tasks moved by other users
    socket.on('task:moved', (updatedTask: Task) => {
      setTasks((prev) =>
        prev.map((t) => (t.id === updatedTask.id ? updatedTask : t))
      );
    });

    // Clean up listeners when component unmounts
    return () => {
      socket.off('task:created');
      socket.off('task:moved');
    };

What do these listeners do?

  • socket.on('task:created') fires whenever the server broadcasts a new task. It adds the task to state so it appears on the board instantly.
  • socket.on('task:moved') fires whenever a task moves between columns. It updates that specific task's status in state.
  • The return () => { ... } cleanup function removes the listeners when the component unmounts. This prevents duplicate listeners if React re-mounts the component.
  • Below the useEffect, find the handleCreateTask function. It currently looks like this:
  const handleCreateTask = async (title: string) => {
    const res = await fetch(`${import.meta.env.VITE_SERVER_URL || 'http://localhost:3001'}/api/tasks`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title }),
    });
    const newTask = await res.json();
    setTasks((prev) => [...prev, newTask]);
  };
  • Replace the entire handleCreateTask function with this:
  const handleCreateTask = (title: string) => {
    socket.emit('task:create', { title });
  };
  • Right below it, find the handleMoveTask function. It currently looks like this:
  const handleMoveTask = async (id: number, status: Task['status']) => {
    const res = await fetch(`${import.meta.env.VITE_SERVER_URL || 'http://localhost:3001'}/api/tasks/${id}`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ status }),
    });
    const updatedTask = await res.json();
    setTasks((prev) => prev.map((t) => (t.id === updatedTask.id ? updatedTask : t)));
  };
  • Replace the entire handleMoveTask function with this:
  const handleMoveTask = (id: number, status: Task['status']) => {
    socket.emit('task:move', { id, status });
  };

Why are the handlers so much shorter now?

Instead of making a REST call, waiting for a response, and updating local state, each handler now fires a single socket event. The server receives the event, saves to the database, and broadcasts the result back to all connected clients. The socket listener in useEffect handles the state update automatically.

  • Save App.tsx.
  • Refresh http://localhost:5173 in your browser. The board should still load with your existing tasks.
  • Try creating a task. It should appear on the board just like before.

Board not loading or tasks not appearing?

  • Open your browser's developer console (F12 or Cmd+Option+I) and check for errors. A CORS error means the Socket.IO server's origin does not match your frontend URL.
  • Confirm you added import { socket } from './socket'; at the top of App.tsx. A missing import causes a compile error.
  • Make sure both the server (port 3001) and client (port 5173) are running.

Need help debugging your Socket.IO client setup?

✔️ Awesome, I've got everything!

Great. Make sure both App.tsx and socket.ts are saved.

ⓧ I'd like to double check the full code

Your client/src/socket.ts should look exactly like this:

import { io } from 'socket.io-client';

const SERVER_URL = import.meta.env.VITE_SERVER_URL || 'http://localhost:3001';

export const socket = io(SERVER_URL);

Your client/src/App.tsx should have these sections in order. Scroll up and confirm each edit is in place:

  • Imports include { socket } from './socket' between the React and Board imports.
  • The useEffect still fetches tasks with fetch() on load, then has socket.on('task:created') and socket.on('task:moved') listeners, and a cleanup return that calls socket.off() for both events.
  • handleCreateTask is a simple 3-line function that calls socket.emit('task:create').
  • handleMoveTask is a simple 3-line function that calls socket.emit('task:move').
Test the real-time flow

This is the moment everything comes together.

  • Open http://localhost:5173 in two browser tabs, side by side.

Before you test, think about what you expect. If you create a task in Tab 1, will Tab 2 update automatically this time?

  • In Tab 1, type a task title and click Add Task.
  • Look at Tab 2.

The task appears in Tab 2 instantly. No refresh needed.

  • In Tab 2, click the arrow button on a task to move it to a different column.
  • Look at Tab 1. The task moves to the same column, live.

Check your server terminal. You should see connection logs and the queries running as you create and move tasks across tabs.

Why Socket.IO instead of native WebSocket?

Native WebSocket gives you a raw, persistent connection. But you would need to build reconnection logic, message framing, room management, and broadcast fan-out yourself.

Socket.IO handles all of that out of the box. It reconnects automatically if the connection drops, uses event names instead of raw strings, and gives you io.emit() to broadcast to every connected client in one line.

Tab 2 not updating?

  • Open the browser console in Tab 2 and look for WebSocket-related errors. A failed connection means the server is not reachable or CORS is blocking.
  • Confirm the server terminal shows User connected: for both tabs. If you only see one connection, Tab 2 may not have loaded the socket module.
  • Make sure you replaced handleCreateTask with the socket.emit version. If the old fetch version is still there, the server saves the task but does not broadcast it.

Need help getting real-time sync working?

Your board now syncs in real time across every connected browser. Next up, you will deploy the project to Render so you have a live URL ready for your resume.

Deploy to Render and Go Live

Your board now syncs tasks across browser tabs in real time. That is impressive, but right now it only runs on your machine. No recruiter, hiring manager, or collaborator can see it.

In this step, you will bundle the React frontend into the Express server, push everything to GitHub, and deploy to Render. The result is a public URL you can share on your resume and portfolio.

In this step, get ready to:
  • Build the React frontend for production and bundle it into the server.
  • Deploy to Render and get a live URL.
  • Verify real-time sync works on the deployed app.
Build and bundle the React client

The Express server already has code to serve static files from a public folder. Your job is to compile the React app into production-ready files and place them there.

  • Stop both dev servers by pressing Ctrl+C in each terminal (the server terminal and the client terminal).
  • Navigate to your syncboard root folder by running:
cd ~/Desktop/syncboard

Before pushing to Git, you need a .gitignore file so that dependency folders and secrets stay out of your repository.

  • Create a .gitignore file in the syncboard root by running:
cat > .gitignore << EOF
node_modules
.env
dist
EOF

What does this file do?

The .gitignore tells Git to skip tracking node_modules (dependencies), .env (secrets), and dist (compiled TypeScript output). Render will install dependencies and compile from source on its own.

  • Verify the file was created by running:
cat .gitignore

You should see node_modules, .env, and dist listed.

  • Build the React client for production by running:
cd client && npm run build

Vite compiles your React code into optimized static files inside client/dist/. You should see output listing the generated files and their sizes.

  • Copy the built files into the server's public folder by running:
cd ..
mkdir -p server/public
cp -r client/dist/* server/public/
  • Confirm the files landed correctly by running:
ls server/public/

You should see index.html and an assets folder listed. These are the production files Express will serve.

Don't see index.html?

  • Make sure you ran npm run build from inside the client directory, not the syncboard root.
  • Check that client/dist/ exists and contains files before copying.
  • On Windows PowerShell, use Copy-Item -Recurse client/dist/* server/public/ instead of cp -r.

Need more help? Why isn't my React build copying to the server/public folder?

Push to GitHub and deploy on Render

With the bundled app ready, you need to get it into a GitHub repository so Render can pull and deploy it.

  • Initialize a Git repository and make your first commit by running these commands from the syncboard root:
git init
git add .
git commit -m "Initial commit"

You should see a summary of the committed files. Notice that node_modules and .env are excluded thanks to your .gitignore.

  • Open GitHub in your browser and navigate to https://github.com/new.
  • Enter syncboard as the **Repository name**.
  • Leave Public selected so Render can access it.
  • Do NOT check any of the initialization options (no README, no .gitignore, no license).
  • Click Create repository.
  • Copy the repository URL that GitHub shows you. It looks like https://github.com/your-username/syncboard.git.
  • Back in your terminal, connect your local repo and push by running:
git remote add origin [[GITHUB_REPO_URL="https://github.com/your-username/syncboard.git"]]
git branch -M main
git push -u origin main

You should see Git push your code to GitHub. Refresh the GitHub page and confirm your files appear.

Push rejected or authentication error?

  • If GitHub asks for authentication, you may need to set up a personal access token or SSH key. Follow the prompts GitHub provides.
  • Make sure you did not initialize the GitHub repo with a README. An initialized repo causes a merge conflict on the first push.

Need more help? Why is my git push to GitHub failing?

Now that your code is on GitHub, you can deploy it to Render.

  • Open https://render.com in your browser.
  • Click Get Started and sign up using your GitHub account (no credit card required).
  • In the Render Dashboard, click New, then select Web Service.
  • Connect your GitHub account if prompted, then select your syncboard repository.
  • Fill in the service creation form with these values:
  • Name: syncboard-server.
  • Language: Node.
  • Root Directory: server. This tells Render to run commands from inside the server folder.
  • Build Command: npm install && npm run build. This installs dependencies and compiles TypeScript.
  • Start Command: npm start. This runs node dist/index.js as defined in your package.json.

Why set a root directory?

Your GitHub repo contains both server and client folders. Setting the root directory to server tells Render to treat that folder as the project root. Build and start commands run from there, so npm install picks up the server's package.json automatically.

  • Choose the Free instance type.
  • Scroll down to the Advanced section and click to expand it.
  • Click + Add Environment Variable.
  • Set the Key to DATABASE_URL.
  • Set the Value to your Neon connection string (the same one in your server/.env file).
  • Click Create Web Service.

Render begins building and deploying your app. You can watch the progress in the deploy logs on the service's Events page.

What is Render doing?

Render clones your repo, navigates to the server folder, runs npm install && npm run build to install dependencies and compile TypeScript, then starts the server with npm start. The server/public folder you pushed is already there, so Express can serve your React files immediately.

Deploy failing?

  • Check the deploy logs for errors. Common issues include a missing DATABASE_URL environment variable or a typo in the build command.
  • Make sure Root Directory is set to server. Without it, Render looks for a package.json in the repo root and fails.
  • Verify your Neon connection string is correct by comparing it with the value in server/.env.

Need more help? Why is my Render deploy failing for my Express and Socket.IO server?

Verify real-time sync on the live app

The deploy takes 2 to 3 minutes. Once the logs show your service is live, Render provides a public URL on the service's page.

  • Wait for the deploy status to update to Live in the Render Dashboard.
  • Copy the .onrender.com URL shown at the top of your service's page. Record it here: your Render URL.
  • Open that URL in your browser.

You should see the SyncBoard layout load. However, the board will not load any tasks yet. The React build still has http://localhost:3001 baked in as the server URL, so the browser tries to connect to your local machine instead of the deployed server.

Why does the board look empty?

Vite embeds environment variables at build time, not at runtime. When you built the client earlier, VITE_SERVER_URL was not set. The code fell back to http://localhost:3001, which is baked into the production JavaScript files. You need to rebuild with the correct URL.

  • In your terminal, navigate to the client directory and create a .env file with your Render URL by running:
cd ~/Desktop/syncboard/client
echo "VITE_SERVER_URL=[[RENDER_URL="your Render URL"]]" > .env

What does this do?

Vite reads .env files at build time and injects any variable starting with VITE_ into your code. By setting VITE_SERVER_URL to your Render URL, the next build will use the deployed server address instead of localhost.

  • Rebuild the React client by running:
npm run build
  • Copy the fresh build into the server's public folder by running:
cd ..
cp -r client/dist/* server/public/
  • Commit and push the updated build by running:
git add .
git commit -m "Update client build with production URL"
git push

Render detects the push and automatically triggers a new deploy. Wait 2 to 3 minutes for the deploy to complete.

Before you open the URL, take a moment to predict. Do you expect the board to load tasks this time? What about real-time sync between two tabs?

  • Open your your Render URL in your browser.
  • Create a task to confirm the board is working.
  • Open the same URL in a second browser tab.
  • Create a task in one tab and watch it appear instantly in the other.
  • Move a task between columns in one tab and confirm it moves in the other.

Both tabs show the same board updating in real time. Your SyncBoard is live and accessible to anyone with the link.

What about the free tier spin-down?

Render's free tier spins down your service after 15 minutes of no HTTP requests or WebSocket messages. The next visitor triggers a cold start that takes about 1 minute. This is normal for a portfolio demo. While someone is actively using the board, the WebSocket connection keeps the server awake.

Board loads but tasks don't appear?

  • Open your browser's developer console (F12 or Cmd+Option+I on macOS) and check for connection errors. If you see requests to localhost:3001, the client was not rebuilt with the correct VITE_SERVER_URL. Repeat the rebuild and push steps above.
  • Make sure the VITE_SERVER_URL in client/.env starts with https:// and does NOT have a trailing slash.
  • Verify the latest deploy finished successfully in the Render Dashboard before testing.

Need more help? Why does my deployed SyncBoard not load tasks or sync in real time?

  • Add your deployed URL and your GitHub repo link to your resume or portfolio. These two links prove you can build and deploy a real-time full-stack application.

Your SyncBoard is live. You have a deployed, real-time collaborative task board with a URL anyone can visit. Next up, there is an optional Secret Mission to push your Socket.IO skills even further.

Secret mission

Add User Presence Indicators

Your board syncs tasks in real time, but you can't tell if anyone else is watching. Add colored presence dots and a live user count to the header that updates the moment someone opens or closes a tab.

Clean Up Your Resources

Clean Up Your Resources

This project uses Neon PostgreSQL and Render, both on permanent free tiers with no credit card required. There are no ongoing costs, so keeping everything running is a safe default.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Neon PostgreSQL database (free tier, pauses compute after 5 minutes of inactivity).
  • Render Web Service (free tier, spins down after 15 minutes of inactivity).
  • GitHub repository containing your SyncBoard project code.
  • Local syncboard project folder on your Desktop.

Keep everything running

No action needed. Choose this if you want your live demo URL to stay accessible.

  • Both Neon and Render are on free tiers. No charges will accrue.
  • Neon automatically pauses compute after 5 minutes of inactivity and resumes on the next query.
  • Render spins down your service after 15 minutes without traffic. The next request takes about 1 minute to cold-start.
  • Your local syncboard folder and GitHub repository remain on your machine and account.

Pause - I'll come back to this later

Both services auto-pause when idle, so there is nothing you need to do manually.

  • Neon pauses compute after 5 minutes of inactivity and auto-resumes on the next query.
  • Render spins down your Web Service after 15 minutes without HTTP requests or WebSocket messages. It auto-starts on the next incoming request.
  • Your GitHub repository and local files stay as they are. No action required.

Delete - I don't want to use this again

Remove all project resources. This is optional since both cloud services are free, but follow these steps if you want a clean slate.

Delete your Render Web Service
  • Go to https://dashboard.render.com and sign in.
  • Select your syncboard-server Web Service.
  • Click Settings in the left sidebar.
  • Scroll to the bottom of the page and click Delete Web Service.
  • Confirm the deletion when prompted.
Delete your Neon project
  • Go to https://console.neon.tech and sign in.
  • Select your SyncBoard project from the project list.
  • Click Settings in the sidebar.
  • Scroll to the bottom and click Delete. Confirm when prompted.
Delete your GitHub repository (optional)
  • Open your SyncBoard repository on GitHub.
  • Click Settings in the repository navigation bar.
  • Scroll to the Danger Zone at the bottom of the page.
  • Click Delete this repository and follow the confirmation steps.
Delete your local project files
  • Delete the local project folder by running this command in your terminal.

macOS

rm -rf ~/Desktop/syncboard

Windows

rmdir /s /q %USERPROFILE%\Desktop\syncboard

Nice Work!

Nice Work!

Nice work! You just built SyncBoard, a full-stack real-time collaborative task board powered by React, TypeScript, Node.js, Express, and Socket.IO.

You've learned how to:

  • Built a real-time collaborative task board where creating or moving a task in one browser tab instantly updates every other connected tab, using Socket.IO for bidirectional WebSocket communication.
  • Connected to a Neon PostgreSQL database using the serverless driver so tasks persist across server restarts and page refreshes.
  • Deployed the full-stack app to Render with a live .onrender.com demo URL ready to share on your resume and GitHub.
  • Secret Mission: Added user presence indicators with colored dots and a live user count, tracking connected users in real time to demonstrate ephemeral vs. persistent state management.

Ready to quiz yourself?