What to Watch? Showcase for the Bun, Astro, React & Nano Stores Stack‍

 Reynaldo Rodríguez
Reynaldo Rodríguez
May 27, 2024
React
Web Development
Frameworks
What to Watch? Showcase for the Bun, Astro, React & Nano Stores Stack‍

In web development, the quest for the perfect stack that balances performance, scalability, and developer experience is never-ending. Today, we're exploring a compelling combination of technologies that are setting new standards in the industry: Bun, Astro, React, and Nano Stores.

Bun, the new kid on the JavaScript runtime block, has been gaining attention for its blazing-fast performance and modern feature set. Astro, a groundbreaking framework, is revolutionizing the way we think about building lightweight, efficient websites. React, the ubiquitous library for building user interfaces, continues to be a cornerstone of modern web applications. And Nano Stores, with its straightforward and flexible approach to state management compatible with many libraries, complements this stack beautifully by simplifying complex state logic.

In this post, we'll delve into how these technologies come together to form a powerhouse for web development. From initial setup to crafting a functional and responsive application, we'll walk you through each step, showcasing the unique advantages and synergies of Bun, Astro, React, and Nano Stores. Whether you're an experienced developer or new to the field, this showcase aims to enlighten and inspire, providing you with practical knowledge and insights to apply in your next project.

Let’s start with Bun, it is Javascript runtime and toolkit designed for speed and performance, it replaces Node.js by natively implementing hundreds of Node.js and Web APIs, The goal of Bun is to run most of the world's server-side JavaScript and provide tools to improve performance, reduce complexity, and multiply developer productivity.

To install Bun we can make use of the following command in a MacOS/Linux terminal:

curl -fsSL https://bun.sh/install | bash

Then, we will need to restart the terminal so zsh or bash context gets aware of bun cli.

Next we have Astro, it is a groundbreaking web framework renowned for its innovative approach in boosting website performance through partial hydration. This technique substantially cuts down on JavaScript usage, leading to quicker load times and a more streamlined user experience. Additionally, Astro employs server-side rendering, utilizing a server (locally or from many providers) to enhance performance further. This server-side process pre-renders pages into static HTML, optimizing content delivery. A key feature of Astro is its compatibility with various frontend frameworks like React, Vue, and Svelte, offering developers the flexibility to integrate their preferred tools. This versatility, combined with its server-aided rendering capabilities, positions Astro as an ideal choice for those seeking to develop optimized and high-performing web applications.

Now, let’s initialize an Astro project with Bun. For that, we’ll make use of a Bun template that will install all required dependencies and wrap up Next.js commands with Bun:

bunx create-astro@latest what-to-watch

This will download the template and execute a wizard asking a few questions related to the project. For this example, we’ll just choose the default options.

After a few seconds, the wizard will finish with all the prerequisites, and we can navigate to the project folder with:

cd what-to-watch


We need to install one more dependency since we have enabled TypeScript, but its type does not come by default.

bun add -d @types/bun




We will need to install and setup React as our frontend library for Astro, we can do this with:

bunx astro add react



We will add nanostores, which will serve as our state manager for our different components. It is designed to work with multiple frontend libraries along with Astro.

bun add nanostores @nanostores/react @nanostores/persistent

Now we can start up the Astro project with Bun like this:

bunx --bun astro dev


If we use Node.js instead of Bun's runtime, we can see that it takes a few more milliseconds.

npm run dev


To make Bun the default runtime for the project, we can update the package.json scripts accordingly. This ensures that the next time we run the dev script, it will use the Bun runtime.

{
  ...
  "scripts": {
    "dev": "bunx --bun astro dev",
    "start": "bunx --bun astro dev",
    "build": "astro check && bunx --bun astro build",
    "preview": "bunx --bun astro preview",
    "astro": "astro"
  },
  ...
}


If we head to http://localhost:4321 we can see our starter page.

When reviewing the project code, we can identify several important folders and files:

  • The public directory: This is for files and assets in your project that do not need to be processed during Astro’s build process. It is suitable for common assets like images and fonts, or special files such as robots.txt and manifest.webmanifest.
  • The src folder holds the project source code, including components, pages, styles, etc.
  • The package.json defines the project dependencies and manifest.
  • The astro.config.mjs defines configuration settings for Astro related to the build or the server.
  • The tsconfig.json defines the configuration for TypeScript.

Within the src folder, we have the following subfolders:

  • Components: As in most frameworks, components are reusable units of code for your pages. These could be Astro components (plain HTML and CSS) or UI framework components like React, Preact, Svelte, Vue, and SolidJs. It is common to group and organize all of your project components together in this folder.
  • Layouts: This folder contains Astro components that define the UI structure shared by one or more pages.
  • Pages: Pages are a special kind of component used to create new pages on your site. A page can be an Astro component or a Markdown file that represents some content on your site. Astro Routing uses file-based routing to generate your build URLs based on the file layout of your project’s pages directory, similar to Next.js.

Now, let’s inspect the sample files created by the starter template. Let’s start with the initial page.

---
import Layout from '../layouts/Layout.astro';
import Card from '../components/Card.astro';
---

