iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
📡

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

https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API

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:

Supabase Database Changes

https://supabase.com/docs/guides/realtime/subscribing-to-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.

https://github.com/Slowhand0309/nextjs-devcontainer-boilerplate

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 👇.

image1.gif

SharedWorker

Next, as the name suggests, I would like to try SharedWorker, which allows shared access across multiple windows, tabs, etc.

https://developer.mozilla.org/en-US/docs/Web/API/SharedWorker

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.

image2.gif

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.

image3.png

Local Supabase Environment Setup

https://supabase.com/docs/guides/local-development

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.

image4.png

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

image5.png

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

image6.png

image7.png

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.

image8.png

image9.png

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.

image10.gif

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

image11.gif

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.

image12.gif

Reference URLs

https://medium.com/@ngrato/harnessing-the-power-of-web-workers-with-next-js-350901a99a10

https://zenn.dev/sora_kumo/articles/65420761a0bec2

Discussion