iTranslated by AI
Handling Supabase Database Changes with Next.js and Web Workers
Overview
In this article, I tried a configuration with Next.js + Web Worker where the Worker side receives Supabase Database Changes and reflects them on the page side.
Web Worker
Web Workers are a mechanism that allows script processing in a web application to be moved to a thread separate from the main thread, enabling execution in the background.
Also, there are the following restrictions:
- You cannot directly manipulate the DOM from within a worker.
- Some default methods and properties of the
windowobject cannot be used.
Supabase Database Changes
Supabase Database Changes leverage PostgreSQL's features to detect database changes (INSERT, UPDATE, DELETE) in real-time.
Project Creation
I will start by creating a project for the sample. This time, I used the following repository template to create the project. It is also fine to create a Next.js project from scratch.
I named the project nextjs_web_worker_example.
The versions are as follows:
"dependencies": {
"@chakra-ui/icons": "^2.1.1",
"@chakra-ui/react": "^2.8.2",
"@emotion/react": "^11.13.5",
"@emotion/styled": "^11.13.0",
"framer-motion": "^11.11.17",
"next": "^14.0.2",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/node": "22.7.4",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"eslint": "8.57.0",
"eslint-config-next": "15.0.3",
"prettier": "^3.3.3",
"typescript": "^5.1.6"
}
Simple Sample Implementation
First, I will create a simple Worker that returns the input text after a few seconds.
Create libs/echo_worker.ts with the following content:
const worker = self as unknown as Worker;
// Worker that returns a message after 2 seconds
worker.addEventListener('message', (e: MessageEvent<string>) => {
console.log('worker received:', e.data);
setTimeout(() => {
worker.postMessage(e.data);
}, 2000);
});
Next, modify app/(examples)/basic/page.tsx as follows:
'use client';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
Button,
Heading,
Input,
Text,
VStack,
} from '@chakra-ui/react';
import { useState } from 'react';
const BasicPage = () => {
const [text, setText] = useState<string>();
const [value, setValue] = useState<string>();
return (
<VStack minH="100vh" p={8} alignItems="flex-start">
<Breadcrumb fontSize="xl">
<BreadcrumbItem>
<BreadcrumbLink href="/" color="blue.400">
Home
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbItem isCurrentPage>
<BreadcrumbLink>Basic</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
<Heading as="h2" size="xl">
Basic
</Heading>
<Text>Enter text and execute; the Worker will return the same content after 2 seconds.</Text>
<Input onChange={(e) => setText(e.target.value)} />
<Button
mt={4}
onClick={(e) => {
e.preventDefault();
const worker = new Worker(
new URL('../../../libs/echo_worker', import.meta.url),
);
worker.addEventListener('message', ({ data }) => {
setValue(data);
});
console.log('main thread:', text);
worker.postMessage(text);
}}
>
Execute
</Button>
<Text>{value}</Text>
</VStack>
);
};
export default BasicPage;
When you try it out, you can see that there is a response after 2 seconds, as shown below 👇.

SharedWorker
Next, as the name suggests, I would like to try SharedWorker, which allows shared access across multiple windows, tabs, etc.
Create libs/echo_shared_worker.ts with the following content:
const sharedWorker = self as unknown as SharedWorker;
let globalNumber = 0;
const ports: MessagePort[] = [];
sharedWorker.addEventListener('connect', (e) => {
const port = (e as MessageEvent).ports[0];
ports.push(port);
port.start();
});
// Increment and return a global value every second
setInterval(() => {
globalNumber++;
ports.forEach((port) => port.postMessage(globalNumber));
}, 1000);
Next, create app/(examples)/shared-worker-basic/page.tsx with the following content:
'use client';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
Heading,
Text,
VStack,
} from '@chakra-ui/react';
import { useEffect, useState } from 'react';
const SharedWorkerBasicPage = () => {
const [value, setValue] = useState<number>(0);
useEffect(() => {
const worker = new SharedWorker(
new URL('../../../libs/echo_shared_worker', import.meta.url),
);
worker.port.addEventListener('message', ({ data }) => {
setValue(data);
});
worker.port.start();
return () => {
worker.port.close();
};
}, []);
return (
<VStack minH="100vh" p={8} alignItems="flex-start">
<Breadcrumb fontSize="xl">
<BreadcrumbItem>
<BreadcrumbLink href="/" color="blue.400">
Home
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbItem isCurrentPage>
<BreadcrumbLink>SharedWorker Basic</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
<Heading as="h2" size="xl">
SharedWorker Basic
</Heading>
<Text>
When you open the page, the SharedWorker increments a global value every second and returns it.
</Text>
<Text>{value}</Text>
</VStack>
);
};
export default SharedWorkerBasicPage;
When you access http://localhost:3000/shared-worker-basic, you can see that the same content is displayed across multiple tabs as shown below.

If you want to view the logs inside the SharedWorker, you can do so by accessing chrome://inspect/#workers and selecting inspect for the relevant worker.