<Layout title="Welcome to Astro.">
	<main>
		<svg
			class="astro-a"
			width="495"
			height="623"
			viewBox="0 0 495 623"
			fill="none"
			xmlns="http://www.w3.org/2000/svg"
			aria-hidden="true"
		>
			<path
				fill-rule="evenodd"
				clip-rule="evenodd"
				d="M167.19 364.254C83.4786 364.254 0 404.819 0 404.819C0 404.819 141.781 19.4876 142.087 18.7291C146.434 7.33701 153.027 0 162.289 0H332.441C341.703 0 348.574 7.33701 352.643 18.7291C352.92 19.5022 494.716 404.819 494.716 404.819C494.716 404.819 426.67 364.254 327.525 364.254L264.41 169.408C262.047 159.985 255.147 153.581 247.358 153.581C239.569 153.581 232.669 159.985 230.306 169.408L167.19 364.254ZM160.869 530.172C160.877 530.18 160.885 530.187 160.894 530.195L160.867 530.181C160.868 530.178 160.868 530.175 160.869 530.172ZM136.218 411.348C124.476 450.467 132.698 504.458 160.869 530.172C160.997 529.696 161.125 529.242 161.248 528.804C161.502 527.907 161.737 527.073 161.917 526.233C165.446 509.895 178.754 499.52 195.577 500.01C211.969 500.487 220.67 508.765 223.202 527.254C224.141 534.12 224.23 541.131 224.319 548.105C224.328 548.834 224.337 549.563 224.347 550.291C224.563 566.098 228.657 580.707 237.264 593.914C245.413 606.426 256.108 615.943 270.749 622.478C270.593 621.952 270.463 621.508 270.35 621.126C270.045 620.086 269.872 619.499 269.685 618.911C258.909 585.935 266.668 563.266 295.344 543.933C298.254 541.971 301.187 540.041 304.12 538.112C310.591 533.854 317.059 529.599 323.279 525.007C345.88 508.329 360.09 486.327 363.431 457.844C364.805 446.148 363.781 434.657 359.848 423.275C358.176 424.287 356.587 425.295 355.042 426.275C351.744 428.366 348.647 430.33 345.382 431.934C303.466 452.507 259.152 455.053 214.03 448.245C184.802 443.834 156.584 436.019 136.218 411.348Z"
				fill="url(#paint0_linear_1805_24383)"></path>
			<defs>
				<linearGradient
					id="paint0_linear_1805_24383"
					x1="247.358"
					y1="0"
					x2="247.358"
					y2="622.479"
					gradientUnits="userSpaceOnUse"
				>
					<stop stop-opacity="0.9"></stop>
					<stop offset="1" stop-opacity="0.2"></stop>
				</linearGradient>
			</defs>
		</svg>
		<h1>Welcome to <span class="text-gradient">Astro</span></h1>
		<p class="instructions">
			To get started, open the directory <code>src/pages</code> in your project.<br />
			<strong>Code Challenge:</strong> Tweak the "Welcome to Astro" message above.
		</p>
		<ul role="list" class="link-card-grid">
			<Card
				href="https://docs.astro.build/"
				title="Documentation"
				body="Learn how Astro works and explore the official API docs."
			/>
			<Card
				href="https://astro.build/integrations/"
				title="Integrations"
				body="Supercharge your project with new frameworks and libraries."
			/>
			<Card
				href="https://astro.build/themes/"
				title="Themes"
				body="Explore a galaxy of community-built starter themes."
			/>
			<Card
				href="https://astro.build/chat/"
				title="Community"
				body="Come say hi to our amazing Discord community. ❤️"
			/>
		</ul>
	</main>
</Layout>

<style>
	main {
		margin: auto;
		padding: 1rem;
		width: 800px;
		max-width: calc(100% - 2rem);
		color: white;
		font-size: 20px;
		line-height: 1.6;
	}
	.astro-a {
		position: absolute;
		top: -32px;
		left: 50%;
		transform: translatex(-50%);
		width: 220px;
		height: auto;
		z-index: -1;
	}
	h1 {
		font-size: 4rem;
		font-weight: 700;
		line-height: 1;
		text-align: center;
		margin-bottom: 1em;
	}
	.text-gradient {
		background-image: var(--accent-gradient);
		-webkit-background-clip: text;
		-webkit-text-fill-color: transparent;
		background-size: 400%;
		background-position: 0%;
	}
	.instructions {
		margin-bottom: 2rem;
		border: 1px solid rgba(var(--accent-light), 25%);
		background: linear-gradient(rgba(var(--accent-dark), 66%), rgba(var(--accent-dark), 33%));
		padding: 1.5rem;
		border-radius: 8px;
	}
	.instructions code {
		font-size: 0.8em;
		font-weight: bold;
		background: rgba(var(--accent-light), 12%);
		color: rgb(var(--accent-light));
		border-radius: 4px;
		padding: 0.3em 0.4em;
	}
	.instructions strong {
		color: rgb(var(--accent-light));
	}
	.link-card-grid {
		display: grid;
		grid-template-columns: repeat(auto-fit, minmax(24ch, 1fr));
		gap: 2rem;
		padding: 0;
	}
</style>


As we can see, most of the content of this file is plain HTML and CSS. It is also importing the layout and a component, both made with Astro, meaning they only have HTML and CSS.

Here is the content of the Layout and the Card component:

---
interface Props {
	title: string;
}

const { title } = Astro.props;
---

<!doctype html>
<html lang="en">
	<head>
		<meta charset="UTF-8" />
		<meta name="description" content="Astro description" />
		<meta name="viewport" content="width=device-width" />
		<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
		<meta name="generator" content={Astro.generator} />
		<title>{title}</title>
	</head>
	<body>
		<slot />
	</body>
