Build a Real-Time Polyglot Chat

Build an authenticated chat with PostgreSQL, MongoDB, and Socket.IO.

Introduction

30 Second Summary

Group chats become frustrating when messages arrive late or disappear after a refresh. People expect every participant to see the same conversation immediately.

In this project, you will build a real-time chat protected by Supabase Auth for two independent browser sessions. Its polyglot persistence design keeps profiles in PostgreSQL while MongoDB Atlas stores chat history behind a Socket.IO gateway.

What You'll Build

Your finished app shows two signed-in browser sessions sharing the same live conversation, even after either session refreshes.

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

  • A sign-up and sign-in flow that gives each browser session its own authenticated user.
  • A persistent chat demo where profile names return from PostgreSQL while messages return from MongoDB Atlas.
  • An authenticated real-time gateway that rejects invalid access tokens before storing messages or activity records.
  • Secret Mission: Create a command-line audit report that counts the latest MongoDB log documents by event type.

Are there any prerequisites?

You'll need full-stack JavaScript fundamentals plus access to an email inbox. The guide checks Node.js before walking you through MongoDB Atlas account setup.

Before We Start

This checkpoint locks in the production-shaped local chat you are about to build before any hands-on work begins. Your polyglot persistence boundary gives authenticated identities plus durable profiles to Supabase Auth and PostgreSQL. MongoDB Atlas stores message documents plus audit logs behind a Node.js Socket.IO gateway.

Set Up the Local App

Your chat architecture gains several moving parts in later steps. A reproducible starter keeps setup problems separate from authentication or database problems.

First, you will confirm Node.js can run Vite 8.2.2. Then you will use Visual Studio Code to assemble the smallest starter that produces a visible browser result.

In this step, get ready to:
  • Confirm Node.js is v22.12.0 or later.
  • Create a pinned React starter with Supabase installed.
  • Launch the Vite development server to see the starter screen.
Check Node.js and npm

The starter needs Node.js v22.12.0 or later. Checking the runtime first prevents an older version from blocking the Vite installation.

  • Press the Windows key to open Windows search.
  • Type PowerShell.
  • Press Enter to open PowerShell.
  • Check your Node.js version and npm version by running these commands:
node --version
npm --version

What do these commands check?

  • The first command prints the installed Node.js version.
  • The second command prints the installed npm version.
  • A Node.js result of v22.12.0 or higher satisfies this starter's runtime requirement.

✔️ I see v22.12.0 or higher

You are set. Your Node.js runtime can run the pinned Vite release.

  • Keep PowerShell available for the project setup checks ahead.

ⓧ I see an older version

Your installed runtime is below the starter's minimum version. Updating it now removes a predictable dependency failure.

  • Open the official Node.js download page.
  • Download Node.js v24.20.0 LTS for Windows.
  • Complete the official Windows installation flow.
  • Close the existing PowerShell window.
  • Reopen PowerShell from Windows search.
  • Confirm the updated versions by running both checks:
node --version
npm --version

What confirms the upgrade?

The Node.js result now needs to show v22.12.0 or higher. The npm command also needs to print a version number.

Still seeing the older version?

Make sure you closed every PowerShell window after installation. A newly opened window loads the updated runtime path.

Need help checking which Node.js installation PowerShell is using? Help me diagnose an older Node.js version after updating on Windows.

ⓧ Command not found

PowerShell cannot currently find Node.js. Installing the current LTS release adds the runtime needed by this project.

  • Open the official Node.js download page.
  • Download Node.js v24.20.0 LTS for Windows.
  • Complete the official Windows installation flow.
  • Open a new PowerShell window from Windows search.
  • Verify the installation by running both checks:
node --version
npm --version

What confirms the installation?

Both commands need to print version numbers. The Node.js version needs to be v22.12.0 or higher.

Commands still unavailable?

Close PowerShell after the installer finishes. Open a fresh window so Windows reloads the runtime path.

Need help finding the missing installation? Help me make Node.js available in PowerShell.

Create the starter files

The starter separates the browser entry point from the React component. A small stylesheet makes the first successful run easy to recognize.

  • Press Cmd+Space on macOS or the Windows key on Windows to open system search.
  • Type Visual Studio Code.
  • Press Enter to open Visual Studio Code.
  • Select File from the top menu.
  • Select Open Folder....
  • Choose your Desktop location.
  • Select New Folder.
  • Enter polyglot-chat as the folder name.

The folder picker now shows your empty polyglot-chat folder on the Desktop.

  • Select Select Folder on Windows or Open on macOS.
  • Select Yes, I trust the authors to enable all features in the workspace if the Workspace Trust dialog opens.

Good progress. The Explorer view now shows polyglot-chat as your active workspace.

  • Select the Explorer view in the Activity Bar.
  • Select the New File... button.
  • Enter package.json as the file name.
  • Press Enter to create the file.
  • Set the starter's pinned packages by pasting this code into package.json:
{"name":"polyglot-chat","private":true,"version":"1.0.0","type":"module","engines":{"node":">=22.12.0"},"scripts":{"dev":"vite","build":"vite build"},"dependencies":{"@supabase/supabase-js":"2.116.0","react":"19.2.8","react-dom":"19.2.8"},"devDependencies":{"vite":"8.2.2"}}

What does this file control?

  • The engines entry records the minimum Node.js version.
  • The dev script starts Vite.
  • The build script creates a production build later.
  • The dependency entries pin React 19.2.8.
  • React DOM is pinned to 19.2.8.
  • Supabase JavaScript is pinned to 2.116.0.
  • Vite is pinned to 8.2.2.
  • Save package.json by pressing Cmd+S on macOS or Ctrl+S on Windows.
  • Select View from the top menu.
  • Select Terminal to open the integrated terminal.
  • Install the pinned packages by running this command:
npm install

What does this command do?

npm reads package.json and downloads the exact dependency versions. It also records the resolved installation for reproducible local runs.

The installation is complete when PowerShell returns to an input prompt without an unresolved installation error. Your workspace now has the packages needed to render the starter.

Package installation failed?

Confirm the integrated terminal belongs to the polyglot-chat workspace. Also confirm that package.json contains valid JSON with matching braces.

Need help interpreting the installation output? Help me debug npm install in my polyglot-chat folder.

Creating the nested source files can feel fiddly. The Explorer structure gives every file a clear location before the app starts.

  • Select the polyglot-chat folder in the Explorer view.
  • Select the New File... button.
  • Enter index.html as the file name.
  • Press Enter to create the file.
  • Define the browser entry document by pasting this code into index.html:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Polyglot Chat</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

What does this document do?

  • The document sets the browser tab title to Polyglot Chat.
  • The root element gives React a place to render.
  • The module script loads src/main.jsx as the JavaScript entry point.
  • Save index.html by pressing Cmd+S on macOS or Ctrl+S on Windows.
  • Confirm the Explorer view lists index.html inside polyglot-chat.

Index file in the wrong place?

The index.html file belongs directly inside polyglot-chat. Move it out of any nested folder before continuing.

Need help checking the folder structure? Help me place index.html in the correct VS Code folder.

  • Select the polyglot-chat folder in the Explorer view.
  • Select the New Folder... button.
  • Enter src as the folder name.
  • Press Enter to create the folder.
  • Select the src folder in the Explorer view.
  • Select the New File... button.
  • Enter App.jsx as the file name.
  • Press Enter to create the file.
  • Define the temporary starter screen by pasting this code into src/App.jsx:
export default function App() {
  return <main className="shell"><h1>Polyglot Chat</h1><p>PostgreSQL profiles + MongoDB chat</p></main>;
}

What does this component do?

  • The App component returns the temporary starter interface.
  • The heading names the project.
  • The paragraph previews the two persistence roles that the later steps connect.
  • Save src/App.jsx by pressing Cmd+S on macOS or Ctrl+S on Windows.
  • Confirm the Explorer view lists App.jsx inside src.

Component file missing?

Expand the src folder in the Explorer view. Check that the file name uses the exact capitalization App.jsx.

Need help repairing the component path? Help me fix the location or name of src/App.jsx.

  • Select the src folder in the Explorer view.
  • Select the New File... button.
  • Enter main.jsx as the file name.
  • Press Enter to create the file.
  • Connect React to the browser document by pasting this code into src/main.jsx:
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import "./styles.css";

createRoot(document.getElementById("root")).render(<App />);

What does this entry point do?

  • The imports load React DOM's browser renderer.
  • The imports also load the App component.
  • The stylesheet import applies the starter design.
  • The final line renders the component inside the root element from index.html.
  • Save src/main.jsx by pressing Cmd+S on macOS or Ctrl+S on Windows.
  • Confirm the Explorer view lists main.jsx inside src.

Entry point not lining up?

Check that main.jsx sits beside App.jsx inside src. The relative import depends on that shared location.

Need help matching the imports to your files? Help me debug the imports in src/main.jsx.

  • Select the src folder in the Explorer view.
  • Select the New File... button.
  • Enter styles.css as the file name.
  • Press Enter to create the file.
  • Style the temporary starter screen by pasting this code into src/styles.css:
:root { font-family: Inter, system-ui, sans-serif; color: #e5eef8; background: #07111f; }
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at top, #163252, #07111f 58%); }
.shell { width: min(760px, calc(100% - 2rem)); margin: 0 auto; padding: 2rem 0; }

What does this stylesheet do?

  • The root rule sets the page typography and foreground colors.
  • The body rule fills the browser window with a dark radial background.
  • The shell rule keeps the starter content centered within a readable width.
  • Save src/styles.css by pressing Cmd+S on macOS or Ctrl+S on Windows.
  • Confirm the Explorer view lists styles.css inside src.

Stylesheet file missing?

Check that the file name is exactly styles.css. It needs to sit inside src beside both JSX files.

Need help correcting the file structure? Help me fix the src folder in my polyglot-chat project.

✔️ Awesome, I've got everything!

Your five starter files are in place. The next check runs them together in the browser.

  • Save every open editor tab before starting the development server.

ⓧ I'd like to double check the full code

Check package.json
{"name":"polyglot-chat","private":true,"version":"1.0.0","type":"module","engines":{"node":">=22.12.0"},"scripts":{"dev":"vite","build":"vite build"},"dependencies":{"@supabase/supabase-js":"2.116.0","react":"19.2.8","react-dom":"19.2.8"},"devDependencies":{"vite":"8.2.2"}}

What should match?

Compare the complete file character by character. This temporary package file contains only the dependencies needed for the authentication starter.

Check index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Polyglot Chat</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

What should match here?

The complete document contains one root element. Its module script points to /src/main.jsx.

Check src/App.jsx
export default function App() {
  return <main className="shell"><h1>Polyglot Chat</h1><p>PostgreSQL profiles + MongoDB chat</p></main>;
}

What should match in the component?

The complete temporary component returns the project heading and persistence summary inside the shell layout.

Check src/main.jsx
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import "./styles.css";

createRoot(document.getElementById("root")).render(<App />);

What should match in the entry point?

The complete entry point imports the component and stylesheet. It renders App into the root element.

Check src/styles.css
:root { font-family: Inter, system-ui, sans-serif; color: #e5eef8; background: #07111f; }
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at top, #163252, #07111f 58%); }
.shell { width: min(760px, calc(100% - 2rem)); margin: 0 auto; padding: 2rem 0; }

What should match in the styles?

The complete stylesheet contains four rules. Together they create the dark page and centered shell used by the starter.

Run the starter app

Vite serves the files from your polyglot-chat workspace. The development server stays attached to the integrated terminal while it runs.

Before you start the server, do you expect the five files to produce the project title in the browser? Keep your prediction in mind.

  • Switch back to the integrated terminal from earlier.
  • Start the Vite development server by running this command:
npm run dev

What does this command do?

npm runs the dev script from package.json. That script starts the Vite development server for the current workspace.

You will see the heading Polyglot Chat above the text PostgreSQL profiles + MongoDB chat on a dark background.

You have your first visible win. The pinned React starter is running from the exact local structure that the authentication work builds on.

  • Keep the Vite server running for the next step.
  • Stop it later by pressing Ctrl+C in the integrated terminal.

Starter screen not loading?

Confirm the terminal belongs to the polyglot-chat workspace. Check that the server process is still running.

Compare each file name with the double-check tab. Capitalization matters for App.jsx and its import.

Need help tracing the startup problem? Help me debug my Polyglot Chat Vite starter.

Your local starter is ready. Next, you will prepare the protected PostgreSQL profile store that gives each authenticated user a durable display name.

Prepare the PostgreSQL Profile Store

Your Vite starter now runs locally. That gives you a visible foundation for the authentication flow.

Authentication identifies each user. The chat also needs an application-owned profile row that survives refreshes and gives the gateway a display name it can trust.

In this step, Supabase stores that row in PostgreSQL. Row Level Security limits each user to their own profile.

In this step, get ready to:
  • Create a Supabase project with a protected public.profiles table.
  • Apply owner-only access through Row Level Security.
  • Connect the local app without tracking .env in source control.
Create the protected profile table

The Supabase project hosts the profile database. Its Free Plan provides enough capacity for this local learning demo.

Choosing a service plan can feel consequential. Select the Free Plan at $0/month for this project.

  • Return to the Supabase dashboard from earlier.
  • Start a project through the dashboard's project creation control.

You should now see the project creation form.

  • Name the project polyglot-chat.
  • Generate a secure database password with your password manager.

The project now has a recognizable name and a password ready for its database.

  • Enter the saved database password in the project form.
  • Choose the available region nearest to you.

The region determines where Supabase hosts this project's database.

  • Select the Free Plan.
  • Submit the project creation form.

A short wait here is normal because Supabase is preparing the database.

  • Wait until the project dashboard shows that the project is ready.

That is the cloud foundation in place. Your Supabase project is ready to hold application-owned profiles.

The profiles table links each profile to an authenticated user. The policies in supabase.sql enforce that ownership inside the database.

  • Use the file tree in Visual Studio Code to create supabase.sql inside the polyglot-chat folder.
  • Add the protected profile schema by copying this code into supabase.sql:
create table public.profiles (
  id uuid primary key references auth.users(id) on delete cascade,
  display_name text not null check (char_length(display_name) between 2 and 40),
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

alter table public.profiles enable row level security;
revoke all on table public.profiles from anon, authenticated;
grant select, insert, update on table public.profiles to authenticated;

create policy "Users can view their own profile"
on public.profiles for select to authenticated
using ((select auth.uid()) = id);

create policy "Users can create their own profile"
on public.profiles for insert to authenticated
with check ((select auth.uid()) = id);

create policy "Users can update their own profile"
on public.profiles for update to authenticated
using ((select auth.uid()) = id)
with check ((select auth.uid()) = id);

What does this SQL do?

  • The id column links each profile to one user in Supabase Auth.
  • The display_name constraint accepts names containing 2 through 40 characters.
  • The created_at column records when the profile was created.
  • The updated_at column records the profile's latest saved timestamp.
  • The grants give authenticated users access to the required operations.
  • The policies compare auth.uid() with the profile's id value.
  • Save supabase.sql.
  • Return to the Supabase project dashboard.
  • Open SQL Editor.
  • Create a blank query using the editor's query control.
  • Paste the contents of supabase.sql into the query editor.
  • Execute the query using the editor's run control.

You should see a successful completion state for the query.

  • Open the database table view in the project dashboard.
  • Select the profiles table from the public schema.

You should see id, display_name, created_at, and updated_at. The table should also show that Row Level Security is enabled.

Did the profile query fail?

Confirm that you pasted the complete contents of supabase.sql into a blank query. A partial policy or missing closing parenthesis prevents the script from completing.

If the table already exists after an earlier attempt, remove that test table before running the complete script again.

Help me diagnose my profile schema query.

Expose profiles and adjust local auth

The Data API gives the browser client a route to query public.profiles. Row Level Security still decides which rows each signed-in user can access.

  • Open the project's Data API settings from the Supabase dashboard.
  • Review the list of exposed schemas.
  • Add public to the exposed schemas if it is absent.
  • Save the Data API settings.

The public schema should now appear in the exposed schemas list. This makes public.profiles reachable through the protected API.

Hosted Supabase projects enable email confirmation by default. This local demo turns that requirement off so two test users can enter the chat without waiting for confirmation emails.

  • Open the project's authentication settings.
  • Open the email provider configuration.
  • Disable Confirm email.
  • Save the authentication settings.

You should now see email confirmation disabled for this project.

Why disable email confirmation here?

This setting keeps the local learning loop short. A production authentication policy needs its own security review before email confirmation is disabled.

Connect the local app safely

The browser client needs the Supabase Project URL and publishable key. Environment variables keep those deployment-specific values outside the application code.

Connection values deserve careful handling. Keep .env out of source control and avoid including its contents in screenshots.

  • Use the Visual Studio Code file tree to create .env.example inside the polyglot-chat folder.
  • Add the project's environment template by copying this code into .env.example:
VITE_SUPABASE_URL=your-supabase-project-url
VITE_SUPABASE_PUBLISHABLE_KEY=your-supabase-publishable-key
VITE_SOCKET_URL=http://localhost:3001
MONGODB_URI=your-mongodb-atlas-connection-string
PORT=3001

What does this environment template do?

  • The VITE_SUPABASE_URL value tells the browser which Supabase project to use.
  • The VITE_SUPABASE_PUBLISHABLE_KEY value lets the browser initialize the Supabase client.
  • The VITE_SOCKET_URL value reserves the local gateway address.
  • The MONGODB_URI value keeps the later database connection placeholder in the shared template.
  • The PORT value reserves the gateway's local port.
  • Save .env.example.
  • Confirm that .env.example appears in the polyglot-chat file tree.

Is the environment template missing?

Confirm that the filename begins with a dot. Make sure the file sits directly inside polyglot-chat beside package.json.

Help me find my environment file.

The local .env file contains project-specific connection values. A .gitignore rule prevents that file from entering version history.

  • Use the Visual Studio Code file tree to create .gitignore inside the polyglot-chat folder.
  • Add the project's ignored paths by copying this code into .gitignore:
node_modules/
dist/
.env
.env.local

What do these ignore rules protect?

The Git rules exclude installed dependencies and generated build output. They also exclude local environment files containing project-specific values.

  • Save .gitignore.
  • Confirm that .gitignore appears beside package.json in the file tree.

Are local values still at risk?

Check that the ignored filename is exactly .env. A missing dot or extra file extension creates a different filename.

Help me verify my ignore rules.

  • Duplicate .env.example as .env using the Visual Studio Code file tree.
  • Return to the Supabase project's Connect dialog.
  • Copy the Project URL.
  • Replace your-supabase-project-url in .env with the copied Project URL.

The first line of .env should now point to your Supabase project.

  • Return to the Connect dialog.
  • Copy the publishable key.
  • Replace your-supabase-publishable-key in .env with the copied publishable key.
  • Leave the remaining three lines unchanged.
  • Save .env.

Your local environment file now contains the two Supabase connection values. The remaining entries preserve the project defaults needed by later components.

Do the environment values look wrong?

Make sure each value appears after its matching equals sign without surrounding quotation marks. Confirm that you copied both values from the same Supabase project.

Help me check my local Supabase configuration.

✔️ Awesome, I've got everything!

Good work. Your local project now has a protected configuration template and an ignored .env file containing your Supabase connection values.

ⓧ I'd like to double check the full code

These are the complete non-secret files added in this step.

The complete supabase.sql file:

create table public.profiles (
  id uuid primary key references auth.users(id) on delete cascade,
  display_name text not null check (char_length(display_name) between 2 and 40),
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

alter table public.profiles enable row level security;
revoke all on table public.profiles from anon, authenticated;
grant select, insert, update on table public.profiles to authenticated;

create policy "Users can view their own profile"
on public.profiles for select to authenticated
using ((select auth.uid()) = id);

create policy "Users can create their own profile"
on public.profiles for insert to authenticated
with check ((select auth.uid()) = id);

create policy "Users can update their own profile"
on public.profiles for update to authenticated
using ((select auth.uid()) = id)
with check ((select auth.uid()) = id);

The complete .env.example file:

VITE_SUPABASE_URL=your-supabase-project-url
VITE_SUPABASE_PUBLISHABLE_KEY=your-supabase-publishable-key
VITE_SOCKET_URL=http://localhost:3001
MONGODB_URI=your-mongodb-atlas-connection-string
PORT=3001

The complete .gitignore file:

node_modules/
dist/
.env
.env.local

Your .env file has the same five keys as .env.example. Its first two values come from the Supabase Connect dialog.

Before you check, predict whether the database controls and local files now describe the same protected profile store.

  • Return to the database table view in Supabase.
  • Select profiles from the public schema.
  • Confirm that the table lists id, display_name, created_at, and updated_at.
  • Confirm that Row Level Security is enabled.

You should see the four profile columns with Row Level Security active.

  • Return to the project's Data API settings.
  • Confirm that public appears in the exposed schemas.
  • Return to the email provider configuration.
  • Confirm that Confirm email is disabled.

You should see the profile schema exposed through the Data API with email confirmation disabled for this local demo.

  • Switch back to .env in Visual Studio Code.
  • Confirm that the first two lines contain the values copied from Supabase.
  • Confirm that the remaining three lines match .env.example.

You now have a protected public.profiles table and a local app configured to reach it. Next, you will turn the starter screen into an authentication flow that saves each user's display name.

Persist User Profiles

Your Supabase project now has a protected PostgreSQL profiles table. Row Level Security ensures each signed-in user can reach only their own row.

Authentication identifies a user. The React app still needs an application-owned display name that the future gateway can trust.

In this step, get ready to:
  • Connect React to Supabase authentication.
  • Create a profile flow backed by PostgreSQL.
  • Prove profile persistence across a browser refresh.
Build the authenticated profile flow

The app needs one source of truth for the current session. It can use that session to read or write the matching row in public.profiles.

The next four chunks form one replacement for src/App.jsx. They are separated so you can connect each piece to its responsibility.

  • In the Explorer sidebar in Visual Studio Code, select src/App.jsx.
  • Replace the existing contents with the next four code chunks in sequence.
  • Keep src/App.jsx unsaved until all four chunks are in place.
  • Paste this first chunk at the top of src/App.jsx:
import { useEffect, useState } from "react";
import { createClient } from "@supabase/supabase-js";

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY;
if (!supabaseUrl || !supabaseKey) throw new Error("Missing Vite environment variables. Check your .env file.");
const supabase = createClient(supabaseUrl, supabaseKey);

export default function App() {
  const [session, setSession] = useState(null);
  const [profile, setProfile] = useState(null);
  const [profileLoading, setProfileLoading] = useState(true);
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [displayName, setDisplayName] = useState("");
  const [notice, setNotice] = useState("");
  const userId = session?.user?.id;

  useEffect(() => {
    const { data } = supabase.auth.onAuthStateChange((_event, nextSession) => {
      setSession(nextSession);
      if (!nextSession) { setProfile(null); setProfileLoading(false); }
    });
    return () => data.subscription.unsubscribe();
  }, []);

How authentication state works

  • The environment values configure the browser client without placing project credentials directly in src/App.jsx.
  • The state values hold the active session plus the fields shown in the interface.
  • The onAuthStateChange subscription updates the app when a user signs in or signs out.
  • The cleanup function removes the subscription when the component unmounts.
  • Paste this profile-loading chunk directly below the first chunk:
  useEffect(() => {
    if (!userId) return;
    let active = true;
    setProfileLoading(true);
    supabase.from("profiles").select("display_name").eq("id", userId).maybeSingle()
      .then(({ data, error }) => {
        if (!active) return;
        if (error) setNotice(error.message);
        setProfile(data);
        setProfileLoading(false);
      });
    return () => { active = false; };
  }, [userId]);

How profile loading works

  • The effect runs when userId changes.
  • The query selects the signed-in user's display_name from the profiles table.
  • The eq filter matches the profile row to the authenticated user ID.
  • The maybeSingle call supports a new account that does not have a profile row yet.
  • Paste these authentication and profile functions directly below the profile effect:
  async function handleAuth(mode) {
    setNotice("");
    const credentials = { email, password };
    const result = mode === "signup" ? await supabase.auth.signUp(credentials) : await supabase.auth.signInWithPassword(credentials);
    if (result.error) { setNotice(result.error.message); return; }
    if (mode === "signup" && !result.data.session) setNotice("Check your email, confirm the account, then sign in.");
  }

  async function saveProfile(event) {
    event.preventDefault();
    const cleanName = displayName.trim();
    if (cleanName.length < 2 || cleanName.length > 40) { setNotice("Display name must contain 2 to 40 characters."); return; }
    const { error } = await supabase.from("profiles").upsert({ id: userId, display_name: cleanName, updated_at: new Date().toISOString() }, { onConflict: "id" });
    if (error) { setNotice(error.message); return; }
    setProfile({ display_name: cleanName });
    setNotice("");
  }

  async function signOut() { await supabase.auth.signOut({ scope: "local" }); }

How profile writes stay user-owned

  • The handleAuth function uses the same credentials for account creation or password sign-in.
  • The saveProfile function trims the display name before checking its length.
  • The upsert call creates the user's row or updates the existing row through the id conflict key.
  • The local sign-out scope ends this browser session without signing out another browser session.
  • Paste the rendered interface directly below signOut to complete src/App.jsx.
  return <main className="shell"><header><div><p className="eyebrow">PostgreSQL profiles + MongoDB chat</p><h1>Polyglot Chat</h1></div>{session && <button className="secondary" onClick={signOut}>Sign out</button>}</header>{notice && <p className="notice">{notice}</p>}{!session && <section className="card auth-card"><h2>Enter the chat</h2><input type="email" placeholder="Email" value={email} onChange={(event) => setEmail(event.target.value)} /><input type="password" placeholder="Password" value={password} onChange={(event) => setPassword(event.target.value)} /><div className="actions"><button onClick={() => handleAuth("signin")}>Sign in</button><button className="secondary" onClick={() => handleAuth("signup")}>Create account</button></div></section>}{session && profileLoading && <section className="card">Loading profile...</section>}{session && !profileLoading && !profile && <form className="card profile-card" onSubmit={saveProfile}><h2>Create your persistent profile</h2><input placeholder="Display name" minLength="2" maxLength="40" value={displayName} onChange={(event) => setDisplayName(event.target.value)} /><button type="submit">Save profile</button></form>}{session && profile && <section className="card"><h2>Profile ready</h2><p>Signed in as {profile.display_name}</p></section>}</main>;
}

What the interface shows

  • Signed-out visitors see the email and password fields.
  • A signed-in user without a profile sees the display-name form.
  • A signed-in user with a profile sees the stored display name.
  • The notice area surfaces authentication or database feedback.

Before you save, which view do you expect the app to show while no user is signed in?

  • Save src/App.jsx.
  • Return to http://localhost:5173 in the browser from earlier.

You should see the Enter the chat card with email and password fields. The page now responds to the current authentication state.

Seeing a blank page?

  • Confirm that all four chunks appear in src/App.jsx in the same order.
  • Confirm that the existing .env file still contains both Supabase values.
  • Restart the existing Vite process if the environment values were loaded after it started.

Help me diagnose my profile app.

✔️ Awesome, I've got everything!

Great. Double-check that src/App.jsx is saved before you continue.

ⓧ I'd like to double check the full code

import { useEffect, useState } from "react";
import { createClient } from "@supabase/supabase-js";

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY;
if (!supabaseUrl || !supabaseKey) throw new Error("Missing Vite environment variables. Check your .env file.");
const supabase = createClient(supabaseUrl, supabaseKey);

export default function App() {
  const [session, setSession] = useState(null);
  const [profile, setProfile] = useState(null);
  const [profileLoading, setProfileLoading] = useState(true);
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [displayName, setDisplayName] = useState("");
  const [notice, setNotice] = useState("");
  const userId = session?.user?.id;

  useEffect(() => {
    const { data } = supabase.auth.onAuthStateChange((_event, nextSession) => {
      setSession(nextSession);
      if (!nextSession) { setProfile(null); setProfileLoading(false); }
    });
    return () => data.subscription.unsubscribe();
  }, []);

  useEffect(() => {
    if (!userId) return;
    let active = true;
    setProfileLoading(true);
    supabase.from("profiles").select("display_name").eq("id", userId).maybeSingle()
      .then(({ data, error }) => {
        if (!active) return;
        if (error) setNotice(error.message);
        setProfile(data);
        setProfileLoading(false);
      });
    return () => { active = false; };
  }, [userId]);

  async function handleAuth(mode) {
    setNotice("");
    const credentials = { email, password };
    const result = mode === "signup" ? await supabase.auth.signUp(credentials) : await supabase.auth.signInWithPassword(credentials);
    if (result.error) { setNotice(result.error.message); return; }
    if (mode === "signup" && !result.data.session) setNotice("Check your email, confirm the account, then sign in.");
  }

  async function saveProfile(event) {
    event.preventDefault();
    const cleanName = displayName.trim();
    if (cleanName.length < 2 || cleanName.length > 40) { setNotice("Display name must contain 2 to 40 characters."); return; }
    const { error } = await supabase.from("profiles").upsert({ id: userId, display_name: cleanName, updated_at: new Date().toISOString() }, { onConflict: "id" });
    if (error) { setNotice(error.message); return; }
    setProfile({ display_name: cleanName });
    setNotice("");
  }

  async function signOut() { await supabase.auth.signOut({ scope: "local" }); }

  return <main className="shell"><header><div><p className="eyebrow">PostgreSQL profiles + MongoDB chat</p><h1>Polyglot Chat</h1></div>{session && <button className="secondary" onClick={signOut}>Sign out</button>}</header>{notice && <p className="notice">{notice}</p>}{!session && <section className="card auth-card"><h2>Enter the chat</h2><input type="email" placeholder="Email" value={email} onChange={(event) => setEmail(event.target.value)} /><input type="password" placeholder="Password" value={password} onChange={(event) => setPassword(event.target.value)} /><div className="actions"><button onClick={() => handleAuth("signin")}>Sign in</button><button className="secondary" onClick={() => handleAuth("signup")}>Create account</button></div></section>}{session && profileLoading && <section className="card">Loading profile...</section>}{session && !profileLoading && !profile && <form className="card profile-card" onSubmit={saveProfile}><h2>Create your persistent profile</h2><input placeholder="Display name" minLength="2" maxLength="40" value={displayName} onChange={(event) => setDisplayName(event.target.value)} /><button type="submit">Save profile</button></form>}{session && profile && <section className="card"><h2>Profile ready</h2><p>Signed in as {profile.display_name}</p></section>}</main>;
}
Style the profile screens

The authentication states now work. A small set of shared styles makes each state clear when you test the full flow.

  • In the Explorer sidebar, select src/styles.css.
  • Replace the contents of src/styles.css with this code:
:root { font-family: Inter, system-ui, sans-serif; color: #e5eef8; background: #07111f; }
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at top, #163252, #07111f 58%); }
button, input { font: inherit; }
button { border: 0; border-radius: 10px; padding: 0.75rem 1rem; color: white; background: #6d5dfc; cursor: pointer; }
button.secondary { background: #24364d; }
input { width: 100%; border: 1px solid #314760; border-radius: 10px; padding: 0.8rem; color: #e5eef8; background: #0d1b2d; }
.shell { width: min(760px, calc(100% - 2rem)); margin: 0 auto; padding: 2rem 0; }
header, .actions { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
.eyebrow { margin: 0; color: #8ca3bd; }
h1, h2 { margin-top: 0.35rem; }
.card { margin-top: 1.25rem; padding: 1.25rem; border: 1px solid #263b53; border-radius: 16px; background: rgba(10, 24, 41, 0.94); }
.auth-card, .profile-card { display: grid; gap: 0.8rem; }
.notice { padding: 0.8rem 1rem; border-radius: 10px; background: #5c2c37; }

What does this CSS do?

  • The shared button and input rules keep every authentication control consistent.
  • The card rules separate sign-in details from profile details.
  • The notice style makes authentication or profile errors visible.
  • The layout stays centered within a readable maximum width.
  • Save src/styles.css.
  • Return to the browser to inspect the updated profile interface.

You should see a dark card with two inputs plus two distinct account buttons. The project title sits above the card.

Styles not appearing?

  • Confirm that src/main.jsx still imports ./styles.css.
  • Confirm that you saved src/styles.css after replacing its contents.
  • Refresh the existing browser page if the automatic update did not appear.

Help me fix the profile styles.

✔️ Awesome, I've got everything!

Your authentication and profile screens are ready for a real persistence test.

ⓧ I'd like to double check the full code

:root { font-family: Inter, system-ui, sans-serif; color: #e5eef8; background: #07111f; }
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at top, #163252, #07111f 58%); }
button, input { font: inherit; }
button { border: 0; border-radius: 10px; padding: 0.75rem 1rem; color: white; background: #6d5dfc; cursor: pointer; }
button.secondary { background: #24364d; }
input { width: 100%; border: 1px solid #314760; border-radius: 10px; padding: 0.8rem; color: #e5eef8; background: #0d1b2d; }
.shell { width: min(760px, calc(100% - 2rem)); margin: 0 auto; padding: 2rem 0; }
header, .actions { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
.eyebrow { margin: 0; color: #8ca3bd; }
h1, h2 { margin-top: 0.35rem; }
.card { margin-top: 1.25rem; padding: 1.25rem; border: 1px solid #263b53; border-radius: 16px; background: rgba(10, 24, 41, 0.94); }
.auth-card, .profile-card { display: grid; gap: 0.8rem; }
.notice { padding: 0.8rem 1rem; border-radius: 10px; background: #5c2c37; }
Prove profile persistence

A successful save is only half the test. A refresh proves the display name came back from PostgreSQL instead of surviving only in React state.

  • Return to the Enter the chat card in your browser.
  • Enter an email address you can use for this local test in the Email field.
  • Enter a test password in the Password field.
  • Click Create account.

You should see the Create your persistent profile form. This confirms that the new account has an authenticated session without a profile row.

  • Click Sign out.
  • Re-enter the same email address in the Email field.
  • Re-enter the same password in the Password field.
  • Click Sign in.

The profile form returns after sign-in. The app has authenticated the same user before checking for their profile row.

  • Enter Ada Builder in the Display name field.
  • Click Save profile.

You should see Profile ready above Signed in as Ada Builder. Your authenticated user now owns a durable profile row.

Before you refresh, do you expect the profile form or the saved profile to appear?

  • Refresh http://localhost:5173.

You should see Signed in as Ada Builder again without entering the display name. That persistence check proves the app restored the profile from PostgreSQL.

Profile missing after refresh?

  • Confirm that the saved display name contains between 2 and 40 characters.
  • Confirm that you refreshed while signed in to the same test account.
  • Check the existing Supabase table to confirm that the row ID matches the authenticated user's ID.

Help me debug profile persistence.

Your identity and profile boundary now survives a reload. Next, you'll give the browser a local message list before a second session exposes its limits.

Expose Browser-Only Chat Limits

Your persisted Supabase profile proves that PostgreSQL can restore application-owned data after a refresh. A chat must also share messages across independent users.

Local React state can make one browser tab look convincing. This step tests that browser-owned array against a second session and a refresh.

In this step, get ready to:
  • Add temporary message state and a local send handler.
  • Send a message through a browser-only chat interface.
  • Compare the message across two sessions and a refresh.
Add browser-owned message state

A state array can hold message objects while the current page remains loaded. Each object records the message text, the signed-in display name, and its creation time.

  • In the Explorer sidebar in Visual Studio Code, return to src/App.jsx.
  • Locate the notice state inside App().
  • Add the temporary message state and local handler below the notice state by copying this code:
  // Keep temporary chat data in this browser session.
  const [messages, setMessages] = useState([]);
  const [draft, setDraft] = useState("");

  // Append one local message without a shared server.
  function sendMessage(event) {
    event.preventDefault();
    const text = draft.trim();
    if (!text) return;

    setMessages((current) => [
      ...current,
      { text, displayName: profile.display_name, createdAt: new Date().toISOString() },
    ]);
    setDraft("");
  }

What does this code do?

  • The messages array holds chat objects in the current browser's memory.
  • The draft value tracks the text inside the message field.
  • The sendMessage() handler trims the draft before appending a timestamped message object.
  • The final setDraft("") call clears the message field after a successful local send.
  • Save src/App.jsx.
  • Return to the running Vite app in your regular browser window.

You should still see the Profile ready card with your saved display name. This confirms that the new state and handler compile without disrupting profile restoration.

Seeing a blank page?

Confirm that both state declarations sit inside App(). Check that the closing brace for sendMessage() appears before signOut().

Use the browser console to locate any missing bracket or parenthesis near the pasted code.

Help me debug the temporary message state and sendMessage handler in src/App.jsx.

Render and send a local message

The local state needs a visible interface before you can test it. The composer writes to draft while the message list renders every object stored in messages.

  • In src/App.jsx, locate the authenticated profile block that begins with session && profile.
  • Replace that profile block with the local chat interface by copying this code:
      {session && profile && (
        <section className="card chat-card">
          {/* Show this browser's local messages beside the saved profile. */}
          <h2>General room</h2>
          <p>Signed in as {profile.display_name}</p>
          <ol className="messages">
            {messages.map((message) => (
              <li key={message.createdAt}>
                <strong>{message.displayName}</strong>: {message.text}
              </li>
            ))}
          </ol>
          <form className="composer" onSubmit={sendMessage}>
            <input placeholder="Write a message" maxLength="500" value={draft}
              onChange={(event) => setDraft(event.target.value)} />
            <button type="submit">Send</button>
          </form>
        </section>
      )}

How does the local interface work?

  • The list maps each local message to an item containing its display name and text.
  • The message field updates draft on every keystroke.
  • Submitting the form calls sendMessage() without reloading the page.
  • The creation time gives each temporary list item a key.
  • Save src/App.jsx.
  • Return to the running app in your regular browser window.
  • Enter My first local message in the Write a message field.
  • Click Send.

You should see your saved display name beside My first local message in the room. That first loop works: your browser can add and render its own message.

Message missing after you send it?

Confirm that the form uses onSubmit={sendMessage}. Check that the input reads from draft and calls setDraft() when its value changes.

Make sure the authenticated interface sits inside the existing return expression.

Help me find why my browser-only React message does not appear after I submit the form.

✔️ Awesome, I've got everything!

Great. Double-check that src/App.jsx is saved before you compare independent sessions.

ⓧ I'd like to double check the full code

import { useEffect, useState } from "react";
import { createClient } from "@supabase/supabase-js";

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY;
if (!supabaseUrl || !supabaseKey) throw new Error("Missing Vite environment variables. Check your .env file.");
const supabase = createClient(supabaseUrl, supabaseKey);

export default function App() {
  const [session, setSession] = useState(null);
  const [profile, setProfile] = useState(null);
  const [profileLoading, setProfileLoading] = useState(true);
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [displayName, setDisplayName] = useState("");
  const [notice, setNotice] = useState("");
  // Keep temporary chat data in this browser session.
  const [messages, setMessages] = useState([]);
  const [draft, setDraft] = useState("");
  const userId = session?.user?.id;

  useEffect(() => {
    const { data } = supabase.auth.onAuthStateChange((_event, nextSession) => {
      setSession(nextSession);
      if (!nextSession) { setProfile(null); setProfileLoading(false); }
    });
    return () => data.subscription.unsubscribe();
  }, []);

  useEffect(() => {
    if (!userId) return;
    let active = true;
    setProfileLoading(true);
    supabase.from("profiles").select("display_name").eq("id", userId).maybeSingle()
      .then(({ data, error }) => {
        if (!active) return;
        if (error) setNotice(error.message);
        setProfile(data);
        setProfileLoading(false);
      });
    return () => { active = false; };
  }, [userId]);

  async function handleAuth(mode) {
    setNotice("");
    const credentials = { email, password };
    const result = mode === "signup" ? await supabase.auth.signUp(credentials) : await supabase.auth.signInWithPassword(credentials);
    if (result.error) { setNotice(result.error.message); return; }
    if (mode === "signup" && !result.data.session) setNotice("Check your email, confirm the account, then sign in.");
  }

  async function saveProfile(event) {
    event.preventDefault();
    const cleanName = displayName.trim();
    if (cleanName.length < 2 || cleanName.length > 40) { setNotice("Display name must contain 2 to 40 characters."); return; }
    const { error } = await supabase.from("profiles").upsert({ id: userId, display_name: cleanName, updated_at: new Date().toISOString() }, { onConflict: "id" });
    if (error) { setNotice(error.message); return; }
    setProfile({ display_name: cleanName });
    setNotice("");
  }

  // Append one local message without a shared server.
  function sendMessage(event) {
    event.preventDefault();
    const text = draft.trim();
    if (!text) return;

    setMessages((current) => [
      ...current,
      { text, displayName: profile.display_name, createdAt: new Date().toISOString() },
    ]);
    setDraft("");
  }

  async function signOut() { await supabase.auth.signOut({ scope: "local" }); }

  return (
    <main className="shell">
      <header>
        <div>
          <p className="eyebrow">PostgreSQL profiles + MongoDB chat</p>
          <h1>Polyglot Chat</h1>
        </div>
        {session && <button className="secondary" onClick={signOut}>Sign out</button>}
      </header>

      {notice && <p className="notice">{notice}</p>}

      {!session && (
        <section className="card auth-card">
          <h2>Enter the chat</h2>
          <input type="email" placeholder="Email" value={email} onChange={(event) => setEmail(event.target.value)} />
          <input type="password" placeholder="Password" value={password} onChange={(event) => setPassword(event.target.value)} />
          <div className="actions">
            <button onClick={() => handleAuth("signin")}>Sign in</button>
            <button className="secondary" onClick={() => handleAuth("signup")}>Create account</button>
          </div>
        </section>
      )}

      {session && profileLoading && <section className="card">Loading profile...</section>}

      {session && !profileLoading && !profile && (
        <form className="card profile-card" onSubmit={saveProfile}>
          <h2>Create your persistent profile</h2>
          <input placeholder="Display name" minLength="2" maxLength="40" value={displayName}
            onChange={(event) => setDisplayName(event.target.value)} />
          <button type="submit">Save profile</button>
        </form>
      )}

      {session && profile && (
        <section className="card chat-card">
          {/* Show this browser's local messages beside the saved profile. */}
          <h2>General room</h2>
          <p>Signed in as {profile.display_name}</p>
          <ol className="messages">
            {messages.map((message) => (
              <li key={message.createdAt}>
                <strong>{message.displayName}</strong>: {message.text}
              </li>
            ))}
          </ol>
          <form className="composer" onSubmit={sendMessage}>
            <input placeholder="Write a message" maxLength="500" value={draft}
              onChange={(event) => setDraft(event.target.value)} />
            <button type="submit">Send</button>
          </form>
        </section>
      )}
    </main>
  );
}
Test two sessions and a refresh

Independent browser sessions keep separate authentication and memory. A second signed-in user gives your local chat its first concurrency test.

  • Keep the regular browser window open with its local message visible.
  • Open an InPrivate window using your browser's menu.
  • Enter http://localhost:5173 in the InPrivate address bar.
  • Enter a different email address in the Email field.
  • Enter a password in the Password field.
  • Click Create account.
  • Enter a distinct name in the Display name field.
  • Click Save profile.

Before you send another message, do you expect the InPrivate session to receive it immediately?

  • Switch back to the regular browser window.
  • Enter Hello from the regular window in the Write a message field.
  • Click Send.
  • Switch to the InPrivate window.

The new message remains visible only in the regular window. The second user receives nothing.

What did the second session prove?

Each browser owns a separate messages array. No shared authority receives the first user's message or sends it to the second user.

The interface can render a message locally, but it has no real-time fan-out across sessions.

A refresh tests a different property. It reveals whether the message belongs to durable application history or temporary page memory.

Before you refresh the regular window, do you expect its local message to survive like the saved profile?

  • Return to the regular browser window.
  • Refresh http://localhost:5173 using your browser's reload control.

The local messages disappear after the refresh. Your PostgreSQL-backed display name reappears because the app loads that profile from Supabase.

What did the refresh prove?

React rebuilds messages from its initial empty array when the page reloads. No database stored the temporary message objects.

You have now exposed three missing capabilities: shared authority, real-time delivery, and durable message history.

You have made the browser-only design fail in visible ways. Next, you will add MongoDB persistence and an authenticated Socket.IO gateway to close those gaps.

Add MongoDB and the Authorized Gateway

Your two authenticated browser sessions exposed the browser-only chat's limits. Each session owns a separate message list that disappears after a refresh.

A shared MongoDB Atlas database gives every session durable history. An authorized Node.js gateway verifies each Supabase user before Socket.IO distributes their messages.

In this step, get ready to:
  • Create a free MongoDB Atlas cluster with controlled database access.
  • Build a gateway that authorizes users before storing chat traffic.
  • Connect two browser sessions to shared real-time message history.
Create the MongoDB Atlas cluster

The chat needs one shared database that every authorized session reaches through the gateway. MongoDB stores each message or activity event as a document.

Choose the Free cluster

Select the Atlas Free cluster for this project. It includes 512 MB of storage without requiring a credit card.

Keep the existing Supabase organization on its Free Plan. The local test traffic in this project stays within both free resources.

  • Visit the MongoDB Atlas account registration page in your browser.
  • Register for MongoDB Atlas using your preferred available sign-in method.
  • Follow the deployment flow to create a Free cluster.
  • Wait for Atlas to show that the cluster is ready.

Your cluster now provides the shared document store. The next controls decide which database identity can connect from your computer.

  • Open Database & Network Access in MongoDB Atlas.
  • Select Database Users.
  • Click Add New Database User.
  • Select Password as the authentication method.
  • Enter a database username.
  • Create a strong database password.
  • Store the password in your password manager.
  • Click Add User.

Why use a database user?

The database user authenticates the gateway to MongoDB Atlas. Your chat users continue to authenticate through Supabase.

This separation keeps database credentials on the server while browser sessions send short-lived access tokens.

  • Select IP Access List under Database & Network Access.
  • Click Add IP Address.
  • Add your current public IP address.
  • Click Save and Close.
  • Use the cluster connection workflow to select the Node.js driver.
  • Complete the password substitution requested by Atlas.
  • Copy the resulting connection string.
  • Return to the .env file in VS Code.

The connection string contains your database password. Keep it inside the ignored .env file.

  • Replace the placeholder value beside MONGODB_URI with the connection string you copied.
  • Save .env.

Atlas connection details missing?

Confirm that the Free cluster has finished provisioning. Check that the database user exists.

Check that your current address appears in the IP Access List. A changing public IP requires you to add the new address.

Help me check my MongoDB Atlas user, IP access, and connection string.

Your cluster is ready to accept connections from the local gateway. Now the project needs the server libraries that connect authorization, storage, and real-time events.

  • Return to the PowerShell terminal running Vite.
  • Stop the development server by pressing Ctrl+C.
  • Replace the contents of package.json with the final dependency configuration below:
{
  "name": "polyglot-chat",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "engines": { "node": ">=22.12.0" },
  "scripts": {
    "dev": "vite",
    "server": "node server.mjs",
    "build": "vite build"
  },
  "dependencies": {
    "@supabase/supabase-js": "2.116.0",
    "dotenv": "17.4.2",
    "mongodb": "7.6.0",
    "react": "19.2.8",
    "react-dom": "19.2.8",
    "socket.io": "4.8.3",
    "socket.io-client": "4.8.3"
  },
  "devDependencies": { "vite": "8.2.2" }
}

What does this configuration add?

  • The server script starts the local gateway from server.mjs.
  • The MongoDB driver connects the gateway to the Atlas cluster.
  • The Socket.IO packages provide the server and browser sides of the real-time connection.
  • Dotenv loads the private .env values into the gateway process.
  • Save package.json.
  • Install the final project dependencies by running:
npm install

The terminal should finish the installation without reporting an unresolved dependency.

Dependency installation failed?

Confirm that PowerShell is inside the existing polyglot-chat folder. Check that the saved package.json contains valid JSON.

Help me diagnose my npm installation failure.

Build the authorized gateway

The gateway becomes the shared authority that the browser-only version lacked. It connects to MongoDB once before accepting chat traffic.

  • Create server.mjs inside the existing polyglot-chat folder using the VS Code Explorer.
  • Add the imports and shared service clients by pasting this first section:
import "dotenv/config";
import { createServer } from "node:http";
import { createClient } from "@supabase/supabase-js";
import { MongoClient, ServerApiVersion } from "mongodb";
import { Server } from "socket.io";

const requiredVariables = ["VITE_SUPABASE_URL", "VITE_SUPABASE_PUBLISHABLE_KEY", "MONGODB_URI"];
for (const variable of requiredVariables) {
  if (!process.env[variable]) throw new Error(`Missing environment variable: ${variable}`);
}

const port = Number(process.env.PORT || 3001);
const supabaseUrl = process.env.VITE_SUPABASE_URL;
const supabaseKey = process.env.VITE_SUPABASE_PUBLISHABLE_KEY;
const authClient = createClient(supabaseUrl, supabaseKey, {
  auth: { autoRefreshToken: false, persistSession: false },
});

const mongoClient = new MongoClient(process.env.MONGODB_URI, {
  serverApi: { version: ServerApiVersion.v1, strict: true, deprecationErrors: true },
});
await mongoClient.connect();
await mongoClient.db("admin").command({ ping: 1 });

What does this gateway setup do?

  • The environment check stops startup when a required database or Supabase value is missing.
  • The server-side Supabase client validates access tokens without persisting a browser session.
  • One reusable MongoDB client connects to Atlas.
  • The administrative ping confirms that Atlas accepted the connection.
  • Save server.mjs.
  • Check the VS Code Problems panel for unresolved imports.

You should see no unresolved import warning for the four installed packages.

Seeing an import warning?

Confirm that npm install completed inside polyglot-chat. Make sure server.mjs sits beside package.json.

Help me fix the unresolved imports in server.mjs.

The connected client now needs collection references plus a small HTTP endpoint. The health response gives you a quick signal that the gateway reached its listening state.

  • Add the database collections and HTTP server below the MongoDB ping:
const database = mongoClient.db("polyglot_chat");
const messages = database.collection("messages");
const logs = database.collection("logs");

const httpServer = createServer((request, response) => {
  if (request.url === "/health") {
    response.writeHead(200, { "Content-Type": "application/json" });
    response.end(JSON.stringify({ status: "ok" }));
    return;
  }
  response.writeHead(404);
  response.end();
});

const io = new Server(httpServer, { cors: { origin: "http://localhost:5173" } });

httpServer.listen(port, () => console.log(`Gateway listening on http://localhost:${port}`));

What does the HTTP layer do?

  • The two collection references keep messages separate from operational activity logs.
  • The /health route returns a small JSON response when the gateway is listening.
  • The Socket.IO server accepts browser connections from the local Vite origin.
  • The final listener starts the gateway on the configured port.
  • Save server.mjs.
  • Start the gateway from PowerShell by running:
npm run server
  • Open http://localhost:3001/health in your browser.

You should see {"status":"ok"}. This proves that the environment values worked and MongoDB accepted the gateway connection.

Gateway did not reach health?

Check the terminal for a message about a missing environment variable. Confirm that .env is saved beside package.json.

If the problem concerns Atlas access, recheck the database password and current IP Access List entry.

Help me troubleshoot the gateway health check.

MongoDB documents use an internal identifier and a native date. The browser needs a plain object with string values it can render consistently.

  • Stop the gateway by pressing Ctrl+C.
  • Insert this serializer above the final httpServer.listen call:
function serializeMessage(message) {
  return {
    id: message._id.toString(), roomId: message.roomId, userId: message.userId,
    displayName: message.displayName, text: message.text,
    createdAt: message.createdAt.toISOString(),
  };
}

Why serialize stored messages?

The serializer converts the MongoDB identifier and date into browser-friendly strings. It also sends only the fields required by the chat interface.

  • Save server.mjs.
  • Restart the gateway by running:
npm run server

The gateway should return to its listening state without a syntax error.

Gateway stopped after the serializer?

Check the braces around serializeMessage(). Confirm that the function appears before httpServer.listen.

Help me check the serializeMessage function.

The gateway must authorize a socket before any connection handler receives it. The middleware validates the token before loading the user's protected PostgreSQL profile.

  • Stop the gateway by pressing Ctrl+C.
  • Add the authorization middleware below serializeMessage().
io.use(async (socket, next) => {
  try {
    const token = socket.handshake.auth.token;
    if (typeof token !== "string") return next(new Error("Authentication required"));

    const { data: { user }, error: userError } = await authClient.auth.getUser(token);
    if (userError || !user) return next(new Error("Invalid Supabase access token"));

    const userClient = createClient(supabaseUrl, supabaseKey, {
      global: { headers: { Authorization: `Bearer ${token}` } },
      auth: { autoRefreshToken: false, persistSession: false },
    });
    const { data: profile, error: profileError } = await userClient
      .from("profiles").select("display_name").eq("id", user.id).maybeSingle();
    if (profileError || !profile) return next(new Error("Create a profile before opening chat"));

    socket.user = { id: user.id, displayName: profile.display_name };
    next();
  } catch {
    next(new Error("Authentication failed"));
  }
});

How does gateway authorization work?

  • The handshake token must exist before the gateway continues.
  • Supabase validates the token against its Auth server.
  • A user-scoped Supabase client reads the protected profile through its authorization header.
  • The verified user ID and display name become trusted socket context.
  • Save server.mjs.
  • Restart the gateway by running:
npm run server

The terminal should return to the gateway listening message. The health URL should still return its JSON response.

Authorization middleware caused a startup error?

Check the nested destructuring around the Supabase user response. Confirm that the middleware closes before the final listener.

Help me debug the Socket.IO authorization middleware.

An accepted socket can now receive shared history. The gateway logs the connection before loading the latest 50 messages for the general room.

  • Stop the gateway by pressing Ctrl+C.
  • Add the connection handler below the authorization middleware:
io.on("connection", async (socket) => {
  const roomId = "general";
  logs.insertOne({ type: "connection", userId: socket.user.id, roomId, createdAt: new Date() }).catch(console.error);

  try {
    const recentMessages = await messages.find({ roomId }).sort({ createdAt: -1 }).limit(50).toArray();
    socket.emit("chat:history", recentMessages.reverse().map(serializeMessage));
  } catch {
    socket.emit("chat:error", "Could not load message history.");
  }
});

What happens on connection?

  • The gateway records one connection document for the authorized user.
  • The query starts with the newest documents and limits the result to 50 messages.
  • The result reverses before emission so the browser receives chronological history.
  • Save server.mjs.
  • Restart the gateway by running:
npm run server

The gateway should listen normally. The connection handler waits for an authorized browser client.

History handler caused a syntax error?

Confirm that the handler sits after io.use() and before httpServer.listen. Check the closing braces around the history query.

Help me fix the connection history handler.

The final server behavior persists each valid message before broadcasting it. Acknowledgements tell the sender whether the write succeeded.

  • Stop the gateway by pressing Ctrl+C.
  • Insert the message and disconnection listeners inside the existing connection handler after the history block:
  socket.on("message:send", async (rawText, acknowledgement) => {
    const respond = typeof acknowledgement === "function" ? acknowledgement : () => {};
    const text = typeof rawText === "string" ? rawText.trim() : "";
    if (!text || text.length > 500) {
      respond({ ok: false, error: "Messages must contain 1 to 500 characters." });
      return;
    }

    const message = { roomId, userId: socket.user.id, displayName: socket.user.displayName, text, createdAt: new Date() };
    try {
      const result = await messages.insertOne(message);
      const savedMessage = serializeMessage({ ...message, _id: result.insertedId });
      logs.insertOne({ type: "message_sent", userId: socket.user.id, roomId,
        messageId: result.insertedId, createdAt: new Date() }).catch(console.error);
      io.emit("chat:message", savedMessage);
      respond({ ok: true });
    } catch {
      respond({ ok: false, error: "The message could not be stored." });
    }
  });

  socket.on("disconnect", (reason) => {
    logs.insertOne({ type: "disconnection", userId: socket.user.id, roomId,
      reason, createdAt: new Date() }).catch(console.error);
  });

How are messages protected and stored?

  • The handler accepts only string messages containing between 1 and 500 characters.
  • The trusted socket context supplies the user ID and display name.
  • MongoDB stores the message before Socket.IO broadcasts it.
  • Separate log documents record sent messages and disconnections.
  • Save server.mjs.
  • Restart the completed gateway by running:
npm run server
  • Refresh http://localhost:3001/health in your browser.

You should still see {"status":"ok"}. Your gateway now connects storage, authorization, history, and real-time delivery.

Gateway stopped after the message handler?

Make sure both socket listeners remain inside the outer connection handler. Check that the final httpServer.listen call remains outside it.

Help me locate the nesting error in server.mjs.

Connect the React chat client

The gateway is listening for authorized sockets. The React client now replaces its local-only append operation with token-based Socket.IO events.

  • Return to src/App.jsx in VS Code.
  • Replace the import and environment setup at the top of the file with this final section:
import { useEffect, useState } from "react";
import { createClient } from "@supabase/supabase-js";
import { io } from "socket.io-client";

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY;
const socketUrl = import.meta.env.VITE_SOCKET_URL;

if (!supabaseUrl || !supabaseKey || !socketUrl) {
  throw new Error("Missing Vite environment variables. Check your .env file.");
}

const supabase = createClient(supabaseUrl, supabaseKey);

What changes in the client setup?

The browser imports the Socket.IO client and reads the gateway URL from Vite's environment values. Startup stops if any required browser value is missing.

  • Replace the existing message and draft state lines with this connected-chat state:
  const [messages, setMessages] = useState([]);
  const [draft, setDraft] = useState("");
  const [socket, setSocket] = useState(null);
  const [connectionState, setConnectionState] = useState("offline");

What does the new state track?

The socket state holds the active gateway connection. The connection state gives the interface a visible connected, offline, or rejected status.

  • Add this socket effect below the existing profile-loading effect:
  useEffect(() => {
    if (!session?.access_token || !profile) return;
    const nextSocket = io(socketUrl, { auth: { token: session.access_token } });

    nextSocket.on("connect", () => setConnectionState("connected"));
    nextSocket.on("disconnect", () => setConnectionState("offline"));
    nextSocket.on("connect_error", (error) => {
      setConnectionState("rejected");
      setNotice(error.message);
    });
    nextSocket.on("chat:history", (history) => setMessages(history));
    nextSocket.on("chat:message", (message) => {
      setMessages((current) => current.some((item) => item.id === message.id)
        ? current
        : [...current, message]);
    });
    nextSocket.on("chat:error", (message) => setNotice(message));
    setSocket(nextSocket);

    return () => {
      nextSocket.disconnect();
      setSocket((current) => (current === nextSocket ? null : current));
    };
  }, [session?.access_token, profile, socketUrl]);

How does the socket effect work?

  • The effect waits for an access token and a persisted profile.
  • The token travels in the Socket.IO authentication payload.
  • History replaces the local array when the gateway accepts the connection.
  • New messages append only when their identifiers are not already present.
  • The cleanup function disconnects the old socket when the session changes.
  • Replace the browser-only submit handler with the authorized send function below:
  function sendMessage(event) {
    event.preventDefault();
    const text = draft.trim();
    if (!text || !socket) return;

    socket.timeout(5000).emit("message:send", text, (error, response) => {
      if (error) {
        setNotice("The gateway did not acknowledge the message.");
        return;
      }
      if (!response.ok) {
        setNotice(response.error);
        return;
      }
      setDraft("");
      setNotice("");
    });
  }

Why wait for an acknowledgement?

The client clears the draft only after the gateway confirms a successful database write. A timeout or rejected message stays visible as a notice.

  • Replace the authenticated chat section in the returned JSX with this final interface:
      {session && profile && (
        <section className="card chat-card">
          <div className="chat-heading">
            <div><h2>General room</h2><p>Signed in as {profile.display_name}</p></div>
            <span className={`status ${connectionState}`}>{connectionState}</span>
          </div>

          <ol className="messages">
            {messages.map((message) => (
              <li key={message.id}>
                <div><strong>{message.displayName}</strong>
                  <time>{new Date(message.createdAt).toLocaleTimeString()}</time></div>
                <p>{message.text}</p>
              </li>
            ))}
          </ol>

          <form className="composer" onSubmit={sendMessage}>
            <input placeholder="Write a message" maxLength="500" value={draft}
              onChange={(event) => setDraft(event.target.value)} />
            <button type="submit">Send</button>
          </form>
        </section>
      )}

What changes in the chat interface?

Each stored message uses its MongoDB-derived identifier as the React key. The status badge shows whether the authorized gateway connection is active.

  • Save src/App.jsx.
  • Replace the contents of src/styles.css with the final chat styles below:
:root { font-family: Inter, system-ui, sans-serif; color: #e5eef8; background: #07111f; }
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at top, #163252, #07111f 58%); }
button, input { font: inherit; }
button { border: 0; border-radius: 10px; padding: 0.75rem 1rem; color: white; background: #6d5dfc; cursor: pointer; }
button.secondary { background: #24364d; }
input { width: 100%; border: 1px solid #314760; border-radius: 10px; padding: 0.8rem; color: #e5eef8; background: #0d1b2d; }
.shell { width: min(760px, calc(100% - 2rem)); margin: 0 auto; padding: 2rem 0; }
header, .chat-heading, .actions, .composer { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
.eyebrow, .chat-heading p { margin: 0; color: #8ca3bd; }
h1, h2 { margin-top: 0.35rem; }
.card { margin-top: 1.25rem; padding: 1.25rem; border: 1px solid #263b53; border-radius: 16px; background: rgba(10, 24, 41, 0.94); box-shadow: 0 18px 50px rgba(0, 0, 0, 0.28); }
.auth-card, .profile-card { display: grid; gap: 0.8rem; }
.notice { padding: 0.8rem 1rem; border-radius: 10px; background: #5c2c37; }
.status { border-radius: 999px; padding: 0.35rem 0.7rem; text-transform: capitalize; background: #38475b; }
.status.connected { background: #176b51; }
.status.rejected { background: #8a3a49; }
.messages { display: grid; gap: 0.75rem; min-height: 280px; max-height: 420px; margin: 1rem 0; padding: 0; overflow-y: auto; list-style: none; }
.messages li { padding: 0.8rem; border-radius: 12px; background: #10243a; }
.messages li div { display: flex; justify-content: space-between; color: #b7c8da; }
.messages p { margin-bottom: 0; }
.messages time { font-size: 0.8rem; }
.composer input { flex: 1; }

What do these styles show?

The status badge changes color when the gateway connects or rejects a session. The message list becomes a bounded scrolling history while the composer remains easy to reach.

  • Save src/styles.css.
  • Check the completed files against the full references below.

✔️ Awesome, I've got everything!

Great. Confirm that package.json, server.mjs, src/App.jsx, and src/styles.css are saved.

ⓧ I'd like to double check the full code

{
  "name": "polyglot-chat",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "engines": { "node": ">=22.12.0" },
  "scripts": {
    "dev": "vite",
    "server": "node server.mjs",
    "build": "vite build"
  },
  "dependencies": {
    "@supabase/supabase-js": "2.116.0",
    "dotenv": "17.4.2",
    "mongodb": "7.6.0",
    "react": "19.2.8",
    "react-dom": "19.2.8",
    "socket.io": "4.8.3",
    "socket.io-client": "4.8.3"
  },
  "devDependencies": { "vite": "8.2.2" }
}
import "dotenv/config";
import { createServer } from "node:http";
import { createClient } from "@supabase/supabase-js";
import { MongoClient, ServerApiVersion } from "mongodb";
import { Server } from "socket.io";

const requiredVariables = ["VITE_SUPABASE_URL", "VITE_SUPABASE_PUBLISHABLE_KEY", "MONGODB_URI"];
for (const variable of requiredVariables) {
  if (!process.env[variable]) throw new Error(`Missing environment variable: ${variable}`);
}

const port = Number(process.env.PORT || 3001);
const supabaseUrl = process.env.VITE_SUPABASE_URL;
const supabaseKey = process.env.VITE_SUPABASE_PUBLISHABLE_KEY;
const authClient = createClient(supabaseUrl, supabaseKey, {
  auth: { autoRefreshToken: false, persistSession: false },
});

const mongoClient = new MongoClient(process.env.MONGODB_URI, {
  serverApi: { version: ServerApiVersion.v1, strict: true, deprecationErrors: true },
});
await mongoClient.connect();
await mongoClient.db("admin").command({ ping: 1 });

const database = mongoClient.db("polyglot_chat");
const messages = database.collection("messages");
const logs = database.collection("logs");

const httpServer = createServer((request, response) => {
  if (request.url === "/health") {
    response.writeHead(200, { "Content-Type": "application/json" });
    response.end(JSON.stringify({ status: "ok" }));
    return;
  }
  response.writeHead(404);
  response.end();
});

const io = new Server(httpServer, { cors: { origin: "http://localhost:5173" } });

function serializeMessage(message) {
  return {
    id: message._id.toString(), roomId: message.roomId, userId: message.userId,
    displayName: message.displayName, text: message.text,
    createdAt: message.createdAt.toISOString(),
  };
}

io.use(async (socket, next) => {
  try {
    const token = socket.handshake.auth.token;
    if (typeof token !== "string") return next(new Error("Authentication required"));

    const { data: { user }, error: userError } = await authClient.auth.getUser(token);
    if (userError || !user) return next(new Error("Invalid Supabase access token"));

    const userClient = createClient(supabaseUrl, supabaseKey, {
      global: { headers: { Authorization: `Bearer ${token}` } },
      auth: { autoRefreshToken: false, persistSession: false },
    });
    const { data: profile, error: profileError } = await userClient
      .from("profiles").select("display_name").eq("id", user.id).maybeSingle();
    if (profileError || !profile) return next(new Error("Create a profile before opening chat"));

    socket.user = { id: user.id, displayName: profile.display_name };
    next();
  } catch {
    next(new Error("Authentication failed"));
  }
});

io.on("connection", async (socket) => {
  const roomId = "general";
  logs.insertOne({ type: "connection", userId: socket.user.id, roomId, createdAt: new Date() }).catch(console.error);

  try {
    const recentMessages = await messages.find({ roomId }).sort({ createdAt: -1 }).limit(50).toArray();
    socket.emit("chat:history", recentMessages.reverse().map(serializeMessage));
  } catch {
    socket.emit("chat:error", "Could not load message history.");
  }

  socket.on("message:send", async (rawText, acknowledgement) => {
    const respond = typeof acknowledgement === "function" ? acknowledgement : () => {};
    const text = typeof rawText === "string" ? rawText.trim() : "";
    if (!text || text.length > 500) {
      respond({ ok: false, error: "Messages must contain 1 to 500 characters." });
      return;
    }

    const message = { roomId, userId: socket.user.id, displayName: socket.user.displayName, text, createdAt: new Date() };
    try {
      const result = await messages.insertOne(message);
      const savedMessage = serializeMessage({ ...message, _id: result.insertedId });
      logs.insertOne({ type: "message_sent", userId: socket.user.id, roomId,
        messageId: result.insertedId, createdAt: new Date() }).catch(console.error);
      io.emit("chat:message", savedMessage);
      respond({ ok: true });
    } catch {
      respond({ ok: false, error: "The message could not be stored." });
    }
  });

  socket.on("disconnect", (reason) => {
    logs.insertOne({ type: "disconnection", userId: socket.user.id, roomId,
      reason, createdAt: new Date() }).catch(console.error);
  });
});

httpServer.listen(port, () => console.log(`Gateway listening on http://localhost:${port}`));
import { useEffect, useState } from "react";
import { createClient } from "@supabase/supabase-js";
import { io } from "socket.io-client";

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY;
const socketUrl = import.meta.env.VITE_SOCKET_URL;

if (!supabaseUrl || !supabaseKey || !socketUrl) {
  throw new Error("Missing Vite environment variables. Check your .env file.");
}

const supabase = createClient(supabaseUrl, supabaseKey);

export default function App() {
  const [session, setSession] = useState(null);
  const [profile, setProfile] = useState(null);
  const [profileLoading, setProfileLoading] = useState(true);
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [displayName, setDisplayName] = useState("");
  const [notice, setNotice] = useState("");
  const [messages, setMessages] = useState([]);
  const [draft, setDraft] = useState("");
  const [socket, setSocket] = useState(null);
  const [connectionState, setConnectionState] = useState("offline");
  const userId = session?.user?.id;

  useEffect(() => {
    const { data } = supabase.auth.onAuthStateChange((_event, nextSession) => {
      setSession(nextSession);
      if (!nextSession) {
        setProfile(null);
        setProfileLoading(false);
      }
    });
    return () => data.subscription.unsubscribe();
  }, []);

  useEffect(() => {
    if (!userId) return;
    let active = true;
    setProfileLoading(true);

    supabase.from("profiles").select("display_name").eq("id", userId).maybeSingle()
      .then(({ data, error }) => {
        if (!active) return;
        if (error) setNotice(error.message);
        setProfile(data);
        setProfileLoading(false);
      });

    return () => { active = false; };
  }, [userId]);

  useEffect(() => {
    if (!session?.access_token || !profile) return;
    const nextSocket = io(socketUrl, { auth: { token: session.access_token } });

    nextSocket.on("connect", () => setConnectionState("connected"));
    nextSocket.on("disconnect", () => setConnectionState("offline"));
    nextSocket.on("connect_error", (error) => {
      setConnectionState("rejected");
      setNotice(error.message);
    });
    nextSocket.on("chat:history", (history) => setMessages(history));
    nextSocket.on("chat:message", (message) => {
      setMessages((current) => current.some((item) => item.id === message.id)
        ? current
        : [...current, message]);
    });
    nextSocket.on("chat:error", (message) => setNotice(message));
    setSocket(nextSocket);

    return () => {
      nextSocket.disconnect();
      setSocket((current) => (current === nextSocket ? null : current));
    };
  }, [session?.access_token, profile, socketUrl]);

  async function handleAuth(mode) {
    setNotice("");
    const credentials = { email, password };
    const result = mode === "signup"
      ? await supabase.auth.signUp(credentials)
      : await supabase.auth.signInWithPassword(credentials);

    if (result.error) {
      setNotice(result.error.message);
      return;
    }
    if (mode === "signup" && !result.data.session) {
      setNotice("Check your email, confirm the account, then sign in.");
    }
  }

  async function saveProfile(event) {
    event.preventDefault();
    const cleanName = displayName.trim();
    if (cleanName.length < 2 || cleanName.length > 40) {
      setNotice("Display name must contain 2 to 40 characters.");
      return;
    }

    const { error } = await supabase.from("profiles").upsert({
      id: userId,
      display_name: cleanName,
      updated_at: new Date().toISOString(),
    }, { onConflict: "id" });

    if (error) {
      setNotice(error.message);
      return;
    }
    setProfile({ display_name: cleanName });
    setNotice("");
  }

  function sendMessage(event) {
    event.preventDefault();
    const text = draft.trim();
    if (!text || !socket) return;

    socket.timeout(5000).emit("message:send", text, (error, response) => {
      if (error) {
        setNotice("The gateway did not acknowledge the message.");
        return;
      }
      if (!response.ok) {
        setNotice(response.error);
        return;
      }
      setDraft("");
      setNotice("");
    });
  }

  async function signOut() {
    await supabase.auth.signOut({ scope: "local" });
  }

  return (
    <main className="shell">
      <header>
        <div>
          <p className="eyebrow">PostgreSQL profiles + MongoDB chat</p>
          <h1>Polyglot Chat</h1>
        </div>
        {session && <button className="secondary" onClick={signOut}>Sign out</button>}
      </header>

      {notice && <p className="notice">{notice}</p>}

      {!session && (
        <section className="card auth-card">
          <h2>Enter the chat</h2>
          <input type="email" placeholder="Email" value={email}
            onChange={(event) => setEmail(event.target.value)} />
          <input type="password" placeholder="Password" value={password}
            onChange={(event) => setPassword(event.target.value)} />
          <div className="actions">
            <button onClick={() => handleAuth("signin")}>Sign in</button>
            <button className="secondary" onClick={() => handleAuth("signup")}>Create account</button>
          </div>
        </section>
      )}

      {session && profileLoading && <section className="card">Loading profile...</section>}

      {session && !profileLoading && !profile && (
        <form className="card profile-card" onSubmit={saveProfile}>
          <h2>Create your persistent profile</h2>
          <input placeholder="Display name" minLength="2" maxLength="40"
            value={displayName} onChange={(event) => setDisplayName(event.target.value)} />
          <button type="submit">Save profile</button>
        </form>
      )}

      {session && profile && (
        <section className="card chat-card">
          <div className="chat-heading">
            <div><h2>General room</h2><p>Signed in as {profile.display_name}</p></div>
            <span className={`status ${connectionState}`}>{connectionState}</span>
          </div>

          <ol className="messages">
            {messages.map((message) => (
              <li key={message.id}>
                <div><strong>{message.displayName}</strong>
                  <time>{new Date(message.createdAt).toLocaleTimeString()}</time></div>
                <p>{message.text}</p>
              </li>
            ))}
          </ol>

          <form className="composer" onSubmit={sendMessage}>
            <input placeholder="Write a message" maxLength="500" value={draft}
              onChange={(event) => setDraft(event.target.value)} />
            <button type="submit">Send</button>
          </form>
        </section>
      )}
    </main>
  );
}
:root { font-family: Inter, system-ui, sans-serif; color: #e5eef8; background: #07111f; }
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at top, #163252, #07111f 58%); }
button, input { font: inherit; }
button { border: 0; border-radius: 10px; padding: 0.75rem 1rem; color: white; background: #6d5dfc; cursor: pointer; }
button.secondary { background: #24364d; }
input { width: 100%; border: 1px solid #314760; border-radius: 10px; padding: 0.8rem; color: #e5eef8; background: #0d1b2d; }
.shell { width: min(760px, calc(100% - 2rem)); margin: 0 auto; padding: 2rem 0; }
header, .chat-heading, .actions, .composer { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
.eyebrow, .chat-heading p { margin: 0; color: #8ca3bd; }
h1, h2 { margin-top: 0.35rem; }
.card { margin-top: 1.25rem; padding: 1.25rem; border: 1px solid #263b53; border-radius: 16px; background: rgba(10, 24, 41, 0.94); box-shadow: 0 18px 50px rgba(0, 0, 0, 0.28); }
.auth-card, .profile-card { display: grid; gap: 0.8rem; }
.notice { padding: 0.8rem 1rem; border-radius: 10px; background: #5c2c37; }
.status { border-radius: 999px; padding: 0.35rem 0.7rem; text-transform: capitalize; background: #38475b; }
.status.connected { background: #176b51; }
.status.rejected { background: #8a3a49; }
.messages { display: grid; gap: 0.75rem; min-height: 280px; max-height: 420px; margin: 1rem 0; padding: 0; overflow-y: auto; list-style: none; }
.messages li { padding: 0.8rem; border-radius: 12px; background: #10243a; }
.messages li div { display: flex; justify-content: space-between; color: #b7c8da; }
.messages p { margin-bottom: 0; }
.messages time { font-size: 0.8rem; }
.composer input { flex: 1; }

The final check proves all three missing capabilities at once. A second session receives the message immediately while MongoDB preserves it for future connections.

  • Keep the gateway running in its current PowerShell terminal.
  • Create another PowerShell terminal in VS Code.
  • Start the Vite app in the new terminal by running:
npm run dev

Before you test the chat, do you expect one sent message to appear in both authenticated sessions this time?

  • Open http://localhost:5173 in your regular browser window.
  • Open the same URL in an InPrivate window.
  • Sign in as a different test user in each window.
  • Confirm that both status badges show connected.
  • Send a message from the regular window.

You should see the message appear immediately in both windows. The display name comes from the sender's protected PostgreSQL profile.

  • Reply from the InPrivate window.
  • Refresh the regular window.
  • Refresh the InPrivate window.

Both windows should reload the shared conversation. The gateway requests the latest 50 stored messages when each authorized socket reconnects.

  • Return to your MongoDB Atlas cluster.
  • Open the cluster's data browser to confirm that the polyglot_chat database exists.
  • Inspect the messages collection to confirm that your chat text and display names were stored.
  • Inspect the logs collection to confirm that connection and sent-message activity was recorded.

That completes the architecture. Your two browser sessions now share an authorized gateway with durable message history and activity logs.

Messages not reaching both windows?

Check that both PowerShell terminals are still running. One should host the gateway while the other hosts Vite.

Confirm that each user has a saved profile. A user without a profile is rejected before the connection handler runs.

Help me debug my two-session real-time chat test.

Secret mission

Build a Log Audit Script

Turn the latest MongoDB activity documents into a command-line audit report. You will count connection, disconnection, and sent-message events without changing the chat app.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep your resources running, pause the local processes, or delete everything. Your Supabase Free project and MongoDB Atlas Free cluster have no ongoing project charge.

Resources you used:

  • Supabase project with Supabase Auth users and the protected PostgreSQL table public.profiles.
  • MongoDB Atlas Free cluster containing polyglot_chat.messages and polyglot_chat.logs.
  • Local polyglot-chat folder containing the chat app, gateway, audit.mjs, and .env.
  • Running Vite app and authenticated Socket.IO gateway in separate PowerShell terminals.

Keep everything running

No action needed. Choose this if you want to keep testing the chat or generating new audit reports.

  • Leave the PowerShell terminal running Vite open while you continue using the app.
  • Leave the PowerShell terminal running the Socket.IO gateway open while you continue chatting.
  • Keep the Supabase project available for authentication and persistent profiles.
  • Keep the MongoDB Atlas Free cluster available for messages and activity logs.
  • Keep the polyglot-chat folder on your computer.

Pause - I'll come back to this later

Shut down the local processes to free up memory. Your files and cloud data remain available for later.

  • Close the PowerShell terminal running Vite.
  • Close the PowerShell terminal running the Socket.IO gateway.
  • Keep the Supabase project available for your Auth users and profiles.
  • Keep the MongoDB Atlas Free cluster available for your messages and logs.
  • Keep the polyglot-chat folder on your computer.

Your Supabase Free project may pause after one week of inactivity. MongoDB Atlas Free clusters never expire.

Delete - I don't want to use this again

Remove all project resources when you want a clean slate. Deleting the cloud projects is permanent, so take a moment to make sure you no longer need the stored demo data.

  • Close the PowerShell terminal running Vite.
  • Close the PowerShell terminal running the Socket.IO gateway.
  • Return to the Supabase dashboard.
  • Select the project that contains public.profiles.
  • Open that project's settings area.
  • Use the project deletion control to delete the Supabase project.
  • Return to MongoDB Atlas.
  • Select the Atlas project that contains your Free cluster.
  • Open that Atlas project's settings area.
  • Use the project deletion control to delete the Atlas project.
  • Delete the polyglot-chat folder through your file browser.

The deleted local folder includes .env. Its removal clears your local copy of the MongoDB connection string.

Nice Work!

Nice Work!

Outstanding work! Your Polyglot Chat now delivers authorized messages between independent browser sessions in real time.

What you learned:

  • Built email and password authentication with Supabase. Protected each user-owned PostgreSQL profile through Row Level Security.
  • Built an authenticated Socket.IO gateway that rejects missing or invalid Supabase access tokens. Delivered messages immediately between two independent browser sessions.
  • Applied polyglot persistence across two data stores. Reloaded durable chat history from MongoDB Atlas after a browser refresh. Recorded gateway activity as persistent log documents.
  • Secret Mission: Added a command-line audit report over the latest MongoDB log documents. Summarized event counts with console.table().

Ready to quiz yourself?