Using Astro and Server-Sent Events (SSE) to Build Realtime In-App Notifications
Tags: astro, javascript, webdev, tutorial
Published: 2024-04-03 07:51:02

Server-sent events (SSE) are a simple way to create real-time updates in web applications over HTTP connections. They are particularly useful for scenarios where you want to deliver real-time updates or notifications to users without refreshing the page.

In this tutorial, you'll create and deploy an in-app notifications application written with Astro using server-sent events (SSE) on Koyeb. You'll learn how to implement in-app notifications using SSE, allowing your application to display real-time updates to users as they interact with it.

By the end of this tutorial, you'll have a functioning Astro application that displays three in-app notification toasts with the message "Deploy To Koyeb" as users load the application in their browsers.

You can deploy the in-app notifications Astro application as configured in this guide using the [Deploy to Koyeb](https://www.koyeb.com/docs/build-and-deploy/deploy-to-koyeb-button) button below:

[![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?name=example-in-app-notifications-sse&type=git&repository=koyeb%2Fexample-in-app-notifications-sse&branch=main)

**Note:** You can take a look at the application we will be building in this tutorial in the [project GitHub repository](https://github.com/koyeb/example-in-app-notifications-sse).

## Requirements

To successfully follow this tutorial, you will need the following:

- Node.js and `npm` installed. The demo app in this tutorial uses version 18 of Node.js.
- Git installed on your local computer.
- A [Koyeb](https://app.koyeb.com/) account to deploy the application.

## Steps

To complete this guide and deploy the in-app notifications Astro application, you'll need to follow these steps:

- [Create a new Astro application](#create-a-new-astro-application)
- [Integrate React in your Astro project](#integrate-react-in-your-astro-project)
- [Enabling server-side rendering in Astro](#enabling-server-side-rendering-in-astro)
- [Create a server-sent events API in Astro](#create-a-server-sent-events-api-in-astro)
- [Configure the Astro frontend to listen to server-sent events](#configure-the-astro-frontend-to-listen-to-server-sent-events)
- [Deploy the Astro application to Koyeb](#deploy-the-astro-application-to-koyeb)

## Create a new Astro application

Let's get started by creating a new Astro project. Open your terminal and run the following command:

```bash
npm create astro@latest in-app-notifications
```

`npm create astro` is the recommended way to scaffold an Astro project quickly.

When prompted, choose:
Make the following selections when prompted:

- How would you like to start your new project? `Empty`
- Do you plan to write TypeScript? `Yes`
- How strict should TypeScript be? `Strict`
- Install dependencies? `Yes`
- Initialize a new git repository? `Yes`

Once the initialization completes, you can move into the project directory and start the app:

```bash
cd in-app-notifications
npm run dev
```

The app should be running on `http://localhost:4321`. Press <kbd>CTRL</kbd>-<kbd>C</kbd> to stop the development server so that we can integrate React into the application.

## Integrate React in your Astro project

We will use React to create the user interface for in-app notifications via toasts.

To add React, open your terminal and run the following command:

```bash
npx astro add react
```

`npx` allows us to execute `npm` package binaries (`astro` in our case) without having to install the binaries globally.

When prompted, choose:

- `Yes` to install the React dependencies
- `Yes` to make changes to Astro configuration file
- `Yes` to make changes to `tsconfig.json` file

React should now be integrated with your Astro project. The process installed the following dependencies:

- `react`: The package containing the functionality necessary to define React components
- `react-dom`: The package that serves as an entry point to the DOM and server renderers for React
- `@astrojs/react`: The integration that enables hydration of React components in Astro
- `@types/react`: Type definitions for `react` package
- `@types/react-dom`: Type definitions for `react-dom` package

## Enabling server-side rendering in Astro

To use [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events) to send notifications in real-time, you need server-side code. Enable server-side rendering in your Astro project by executing the following command in your terminal:

```bash
npx astro add node
```

When prompted, choose:

- `Yes` to install the Node dependencies
- `Yes` to make changes to Astro configuration file

Your Astro project should now be integrated with a Node.js server. The integration process installed the following dependency:

- `@astrojs/node`: The adapter that allows you to server-side render your Astro application

The port by default, to which Koyeb will forward incoming requests is 8000. To respond to incoming requests to 8000 port from your Astro application, make the following additions in `astro.config.mjs` file:

{/* prettier-ignore-start */}

```javascript
import node from '@astrojs/node'
import react from '@astrojs/react'
import { defineConfig } from 'astro/config'

const port = parseInt(process.env.PORT) || 8000 // [!code ++]

// https://astro.build/config
export default defineConfig({
  server: { // [!code ++]
    port: port, // [!code ++]
    host: '0.0.0.0', // [!code ++]
  }, // [!code ++]
  integrations: [react()],
  output: 'server',
  adapter: node({
    mode: 'standalone',
  }),
})
```

{/* prettier-ignore-end */}

To run the build of your application in production mode, make the following changes in `package.json` file:

{/* prettier-ignore-start */}

```json
{
  "name": "in-app-notifications",
  "type": "module",
  "version": "0.0.1",
  "scripts": {
    "dev": "astro dev",
    "start": "astro dev", // [!code --]
    "start": "node dist/server/entry.mjs", // [!code ++]
    "build": "astro check && astro build",
    "preview": "astro preview",
    "astro": "astro"
  },
  "dependencies": {
    "@astrojs/check": "^0.5.6",
    "@astrojs/node": "^8.2.3",
    "@astrojs/react": "^3.0.10",
    "@types/react": "^18.2.64",
    "@types/react-dom": "^18.2.21",
    "astro": "^4.4.15",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "typescript": "^5.4.2"
  }
}
```

{/* prettier-ignore-end */}

Once that's done, execute the following command in your terminal to see the current Astro application in action on [localhost:8000](http://localhost:8000):

```bash
npm run build && npm run start
```

Once the application is built, you can also set the `PORT` environment variable to run the application on a different port:

```bash
PORT=5555 npm run start
```

Press <kbd>CTRL</kbd>-<kbd>C</kbd> to stop the server. Next we will move on to create a server-sent events API in your Astro application.

## Create a server-sent events API in Astro

[Server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) provide a method for delivering real-time updates without the overhead of repeated client requests. It's different from the conventional request-response process as SSE facilitates a streamlined flow of data from server to client through a persistent connection.

Creating [API routes](https://docs.astro.build/en/guides/endpoints/#server-endpoints-api-routes) in Astro is as simple as creating a Typescript file and exporting an HTTP method function (such as `GET`). To start creating your own server-sent events API endpoint to send notifications, create an `sse.ts` file in the `src/pages` directory, and add the snippet below in it:

```typescript
// File: src/pages/sse.ts

export async function GET() {
  const encoder = new TextEncoder()
  const message = 'Deploy To Koyeb'
  // Create a streaming response
  const customReadable = new ReadableStream({
    async start(controller) {
      // Emit three notifications after 1 second each
      controller.enqueue(encoder.encode(`data: ${message}\n\n`))
      await new Promise((r) => setTimeout(r, 1000))
      controller.enqueue(encoder.encode(`data: ${message}\n\n`))
      await new Promise((r) => setTimeout(r, 1000))
      controller.enqueue(encoder.encode(`data: ${message}\n\n`))
      // Close the stream after sending the three notifications
      controller.close()
    },
  })
  // Return the stream response and keep the connection alive
  return new Response(customReadable, {
    // Set the headers for Server-Sent Events (SSE)
    headers: {
      Connection: 'keep-alive',
      'Content-Encoding': 'none',
      'Cache-Control': 'no-cache, no-transform',
      'Content-Type': 'text/event-stream; charset=utf-8',
    },
  })
}
```

The code begins with exporting a `GET` function handler. Inside the function, we instantiate a [`TextEncoder`](https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder) object that will create UTF-8 bytes from a given text-based notification.

Afterwards, it creates a [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) instance that will be responsible for queueing notifications for the endpoint in the form of byte data. Three notifications events are sent through the stream via the asynchronous `start` callback function, with a delay of 1 second between each event. Finally, the function returns a standard web [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) made up of the `ReadableStream` instance created above, along with the following headers:

- `Connection: keep-alive`: To persistent the connection between client and server.
- `Content-Encoding: none`: To indicate that the content is not encoded or transformed.
- `Cache-Control: no-cache, no-transform`: To prevent caching and content transformation.
- `Content-type: text/event-stream; charset=utf-8`: To set the MIME type for server-sent events with UTF-8 encoding.

## Configure the Astro frontend to listen to Server-Sent Events

To get started creating a React component that listens to server-sent events, create a `components` directory in the `src` directory by typing:

```bash
mkdir src/components
```

Inside this `components` directory, create a `Notifications.jsx` file with the following code:

```javascript
// File: src/components/Notifications.jsx
import { useEffect } from 'react'

export default function () {
  useEffect(() => {
    // Initiate the first call to connect to SSE API
    const eventSource = new EventSource('/sse')

    const messageListener = (event) => {
      const tmp = event.data
      // Do something with the obtained message
      console.log(tmp)
    }

    const closeListener = () => eventSource.close()

    // Close the connection to SSE API if any error
    eventSource.addEventListener('error', closeListener)

    // Listen to events received via the SSE API
    eventSource.addEventListener('message', messageListener)

    // As the component unmounts, close listeners to SSE API
    return () => {
      eventSource.removeEventListener('error', closeListener)
      eventSource.removeEventListener('message', messageListener)
      eventSource.close()
    }
  }, [])

  return <></>
}
```

The code above begins with exporting a React component. Inside this component, no user interface is returned as it's meant only to listen to server-sent events and create toast notifications. The `useEffect` hook is used with no reactive dependencies to only trigger this once as the component gets mounted on the DOM. An [`EventSource`](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) instance is created to establish a connection to the Server-Sent Events Endpoint. Then, event listeners are created for the following events:

- `message`: fired when data is received through an event source.
- `error`: fired when a connection with an event source is closed or fails to open.

Finally, the code includes a cleanup function to remove the event listeners and close the connection to the `EventSource` instance when the component is unmounted, preventing potential memory leaks.

To use this React component on the home page of your application, make the following changes in `src/pages/index.astro` file:

{/* prettier-ignore-start */}

```html
---
import Notifications from '../components/Notifications' <!-- [!code ++] -->
---

<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="viewport" content="width=device-width" />
    <meta name="generator" content="{Astro.generator}" />
    <title>Astro</title>
  </head>
  <body>
    <h1>Astro</h1>
    <Notifications client:load /> <!-- [!code ++] -->
  </body>
</html>
```

{/* prettier-ignore-end */}

The changes above import the Notifications React component and use Astro's [`client:load` directive](https://docs.astro.build/en/reference/directives-reference/#clientload) to make sure that this React component is loaded and hydrated immediately on the page.

To display toast notifications whenever data is received through an event source, we need to install [`sonner`](https://sonner.emilkowal.ski), an opinionated toast component for React, by executing the following command:

```bash
npm install sonner
```

To render toast notifications in a React component using `sonner`, make the following changes in the `Notifications` component:

{/* prettier-ignore-start */}

```javascript
// File: src/components/Notifications.jsx
import { useEffect } from 'react'
import { Toaster, toast } from 'sonner' // [!code ++]

export default function () {
  useEffect(() => {
    // Initiate the first call to connect to SSE API
    const eventSource = new EventSource('/sse')

    const messageListener = (event) => {
      const tmp = event.data
      console.log(tmp) // [!code --]
      // Display toast notifications
      toast(tmp) // [!code ++]
    }

    const closeListener = () => eventSource.close()

    // Close the connection to SSE API if any error
    eventSource.addEventListener('error', closeListener)

    // Listen to events received via the SSE API
    eventSource.addEventListener('message', messageListener)

    // As the component unmounts, close listeners to SSE API
    return () => {
      eventSource.removeEventListener('error', closeListener)
      eventSource.removeEventListener('message', messageListener)
      eventSource.close()
    }
  }, [])

  return <></> // [!code --]
  return ( // [!code ++]
    <> // [!code ++]
      <Toaster /> // [!code ++]
    </> // [!code ++]
  ) // [!code ++]
}
```

{/* prettier-ignore-end */}

The changes above import the `toast` utility function which renders a toast in the user interface, and the `<Toaster />` component that contains relevant styles and props for the toast's React component. Afterwards, it invokes the `toast` function to render a toast whenever data is received through an event source. The changes end with returning the Toaster component as the only element to be rendered in the Notifications component.

You've successfully created in-app notifications in an Astro application that uses server-sent events to trigger toast notifications for users. In the upcoming section, you will proceed to deploy the application online on the Koyeb platform.

## Deploy the Astro application to Koyeb

Koyeb is a developer-friendly serverless platform to deploy apps globally. No-ops, servers, or infrastructure management are required. It supports different tech stacks such as Rust, Golang, Python, PHP, Node.js, Ruby, and Docker.

With the app now complete, the final step is to deploy it online on Koyeb.

We will use git-driven deployment to deploy on Koyeb. To do this, we need to create a new GitHub repository from the [GitHub web interface](https://github.com/new) or by using the GitHub CLI with the following command:

```bash
gh repo create <YOUR_GITHUB_REPOSITORY> --private
```

Astro initialized a git repository as well as a compatible `.gitignore` file when we created our application. Add the new remote on GitHub to the repository and ensure that we're committing to the expected branch by typing:

```bash
git remote add origin git@github.com:<YOUR_GITHUB_USERNAME>/<YOUR_GITHUB_REPOSITORY>.git
git branch -M main
```

Add all the files in your project directory to the git repository and push them to GitHub:

```bash
git add :/
git commit -m "Initial commit"
git push -u origin main
```

To deploy the code on the GitHub repository, visit the [Koyeb control panel](https://app.koyeb.com/) and while on the **Overview** tab, click **Create Web Service** to start the deployment process. On the App deployment page:

1. Select the **GitHub** deployment option.
2. Choose the GitHub repository for your code from the repository drop-down menu. Alternatively, deploy from the [example repository associated with this tutorial](https://github.com/koyeb/example-in-app-notifications-sse) by entering `https://github.com/koyeb/example-in-app-notifications-sse` in the public repository field.
3. Select the branch you intend to deploy, for example `main`.
4. Enter a name for the application or use the provided one.
5. Finally, initiate the deployment process by clicking **Deploy**.

During the deployment on Koyeb, the process identifies the `build` and `start` scripts outlined in the `package.json` file, using them to build and launch the application. The deployment progress can be tracked through the logs presented. Upon the completion of deployment and the successful execution of vital health checks, your application will be operational.

If you would like to look at the code for the demo application, you can find it [here](https://github.com/koyeb/example-in-app-notifications-sse).

## Conclusion

In this tutorial, you created an in-app notifications application with Astro using server-sent events. You've also gained some experience with real-time communication between servers and clients, and understanding how it can help you build dynamic web applications.

Given that the application was deployed using the Git deployment option, subsequent code push to the deployed branch will automatically initiate a new build for your application. Changes to your application will become live once the deployment is successful. In the event of a failed deployment, Koyeb retains the last operational production deployment, ensuring the uninterrupted operation of your application.