</html>
<style is:global>
	:root {
		--accent: 136, 58, 234;
		--accent-light: 224, 204, 250;
		--accent-dark: 49, 10, 101;
		--accent-gradient: linear-gradient(
			45deg,
			rgb(var(--accent)),
			rgb(var(--accent-light)) 30%,
			white 60%
		);
	}
	html {
		font-family: system-ui, sans-serif;
		background: #13151a;
		background-size: 224px;
	}
	code {
		font-family:
			Menlo,
			Monaco,
			Lucida Console,
			Liberation Mono,
			DejaVu Sans Mono,
			Bitstream Vera Sans Mono,
			Courier New,
			monospace;
	}
</style>




---
interface Props {
	title: string;
	body: string;
	href: string;
}

const { href, title, body } = Astro.props;
---

<li class="link-card">
	<a href={href}>
		<h2>
			{title}
			<span>&rarr;</span>
		</h2>
		<p>
			{body}
		</p>
	</a>
</li>
<style>
	.link-card {
		list-style: none;
		display: flex;
		padding: 1px;
		background-color: #23262d;
		background-image: none;
		background-size: 400%;
		border-radius: 7px;
		background-position: 100%;
		transition: background-position 0.6s cubic-bezier(0.22, 1, 0.36, 1);
		box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
	}
	.link-card > a {
		width: 100%;
		text-decoration: none;
		line-height: 1.4;
		padding: calc(1.5rem - 1px);
		border-radius: 8px;
		color: white;
		background-color: #23262d;
		opacity: 0.8;
	}
	h2 {
		margin: 0;
		font-size: 1.25rem;
		transition: color 0.6s cubic-bezier(0.22, 1, 0.36, 1);
	}
	p {
		margin-top: 0.5rem;
		margin-bottom: 0;
	}
	.link-card:is(:hover, :focus-within) {
		background-position: 0;
		background-image: var(--accent-gradient);
	}
	.link-card:is(:hover, :focus-within) h2 {
		color: rgb(var(--accent-light));
	}
</style>


In these two Astro components, we can see the usage of properties embedded in the HTML. That’s possible because Astro components are made up of two main parts: the Component Script and the Component Template. Astro uses a code fence (---) to identify the component script in your Astro component. You can use the component script to write any JavaScript/TypeScript code that you need to render your template. The code fence is designed to guarantee that the JavaScript/TypeScript you write in it is “fenced in.” It won’t escape into your frontend application or fall into your user’s hands. You can safely write code here that is expensive or sensitive (like a call to your private database) without worrying about it ever ending up in your user’s browser. This can include:

  • Importing other Astro components
  • Importing other framework components, like React
  • Importing data, like a JSON file
  • Fetching content from an API or database
  • Creating variables that you will reference in your template

The component template is below the code fence and determines the HTML output of your component. If you write plain HTML here, your component will render that HTML in any Astro page where it is imported and used. However, Astro’s component template syntax also supports JavaScript expressions, Astro <style> and <script> tags, imported components, and special Astro directives. Data and values defined in the component script can be used in the component template to produce dynamically-created HTML. One thing we can also notice here is the usage of native anchor tags to handle linking, since there is no specific component in Astro for this, as it handles linking gracefully.

In order to review the React implementation and component interactivity in Astro (Astro Islands), let’s start building a Movie/Series Catalog that relies on the TMDB API. We will be able to add any of these to our watch list. There will be a few pages: a landing page, the movies page, the movie details page, the series page, and the series details page. We will also build a wishlist widget that will show on all pages and contain a list of the movies/series added.

Let’s begin by creating an .env file, which will contain the API URL and the access token.


.env

TMDB_API_URL=https://api.themoviedb.org/3/
TMDB_API_KEY=eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI5YTJjYjhhOWEyN2JjZGViNTg0YTNkMWJhZDgxMDY3YiIsInN1YiI6IjY1YTdkNDg5NTI5NGU3MDEyYWQyZDkyNSIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.rL_yUUuNTOubfsmxNlyoGq1QAVAkfIKENR8hNw-rURw

We need some type definitions, which will be used across all components and pages.

types/index.ts

export type Pagination = {
  page: number;
  total_pages: number;
  total_results: number;
};

type Genres = {
  id: number;
  name: string;
};

export type Movie = {
  id: number;
  title: string;
  poster_path: string;
  overview?: string;
  release_date?: string;
  homepage?: string;
  genres?: Genres[];
};

export type Series = {
  id: number;
  name: string;
  poster_path: string;
  overview?: string;
  first_air_date?: string;
  homepage?: string;
  genres?: Genres[];
};

export type MoviesResponse = Pagination & {
  results: Movie[];
};

export type SeriesResponse = Pagination & {
  results: Series[];
};

export type WatchlistItem = {
  id: string;
  title: string;
  poster_path: string;
  type: "movie" | "series";
};


We also need to tweak the tsconfig.json file to allow imports by alias for the project structure.

tsconfig.json

{
  "extends": "astro/tsconfigs/strict",
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "react",
    "baseUrl": ".",
    "paths": {
      "@components/*": ["src/components/*"],
      "@layouts/*": ["src/layouts/*"],
      "@pages/*": ["src/pages/*"],
      "@typ/*": ["src/types/*"],
      "@stores/*": ["src/stores/*"],
    }
  }
}