Local Supabase Environment Setup
I will set up the environment using the Supabase CLI.
$ npx supabase init
Generate VS Code settings for Deno? [y/N] N
Generate IntelliJ Settings for Deno? [y/N] N
Create a table to be monitored for Database Changes.
npx supabase migration new create_books_table
Executing the above will create a SQL file under supabase/migrations/, so I will write the SQL to create the books table there.
create table
books (
id bigint primary key generated always as identity,
name text,
author text,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
Once everything is ready, start the local Supabase environment with npx supabase start.
When it starts, the Studio URL will be displayed. Accessing it will open the dashboard.
Select "Table Editor" from the side menu of the dashboard.

If successful, the "books" table created earlier should be displayed.

Select the "books" table, click "Realtime off" in the upper right, and select "Enable realtime" to monitor Database Changes.


It's OK if it changes from "Realtime off" to "Realtime on". This completes the minimum setup for now.
I will also register one record as sample data. Register the data from "Insert" > "Insert row" in the upper left.


Simple Web Worker Implementation for Receiving Database Changes
Next, I will install the necessary packages on the Next.js side.
yarn add @supabase/supabase-js
Next, create libs/supabase.ts with the following content:
import { createClient } from '@supabase/supabase-js';
export const supabase = createClient(
'http://localhost:54321',
'eyJh...',
);
Note: Set the anon key displayed during npx supabase start as the second argument.
Next, I will proceed with the Worker implementation. Create libs/db_changes_worker.ts with the following content:
import { RealtimeChannel } from '@supabase/supabase-js';
import { supabase } from './supabase';
const worker = self as unknown as Worker;
type ReciveMessageType = {
type: 'start' | 'stop';
};
let channel: RealtimeChannel | null = null;
worker.addEventListener('message', (e) => {
const message = e.data as ReciveMessageType;
switch (message.type) {
case 'start':
channel = supabase
.channel('worker-books-db-changes')
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'books',
},
(payload) => self.postMessage(payload.new),
)
.subscribe();
break;
case 'stop':
channel?.unsubscribe();
break;
default:
break;
}
});
For the page side implementation, create app/(examples)/db-change-worker/page.tsx as follows:
'use client';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
Button,
Heading,
Text,
VStack,
} from '@chakra-ui/react';
import { useEffect, useRef, useState } from 'react';
const DbChangeWorkerPage = () => {
const [value, setValue] = useState<object>();
const workerRef = useRef<Worker | null>(null);
useEffect(() => {
const worker = new Worker(
new URL('../../../libs/db_changes_worker', import.meta.url),
);
worker.onmessage = (event) => {
const data = event.data;
setValue(data);
};
workerRef.current = worker;
return () => {
worker.terminate();
};
}, []);
return (
<VStack minH="100vh" p={8} alignItems="flex-start">
<Breadcrumb fontSize="xl">
<BreadcrumbItem>
<BreadcrumbLink href="/" color="blue.400">
Home
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbItem isCurrentPage>
<BreadcrumbLink>Database Changes Worker</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
<Heading as="h2" size="xl">
Database Changes Worker
</Heading>
<Text>Clicking "Start" (開始) will monitor updates to the books table.</Text>
<Button
mt={4}
onClick={(e) => workerRef.current?.postMessage({ type: 'start' })}
>
開始
</Button>
<Button
mt={4}
onClick={(e) => workerRef.current?.postMessage({ type: 'stop' })}
>
停止
</Button>
<Text>{JSON.stringify(value)}</Text>
</VStack>
);
};
export default DbChangeWorkerPage;
When you actually run it, as shown below 👇, the updated content is displayed when an update occurs.

Also, clicking "Stop" (停止) will stop the monitoring.

SharedWorker Implementation for Receiving Database Changes
Finally, I would like to create a sample for receiving Database Changes with a SharedWorker.
Create libs/db_changes_shared_worker.ts with the following content:
import { RealtimeChannel } from '@supabase/supabase-js';
import type { ReciveMessageType } from './db_changes_worker';
import { supabase } from './supabase';
const dbChabgesSharedWorker = self as unknown as SharedWorker;
const workerPorts: MessagePort[] = [];
let channel: RealtimeChannel | null = null;
dbChabgesSharedWorker.addEventListener('connect', (e) => {
const port = (e as MessageEvent).ports[0];
port.onmessage = (event: MessageEvent) => {
const message = event.data as ReciveMessageType;
switch (message.type) {
case 'start':
channel = supabase
.channel('worker-books-db-changes')
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'books',
},
(payload) =>
workerPorts.forEach((port) => port.postMessage(payload.new)),
)
.subscribe();
break;
case 'stop':
channel?.unsubscribe();
break;
default:
break;
}
};
workerPorts.push(port);
port.start();
});
Next, create app/(examples)/db-change-shared-worker/page.tsx with the following content:
'use client';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
Button,
Heading,
Text,
VStack,
} from '@chakra-ui/react';
import { useEffect, useRef, useState } from 'react';
const DbChangeSharedWorkerPage = () => {
const [value, setValue] = useState<object>();
const workerRef = useRef<SharedWorker | null>(null);
useEffect(() => {
const worker = new SharedWorker(
new URL('../../../libs/db_changes_shared_worker', import.meta.url),
);
worker.port.onmessage = (event) => {
const data = event.data;
setValue(data);
};
workerRef.current = worker;
return () => {
worker.port.close();
};
}, []);
return (
<VStack minH="100vh" p={8} alignItems="flex-start">
<Breadcrumb fontSize="xl">
<BreadcrumbItem>
<BreadcrumbLink href="/" color="blue.400">
Home
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbItem isCurrentPage>
<BreadcrumbLink>Database Changes Shared Worker</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
<Heading as="h2" size="xl">
Database Changes Shared Worker
</Heading>
<Text>
Clicking "Start" (開始) will cause the SharedWorker to monitor updates to the books table.
</Text>
<Button
mt={4}
onClick={(e) => workerRef.current?.port.postMessage({ type: 'start' })}
>
開始
</Button>
<Button
mt={4}
onClick={(e) => workerRef.current?.port.postMessage({ type: 'stop' })}
>
停止
</Button>
<Text>{JSON.stringify(value)}</Text>
</VStack>
);
};
export default DbChangeSharedWorkerPage;
Once you have implemented this, you can open multiple tabs, and clicking "Start" (開始) in any one of the tabs will allow all tabs to receive updates.

Reference URLs
Discussion