Besides the TypeScript configuration, we need to tweak the Astro config to decide which output we will be generating. By default, it generates a full static site, but we want to apply SSR on some pages because of their dynamic data. So, we use the hybrid behavior, which keeps generating static pages by default but can opt out certain pages to use SSR.


astro.config.mjs

import { defineConfig } from "astro/config";

import react from "@astrojs/react";

// https://astro.build/config
export default defineConfig({
  output: "hybrid",
  integrations: [react()],
});

Next, we need to create a store to hold the watchlist elements. Since we are doing full page loads from the server when navigating (default behavior for Astro since all pages are static), we will need to persist the state somewhere. In this case, we will use NanoStore and its persistent library, which saves the data to the localStorage.

stores/watchlist.ts

import { atom } from "nanostores";
import { persistentMap } from "@nanostores/persistent";
import type { WatchlistItem } from "@typ/index";

export const isWatchlistOpen = atom(false);

export const watchlistItems = persistentMap<Record<string, WatchlistItem>>(
  "watchlistItems",
  {},
  {
    encode(value) {
      return JSON.stringify(value);
    },
    decode(value) {
      try {
        return JSON.parse(value);
      } catch (e) {
        return value;
      }
    },
  }
);

export function addWatchlistItem({
  id,
  title,
  poster_path,
  type,
}: {
  id: string;
  title: string;
  poster_path: string;
  type: "movie" | "series";
}) {
  const existingEntry = watchlistItems.get()[`${id}-${type}`];
  if (!existingEntry) {
    watchlistItems.setKey(`${id}-${type}`, { id, title, poster_path, type });
  }
}

export function removeWatchlistItem({
  id,
  type,
}: {
  id: string;
  type: "movie" | "series";
}) {
  const existingEntry = watchlistItems.get()[`${id}-${type}`];
  if (existingEntry) {
    // @ts-ignore
    watchlistItems.setKey(`${id}-${type}`, undefined);
  }
}


Now we can start working on the reusable components. These will be common React functional components. In order to keep the example short, we will be adding inline styles.

components/Paginator.tsx

export default function Paginator({
  prevUrl,
  currentPage,
  nextUrl,
}: {
  prevUrl?: string;
  currentPage: number;
  nextUrl?: string;
}) {
  return (
    <div
      style={{
        display: "flex",
        justifyContent: "center",
        gap: "5px",
        marginTop: "20px",
        marginBottom: "50px",
      }}
    >
      {prevUrl ? (
        <a
          style={{
            minWidth: "80px",
            padding: "4px",
            borderRadius: "8px",
            backgroundColor: "white",
            color: "black",
            textDecoration: "none",
            textAlign: "center",
          }}
          href={prevUrl}
        >
          Previous
        </a>
      ) : (
        <div
          style={{
            minWidth: "80px",
            padding: "4px",
          }}
        />
      )}

      <span
        style={{
          padding: "4px",
          borderRadius: "8px",
          backgroundColor: "white",
          marginLeft: "2px",
          color: "black",
          fontWeight: "bold",
          minWidth: "30px",
          textAlign: "center",
        }}
      >
        {currentPage}
      </span>

      {nextUrl ? (
        <a
          style={{
            minWidth: "80px",
            padding: "4px",
            borderRadius: "8px",
            backgroundColor: "white",
            color: "black",
            textDecoration: "none",
            textAlign: "center",
          }}
          href={nextUrl}
        >
          Next
        </a>
      ) : (
        <div
          style={{
            minWidth: "80px",
            padding: "4px",
          }}
        />
      )}
    </div>
  );
}

components/ContentDetails.tsx

import type { Movie, Series } from "@typ/index";

export default function ContentDetails({
  content,
}: {
  content: Movie | Series;
}) {
  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        gap: "10px",
        backgroundColor: "white",
        borderRadius: "8px",
        padding: "10px",
        color: "black",
      }}
    >
      <div>
        <span style={{ fontWeight: "bold" }}>Overview: </span>
        <span>{content.overview}</span>
      </div>
      {content.homepage && (
        <div>
          <span style={{ fontWeight: "bold" }}>Homepage: </span>
          <span>{content.homepage}</span>
        </div>
      )}
      {((content as Movie).release_date ||
        (content as Series).first_air_date) && (
        <div>
          <span style={{ fontWeight: "bold" }}>Release Date: </span>
          <span>
            {(content as Movie).release_date ||
              (content as Series).first_air_date}
          </span>
        </div>
      )}
      <div>
        <span style={{ fontWeight: "bold" }}>Genres: </span>
        <span>{content.genres?.map((genre) => genre.name).join(", ")}</span>
      </div>
    </div>
  );
}

components/ContentCard.tsx

import WatchlistButton from "@components/WatchlistButton";
import type { Movie, Series } from "@typ/index";

type AnchorLinkProps = {
  href: string;
  children: React.ReactNode;
};

function AnchorLink({ href, children }: AnchorLinkProps) {
  return (
    <a href={href} style={{ textDecoration: "none" }}>
      {children}
    </a>
  );
}

type ContentCardProps = {
  content: Movie | Series;
  type: "movie" | "series";
  showLink?: boolean;
};

export default function ContentCard({
  content,
  type,
  showLink = false,
}: ContentCardProps) {
  const path = `/${type === "movie" ? "movies" : "series"}/${content.id}`;
  const title =
    type === "movie" ? (content as Movie).title : (content as Series).name;
  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        backgroundColor: "white",
        borderRadius: "8px",
        boxShadow: "0px 2px 4px rgba(0, 0, 0, 0.1)",
        overflow: "clip",
        position: "relative",
      }}
    >
      {showLink ? (
        <AnchorLink href={path}>
          <img
            src={`https://image.tmdb.org/t/p/w400${content.poster_path}`}
            alt={title}
          />
        </AnchorLink>
      ) : (
        <img
          src={`https://image.tmdb.org/t/p/w400${content.poster_path}`}
          alt={title}
        />
      )}

      <WatchlistButton content={content} type={type} />

      {showLink ? (
        <AnchorLink href={path}>
          <h2
            style={{
              fontSize: "1.25rem",
              fontWeight: "bold",
              color: "black",
              textAlign: "center",
              padding: "1rem",
            }}
          >
            {title}
          </h2>
        </AnchorLink>
      ) : (
        <h2
          style={{
            fontSize: "1.25rem",
            fontWeight: "bold",
            color: "black",
            textAlign: "center",
            padding: "1rem",
          }}
        >
          {title}
        </h2>
      )}
    </div>
  );
}

components/ContentList.tsx

import ContentCard from "@components/ContentCard";
import type { Movie, Series } from "@typ/index";

export default function ContentList({
  content,
  type,
}: {
  content: Movie[] | Series[];
  type: "movie" | "series";
}) {
  return (
    <div
      style={{
        display: "grid",
        gridTemplateColumns: "repeat(auto-fit, minmax(24ch, 1fr))",
        gap: "1rem",
      }}
    >
      {content.map((element) => (
        <ContentCard key={element.id} content={element} type={type} showLink />
      ))}
    </div>
  );
}

components/WatchlistButton.tsx

import { useState, useEffect } from "react";
import { useStore } from "@nanostores/react";
import type { Movie, Series } from "@typ/index";

import {
  watchlistItems,
  addWatchlistItem,
  removeWatchlistItem,
} from "@stores/watchlist";

export default function WatchlistButton({
  content,
  type,
}: {
  content: Movie | Series;
  type: "movie" | "series";
}) {
  const items = useStore(watchlistItems);

  const [isWatchlisted, setIsWatchlisted] = useState<boolean | null>(null);

  useEffect(() => {
    setIsWatchlisted(!!items[`${content.id.toString()}-${type}`]);
  }, [items]);

  const addItem = (content: Movie | Series, type: "movie" | "series") => {
    addWatchlistItem({
      id: content.id.toString(),
      title:
        type === "movie" ? (content as Movie).title : (content as Series).name,
      poster_path: content.poster_path,
      type,
    });
  };

  const removeItem = (content: Movie | Series, type: "movie" | "series") => {
    removeWatchlistItem({
      id: content.id.toString(),
      type,
    });
  };

  if (isWatchlisted === null) return null;

  return (
    <button
      type="button"
      style={{
        position: "absolute",
        top: "5px",
        right: "5px",
        width: "40px",
        height: "40px",
        display: "flex",
        justifyContent: "center",
        alignItems: "center",
        backgroundColor: "white",
        borderRadius: "100%",
        cursor: "pointer",
        border: "none",
        boxShadow: "0px 2px 4px rgba(0, 0, 0, 0.5)",
        zIndex: 5,
      }}
      onClick={
        isWatchlisted
          ? () => removeItem(content, type)
          : () => addItem(content, type)
      }
    >
      {isWatchlisted ? (
        <svg
          xmlns="http://www.w3.org/2000/svg"
          viewBox="0 0 24 24"
          fill="green"
          width="24"
          height="24"
        >
          <path d="M0 0h24v24H0z" fill="none" />
          <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
        </svg>
      ) : (
        <svg
          xmlns="http://www.w3.org/2000/svg"
          viewBox="0 0 24 24"
          fill="gray"
          width="24"
          height="24"
        >
          <path d="M0 0h24v24H0z" fill="none" />
          <path
            d="M12 5v14M5 12h14"
            stroke="gray"
            strokeWidth="2"
            strokeLinecap="round"
            strokeLinejoin="round"
          />
        </svg>
      )}
    </button>
  );
}

components/WatchlistFlyoutToggle.tsx

import { useState, useEffect } from "react";
import { useStore } from "@nanostores/react";
import { isWatchlistOpen, watchlistItems } from "@stores/watchlist";

export default function WatchlistFlyoutToggle() {
  const isOpen = useStore(isWatchlistOpen);
  const items = useStore(watchlistItems);

  const [itemsNumber, setItemsNumber] = useState<number | null>(null);

  useEffect(() => {
    setItemsNumber(Object.keys(items).length);
  }, [items]);

  if (itemsNumber === null) return null;

  return (
    <button
      onClick={() => isWatchlistOpen.set(!isOpen)}
      style={{
        position: "absolute",
        top: "10px",
        right: "10px",
        height: "40px",
        width: isOpen ? "40px" : "auto",
        display: "flex",
        justifyContent: "center",
        alignItems: "center",
        backgroundColor: "#23262d",
        backgroundImage: "none",
        backgroundSize: "400%",
        backgroundPosition: "100%",
        padding: "20px",
        color: "white",
        fontSize: "1.1rem",
        transition: "color 0.6s cubic-bezier(0.22, 1, 0.36, 1)",
        borderRadius: isOpen ? "100%" : "8px",
        cursor: "pointer",
        border: "none",
        boxShadow: "inset 0 0 0 1px rgba(255, 255, 255, 0.1)",
        zIndex: 10,
        fontWeight: "bold",
      }}
    >
      {!isOpen
        ? `Watchlist ${itemsNumber !== 0 ? `(${itemsNumber})` : ""}`
        : "X"}
    </button>
  );
}

components/WatchlistFlyout.tsx

import { useState, useEffect } from "react";
import { useStore } from "@nanostores/react";
import {
  watchlistItems,
  isWatchlistOpen,
  removeWatchlistItem,
} from "@stores/watchlist";
import type { WatchlistItem } from "@typ/index";

export default function WatchlistFlyout() {
  const isOpen = useStore(isWatchlistOpen);
  const items = useStore(watchlistItems);

  const [listItems, setListItems] = useState<Record<string, WatchlistItem>>({});
  useEffect(() => {
    setListItems(items);
  }, [items]);

  const removeItem = (content: WatchlistItem, type: "movie" | "series") => {
    removeWatchlistItem({
      id: content.id.toString(),
      type,
    });
  };

  if (!isOpen) return null;

  return (
    <aside
      style={{
        position: "fixed",
        right: 0,
        top: 0,
        height: "100vh",
        background: "white",
        paddingInline: "2rem",
        minWidth: "min(90vw, 300px)",
        maxWidth: "min(90vw, 300px)",
        borderLeft: "3px solid var(--color-bg-3)",
        paddingTop: "50px",
        display: "flex",
        justifyContent: "center",
        zIndex: 9,
      }}
    >
      {Object.values(listItems).length ? (
        <ul style={{ listStyle: "none", padding: 0 }} role="list">
          {Object.values(listItems).map((content) => (
            <li
              key={`${content.id}-${content.type}`}
              style={{
                display: "flex",
                gap: "1rem",
                alignItems: "center",
                marginTop: "5px",
                backgroundColor: "#23262d",
                color: "white",
                paddingRight: "10px",
                borderRadius: "5px",
                overflow: "clip",
                position: "relative",
              }}
            >
              <a
                href={`/${content.type === "movie" ? "movies" : "series"}/${
                  content.id
                }`}
              >
                <img
                  style={{ width: "4rem" }}
                  src={`https://image.tmdb.org/t/p/w400${content.poster_path}`}
                  alt={content.title}
                />
              </a>
              <div>
                <a
                  href={`/${content.type === "movie" ? "movies" : "series"}/${
                    content.id
                  }`}
                  style={{ textDecoration: "none", color: "white" }}
                >
                  <h3 style={{ marginBlock: "0.3rem" }}>{content.title}</h3>
                </a>
              </div>
              <button
                style={{
                  position: "absolute",
                  top: "5px",
                  right: "5px",
                  width: "20px",
                  height: "20px",
                  background: "white",
                  border: "none",
                  cursor: "pointer",
                  display: "flex",
                  alignItems: "center",
                  justifyContent: "center",
                  borderRadius: "100%",
                  fontSize: "0.7rem",
                }}
                onClick={() => removeItem(content, content.type)}
              >
                X
              </button>
            </li>
          ))}
        </ul>
      ) : (
        <p>Your Watchlist is empty!</p>
      )}
    </aside>
  );
}


Now that we have all the components in place, let’s update the existing Layout.astro to inject the Watchlist widget.

layouts/Layout.astro

---
import WatchlistFlyoutToggle from "@components/WatchlistFlyoutToggle";
import WatchlistFlyout from "@components/WatchlistFlyout";

interface Props {
  title: string;
}

const { title } = Astro.props;
---

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="description" content="Astro description" />
    <meta name="viewport" content="width=device-width" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="generator" content={Astro.generator} />
    <title>{title}</title>
  </head>
  <body>
    <header>
      <nav>
        <WatchlistFlyoutToggle client:idle />
      </nav>
    </header>
    <slot />
    <WatchlistFlyout client:idle />
    <style is:global>
      :root {
        --accent: 136, 58, 234;
        --accent-light: 224, 204, 250;
        --accent-dark: 49, 10, 101;
        --accent-gradient: linear-gradient(
          45deg,
          rgb(var(--accent)),
          rgb(var(--accent-light)) 30%,
          white 60%
        );
      }
      html {
        font-family: system-ui, sans-serif;
        background: #13151a;
        background-size: 224px;
      }
      code {
        font-family:
          Menlo,
          Monaco,
          Lucida Console,
          Liberation Mono,
          DejaVu Sans Mono,
          Bitstream Vera Sans Mono,
          Courier New,
          monospace;
      }
    </style>
  </body>
</html>


Notice that we are adding the client directive to the components with the idle value, this way Astro doesn’t inject any of these component’s javascript code until the page has fully loaded and becomes idle.

Now it is time to set our routes and pages. Remember that pages need to be Astro components, — since we already have an existing landing page in Astro we will just modify it for our purpose.

pages/index.astro

---
import Layout from "@layouts/Layout.astro";
import Card from "@components/Card.astro";
---

<Layout title="What To Watch">
  <main>
    <h1>What to <span class="text-gradient">Watch</span></h1>
    <p class="instructions">
      To get started, select one of the categories and then start exploring.<br
      />
      Don't forget to add things you like to your <strong>Watchlist</strong>.
    </p>
    <ul role="list" class="link-card-grid">
      <Card
        href="/movies"
        title="Movies"
        body="Explore popular movies worldwide."
      />
      <Card
        href="/series"
        title="Series"
        body="Get to know the most seen Series on all platforms."
      />
    </ul>
  </main>
</Layout>

<style>
  main {
    margin: auto;
    padding: 1rem;
    width: 800px;
    max-width: calc(100% - 2rem);
    color: white;
    font-size: 20px;
    line-height: 1.6;
  }
  h1 {
    font-size: 4rem;
    font-weight: 700;
    line-height: 1;
    text-align: center;
    margin-bottom: 1em;
  }
  .text-gradient {
    background-image: var(--accent-gradient);
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
    background-size: 400%;
    background-position: 0%;
  }
  .instructions {
    margin-bottom: 2rem;
    border: 1px solid rgba(var(--accent-light), 25%);
    background: linear-gradient(
      rgba(var(--accent-dark), 66%),
      rgba(var(--accent-dark), 33%)
    );
    padding: 1.5rem;
    border-radius: 8px;
  }
  .instructions strong {
    color: rgb(var(--accent-light));
  }
  .link-card-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(24ch, 1fr));
    gap: 2rem;
    padding: 0;
  }
</style>

Notice that we are adding links both to the movies and series routes, so we will need to have a folder for each of these within the pages folder. Inside each matching route folder, we will have both the list and the details pages, whether the param is present or not.


pages/movies/index.astro

---
import Layout from "@layouts/Layout.astro";
import ContentList from "@components/ContentList";
import Paginator from "@components/Paginator";
import type { MoviesResponse } from "@typ/index";

const { url } = Astro.request;
const page = new URL(url).searchParams.get("page") || 1;

const moviesResponse: MoviesResponse = await fetch(
  `${import.meta.env.TMDB_API_URL}/movie/popular?page=${page}`,
  {
    method: "GET",
    headers: {
      accept: "application/json",
      Authorization: `Bearer ${import.meta.env.TMDB_API_KEY}`,
    },
  }
).then((res) => res.json());
---

<Layout title="What To Watch || Popular Movies">
  <main>
    <h1>Popular <span class="text-gradient">Movies</span></h1>
    <ContentList content={moviesResponse.results} type="movie" client:idle />
    <Paginator
      prevUrl={moviesResponse.page > 1
        ? `/movies?page=${moviesResponse.page - 1}`
        : undefined}
      currentPage={moviesResponse.page}
      nextUrl={moviesResponse.page < moviesResponse.total_pages
        ? `/movies?page=${moviesResponse.page + 1}`
        : undefined}
    />
  </main>
</Layout>

<style>
  main {
    margin: auto;
    padding: 1rem;
    max-width: 80%;
    color: white;
    font-size: 20px;
    line-height: 1.6;
  }
  h1 {
    font-size: 4rem;
    font-weight: 700;
    line-height: 1;
    text-align: center;
    margin-bottom: 1em;
  }
  .text-gradient {
    background-image: var(--accent-gradient);
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
    background-size: 400%;
    background-position: 0%;
  }
</style>


pages/movies/[movie].astro

---
import Layout from "@layouts/Layout.astro";
import type { Movie } from "@typ/index";
import ContentCard from "@components/ContentCard";
import ContentDetails from "@components/ContentDetails";

export const prerender = false;

const { movie } = Astro.params;

const movieDetails: Movie = await fetch(
  `${import.meta.env.TMDB_API_URL}/movie/${movie}`,
  {
    method: "GET",
    headers: {
      accept: "application/json",
      Authorization: `Bearer ${import.meta.env.TMDB_API_KEY}`,
    },
  }
).then((res) => res.json());
---

<Layout title=`What To Watch || ${movieDetails.title}`>
  <main>
    <h1><span class="text-gradient">{movieDetails.title}</span></h1>
    <section>
      <div>
        <ContentCard content={movieDetails} type="movie" client:idle />
      </div>
      <div>
        <ContentDetails content={movieDetails} />
      </div>
    </section>
  </main>
</Layout>

<style>
  main {
    margin: auto;
    padding: 1rem;
    max-width: 80%;
    color: white;
    font-size: 20px;
    line-height: 1.6;
  }
  section {
    display: flex;
    gap: 2rem;
    justify-content: center;
  }
  div {
    display: flex;
  }
  h1 {
    font-size: 4rem;
    font-weight: 700;
    line-height: 1;
    text-align: center;
    margin-bottom: 1em;
  }
  .text-gradient {
    background-image: var(--accent-gradient);
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
    background-size: 400%;
    background-position: 0%;
  }
</style>


Since dynamic routes by default require a list of params to build every possible page at build time, we need to bypass that by setting the prerender constant to false. This way, all dynamic pages will be generated and provided from the server at runtime.

Then we do the same thing for the series pages.


pages/series/index.astro

---
import Layout from "@layouts/Layout.astro";
import ContentList from "@components/ContentList";
import Paginator from "@components/Paginator";
import type { SeriesResponse } from "@typ/index";

const { url } = Astro.request;
const page = new URL(url).searchParams.get("page") || 1;

const seriesResponse: SeriesResponse = await fetch(
  `${import.meta.env.TMDB_API_URL}/tv/popular?page=${page}`,
  {
    method: "GET",
    headers: {
      accept: "application/json",
      Authorization: `Bearer ${import.meta.env.TMDB_API_KEY}`,
    },
  }
).then((res) => res.json());
---

<Layout title="What To Watch || Popular Movies">
  <main>
    <h1>Popular <span class="text-gradient">Movies</span></h1>
    <ContentList content={seriesResponse.results} type="series" client:idle />
    <Paginator
      prevUrl={seriesResponse.page > 1
        ? `/movies?page=${seriesResponse.page - 1}`
        : undefined}
      currentPage={seriesResponse.page}
      nextUrl={seriesResponse.page < seriesResponse.total_pages
        ? `/movies?page=${seriesResponse.page + 1}`
        : undefined}
    />
  </main>
</Layout>

<style>
  main {
    margin: auto;
    padding: 1rem;
    max-width: 80%;
    color: white;
    font-size: 20px;
    line-height: 1.6;
  }
  h1 {
    font-size: 4rem;
    font-weight: 700;
    line-height: 1;
    text-align: center;
    margin-bottom: 1em;
  }
  .text-gradient {
    background-image: var(--accent-gradient);
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
    background-size: 400%;
    background-position: 0%;
  }
</style>


pages/series/[series].astro

​​---
import Layout from "@layouts/Layout.astro";
import type { Movie } from "@typ/index";
import ContentCard from "@components/ContentCard";
import ContentDetails from "@components/ContentDetails";

export const prerender = false;

const { series } = Astro.params;

const movieDetails: Movie = await fetch(
  `${import.meta.env.TMDB_API_URL}/tv/${series}`,
  {
    method: "GET",
    headers: {
      accept: "application/json",
      Authorization: `Bearer ${import.meta.env.TMDB_API_KEY}`,
    },
  }
).then((res) => res.json());
---

<Layout title=`What To Watch || ${movieDetails.title}`>
  <main>
    <h1><span class="text-gradient">{movieDetails.title}</span></h1>
    <section>
      <div>
        <ContentCard content={movieDetails} type="series" client:idle />
      </div>
      <div>
        <ContentDetails content={movieDetails} />
      </div>
    </section>
  </main>
</Layout>

<style>
  main {
    margin: auto;
    padding: 1rem;
    max-width: 80%;
    color: white;
    font-size: 20px;
    line-height: 1.6;
  }
  section {
    display: flex;
    gap: 2rem;
    justify-content: center;
  }
  div {
    display: flex;
  }
  h1 {
    font-size: 4rem;
    font-weight: 700;
    line-height: 1;
    text-align: center;
    margin-bottom: 1em;
  }
  .text-gradient {
    background-image: var(--accent-gradient);
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
    background-size: 400%;
    background-position: 0%;
  }
</style>


Finally, we can run the app and test its functionality.

Now, let’s review the deployment process. Since Astro by default is meant to make static sites primarily, when we add SSR to it, we need to have a runtime environment available to serve these non-static pages, something which is not included by default in Astro (other than just for development purposes). Here is where the Astro Adapters come into play. We can easily implement many providers through an adapter. In this case, we will implement Netlify. We just need to install the adapter as follows:

bunx astro add netlify

Now, we just need to build the app with:

npm run build



Then submit to Netlify with their CLI. The first time you’ll be asked to set up a site if you haven’t done so already on their website.

netlify deploy


When asked, you’ll have to choose the dist folder, which contains the application build generated locally.

Then, if you navigate to the Website draft URL, you’ll be able to test the app. Don’t forget to add the environment variables to the Netlify application, as these will not be included by default in the compiled version. You’ll have to redeploy in order to pick up new changes.

After we have tested the preview release and ensured all is working properly, we can move it to the production environment by redeploying and passing the --prod flag.

netlify deploy --prod


You’ll able to see the site on its main app url: https://watchlist-astro.netlify.app/

You can see the site performs really well even with full page loads on navigation and server-side rendering for dynamic pages. The whole project can be found at https://github.com/ReyRod/bun-astro-react-nanostores-showcase.


The exploration of the Bun, Astro, React, and Nano Stores stack offers a fresh perspective on building performant, scalable, and enjoyable web applications. Through a practical demonstration, we highlight the synergy between Bun's impressive speed, Astro's innovative partial hydration approach, React's proven UI building capabilities, and Nano Stores' flexible state management. 

The step-by-step guide not only showcases the installation and setup process but also delves into creating a dynamic, feature-rich application that leverages each technology's strengths. The integration of these technologies provides developers with a powerful toolkit for crafting modern web experiences that are fast, responsive, and maintainable. As the web development landscape continues to evolve, the combination of Bun, Astro, React, and Nano Stores stands out as a compelling choice for developers seeking to push the boundaries of what's possible on the web. 

This article serves as a testament to the potential of combining cutting-edge tools to elevate the web development process and deliver exceptional user experiences.

Don't miss a thing, subscribe to our monthly Newsletter!

Thanks for subscribing!
Oops! Something went wrong while submitting the form.

Should you use NestJS for your next project?

Learn the pros and cons of choosing NestJS for your software development projects.

June 27, 2022
Read more ->
React
Nodejs

Next.js Latest features

Explore Next.js's latest features in versions 13 and 14: App Router, Server Components, Streaming, and Server Actions. These updates revolutionize routing, rendering, content delivery, and server-side logic, enhancing performance and user experience.

February 7, 2024
Read more ->
Nodejs
React
Software Development

Contact

Ready to get started?
Use the form or give us a call to meet our team and discuss your project and business goals.
We can’t wait to meet you!

Write to us!
info@vairix.com

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.