<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nemanja Mitic</title>
    <description>The latest articles on DEV Community by Nemanja Mitic (@nemanjam).</description>
    <link>https://dev.to/nemanjam</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3029234%2Fe5f66dd8-9744-4474-aa92-7a452b36b2b2.jpg</url>
      <title>DEV Community: Nemanja Mitic</title>
      <link>https://dev.to/nemanjam</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nemanjam"/>
    <language>en</language>
    <item>
      <title>Runtime environment variables in Next.js - build reusable Docker images</title>
      <dc:creator>Nemanja Mitic</dc:creator>
      <pubDate>Sun, 14 Dec 2025 08:17:27 +0000</pubDate>
      <link>https://dev.to/nemanjam/runtime-environment-variables-in-nextjs-build-reusable-docker-images-ho</link>
      <guid>https://dev.to/nemanjam/runtime-environment-variables-in-nextjs-build-reusable-docker-images-ho</guid>
      <description>&lt;h2&gt;
  
  
  Classification of environment variables by dimension
&lt;/h2&gt;

&lt;p&gt;At first glance, you might think of environment variables as just a few values needed when the app starts, but as you dig deeper, you realize it's far more complex than that. If you don’t clearly understand the nature of the value you're dealing with, you'll have a hard time running the app and managing its configuration across multiple environments.&lt;/p&gt;

&lt;p&gt;Let's identify a few dimensions that any environment variable can have:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;When:&lt;/strong&gt; build-time, start-time, run-time&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where:&lt;/strong&gt; server (static, SSR (request), ISR), client&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Visibility:&lt;/strong&gt; public, private&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Requirement:&lt;/strong&gt; optional, required&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope:&lt;/strong&gt; common for all environments (constant, config), unique&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mutability:&lt;/strong&gt; constant, mutable&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Git tracking:&lt;/strong&gt; versioned, ignored&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There are probably more, but this is enough to understand why it can be challenging to manage. We could go very wide, write a long article and elaborate each of these and their combinations, but since the goal of this article is very specific and practical - handling Next.js environment variables in Docker, we'll focus just on the top three items from the list. Still, it was worth mentioning the others for context.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next.js environment variables
&lt;/h2&gt;

&lt;p&gt;If you search the Next.js docs, you will find a &lt;a href="https://nextjs.org/docs/app/guides/environment-variables#runtime-environment-variables" rel="noopener noreferrer"&gt;guide on environment variables&lt;/a&gt;, such as &lt;code&gt;.env*&lt;/code&gt; filenames that are loaded by default, their load order and priority, variable expansion, and exposing and inlining variables with the &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; prefix into the client. In the &lt;a href="https://nextjs.org/docs/app/guides/self-hosting#environment-variables" rel="noopener noreferrer"&gt;self-hosting guide&lt;/a&gt;, you will also find a paragraph about opting into dynamic rendering so that variable values are read on each server component render, not just once at build time, and how this is useful for reusable Docker images.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with build-time environment variables
&lt;/h2&gt;

&lt;p&gt;A common scenario after reading the docs is to be aware of &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; and server variables and then scatter them around the codebase. If you use Docker and GitHub Actions, you will typically end up with something like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/11c8a512f57e937aa623776418fa4cfb1e9b4dc4/frontend/Dockerfile" rel="noopener noreferrer"&gt;11c8a512.../frontend/Dockerfile&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# frontend/Dockerfile&lt;/span&gt;

&lt;span class="c"&gt;# Next.js app installer stage&lt;/span&gt;
FROM base AS installer
RUN apk update
RUN apk add &lt;span class="nt"&gt;--no-cache&lt;/span&gt; libc6-compat

&lt;span class="c"&gt;# Enable pnpm&lt;/span&gt;
ENV &lt;span class="nv"&gt;PNPM_HOME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/pnpm"&lt;/span&gt;
ENV &lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PNPM_HOME&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;$PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
RUN corepack &lt;span class="nb"&gt;enable
&lt;/span&gt;RUN corepack prepare pnpm@10.12.4 &lt;span class="nt"&gt;--activate&lt;/span&gt;

WORKDIR /app

&lt;span class="c"&gt;# Copy monorepo package.json and lock files&lt;/span&gt;
COPY &lt;span class="nt"&gt;--from&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;builder /app/out/json/ &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="c"&gt;# Install the dependencies&lt;/span&gt;
RUN pnpm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--frozen-lockfile&lt;/span&gt;

&lt;span class="c"&gt;# Copy pruned source&lt;/span&gt;
COPY &lt;span class="nt"&gt;--from&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;builder /app/out/full/ &lt;span class="nb"&gt;.&lt;/span&gt;

&lt;span class="c"&gt;# THIS: set build time env vars&lt;/span&gt;
ARG ARG_NEXT_PUBLIC_SITE_URL
ENV &lt;span class="nv"&gt;NEXT_PUBLIC_SITE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$ARG_NEXT_PUBLIC_SITE_URL&lt;/span&gt;
RUN &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"NEXT_PUBLIC_SITE_URL=&lt;/span&gt;&lt;span class="nv"&gt;$NEXT_PUBLIC_SITE_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

ARG ARG_NEXT_PUBLIC_API_URL
ENV &lt;span class="nv"&gt;NEXT_PUBLIC_API_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$ARG_NEXT_PUBLIC_API_URL&lt;/span&gt;
RUN &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"NEXT_PUBLIC_API_URL=&lt;/span&gt;&lt;span class="nv"&gt;$NEXT_PUBLIC_API_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Build the project&lt;/span&gt;
RUN pnpm turbo build

&lt;span class="c"&gt;# ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/11c8a512f57e937aa623776418fa4cfb1e9b4dc4/.github/workflows/build-push-frontend.yml" rel="noopener noreferrer"&gt;11c8a512.../.github/workflows/build-push-frontend.yml&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/build-push-docker-image.yml&lt;/span&gt;

&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build and push Docker frontend&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;main'&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;IMAGE_NAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.repository.name }}-frontend&lt;/span&gt;
  &lt;span class="c1"&gt;# THIS: set build time env vars&lt;/span&gt;
  &lt;span class="na"&gt;NEXT_PUBLIC_SITE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;https://full-stack-fastapi-template-nextjs.arm1.nemanjamitic.com'&lt;/span&gt;
  &lt;span class="na"&gt;NEXT_PUBLIC_API_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;https://api.full-stack-fastapi-template-nextjs.arm1.nemanjamitic.com'&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build and push docker image&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# ...&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build and push Docker image&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/build-push-action@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./frontend&lt;/span&gt;
          &lt;span class="na"&gt;file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./frontend/Dockerfile&lt;/span&gt;
          &lt;span class="na"&gt;platforms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;linux/amd64,linux/arm64&lt;/span&gt;
          &lt;span class="na"&gt;progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;plain&lt;/span&gt;
          &lt;span class="c1"&gt;# THIS: set build time args&lt;/span&gt;
          &lt;span class="na"&gt;build-args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;"ARG_NEXT_PUBLIC_SITE_URL=${{ env.NEXT_PUBLIC_SITE_URL }}"&lt;/span&gt;
            &lt;span class="s"&gt;"ARG_NEXT_PUBLIC_API_URL=${{ env.NEXT_PUBLIC_API_URL }}"&lt;/span&gt;
          &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:latest&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/11c8a512f57e937aa623776418fa4cfb1e9b4dc4/frontend/package.json" rel="noopener noreferrer"&gt;11c8a512.../frontend/package.json&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;frontend/package.json&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"full-stack-fastapi-template-nextjs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0.0.1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"private"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"turbo build"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"dev"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"turbo dev"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"standalone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"turbo run standalone --filter web"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;THIS:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;set&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;build&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;time&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;args&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"docker:build:x86"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"docker buildx build -f ./Dockerfile -t nemanjamitic/full-stack-fastapi-template-nextjs-frontend --build-arg ARG_NEXT_PUBLIC_SITE_URL='full-stack-fastapi-template-nextjs.local.nemanjamitic.com' --build-arg ARG_NEXT_PUBLIC_API_URL='api.full-stack-fastapi-template-nextjs.local.nemanjamitic.com' --platform linux/amd64 ."&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the code above, we can see that our Next.js app requires the &lt;code&gt;NEXT_PUBLIC_SITE_URL&lt;/code&gt; and &lt;code&gt;NEXT_PUBLIC_API_URL&lt;/code&gt; environment variables at build time. These values will be inlined into the bundle during the build and cannot be changed later. This means the &lt;code&gt;Dockerfile&lt;/code&gt; must pass them as the corresponding &lt;code&gt;ARG_NEXT_PUBLIC_SITE_URL&lt;/code&gt; and &lt;code&gt;ARG_NEXT_PUBLIC_API_URL&lt;/code&gt; build arguments when building the image.&lt;/p&gt;

&lt;p&gt;Leaving them undefined would break the build because they are validated with Zod inside the Next.js app, and validation runs at both build time and run time. Stripping the &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; prefix would also break the build, even without Zod, if they are used in client code.&lt;/p&gt;

&lt;p&gt;Consequently, we need to pass these build arguments whenever we build the Docker image, for example in GitHub Actions and in the local build script defined in &lt;code&gt;package.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Using this method, we would get a functional Docker image, but with one major drawback: &lt;strong&gt;it can be used only in a single environment&lt;/strong&gt; because the &lt;code&gt;NEXT_PUBLIC_SITE_URL&lt;/code&gt; and &lt;code&gt;NEXT_PUBLIC_API_URL&lt;/code&gt; values are baked into the image at build time and are immutable.&lt;/p&gt;

&lt;p&gt;To make this crystal clear, whatever we set for the &lt;code&gt;NEXT_PUBLIC_SITE_URL&lt;/code&gt; and &lt;code&gt;NEXT_PUBLIC_API_URL&lt;/code&gt; environment variables at runtime will be ignored because they no longer exist in the Next.js app. After the build they are replaced with string literals in the JavaScript bundle.&lt;/p&gt;

&lt;p&gt;If, besides production, you also have staging, preview, testing environments, or other production mirrors, you would need to maintain a separate image with its own configuration code, build process, and registry storage for each of them. This means a lot of overhead.&lt;/p&gt;

&lt;p&gt;Many people find this impractical, which you can see from the popularity of such issues in the Next.js repository:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/vercel/next.js/discussions/44628" rel="noopener noreferrer"&gt;Better support for runtime environment variables #44628&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/vercel/next.js/discussions/17641" rel="noopener noreferrer"&gt;Docker image with NEXT*PUBLIC* env variables #17641&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/vercel/next.js/discussions/22243" rel="noopener noreferrer"&gt;Not possible to use different configurations in staging + production #22243&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The solution: run-time environment variables
&lt;/h2&gt;

&lt;p&gt;The solution is obvious: we should prevent any use of build-time (stale, immutable) variables and read everything from the target environment at runtime. This also means avoiding any &lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt; client variables.&lt;/p&gt;

&lt;p&gt;To implement this, we must be well aware of where and when a given component runs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Server component - runs on the server, generated at build time or at request time&lt;/li&gt;
&lt;li&gt;Static page - runs on the server, generated once at build time&lt;/li&gt;
&lt;li&gt;Client component - runs in the browser, generated at build time or at request time&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Server component
&lt;/h3&gt;

&lt;p&gt;These components (or entire pages) are dynamically rendered on each request. They have access to any server data, including both public and private environment variables. No additional action is needed. In Next.js, we identify such components by their use of request resources such as cookies, headers, and connection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/headers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headersList&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cookiesList&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// void&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Static page
&lt;/h3&gt;

&lt;p&gt;Such a page is pre-rendered once at build time in the build environment. It has access to server data, but it is converted to a static asset at build time and is immutable at runtime. We have two options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Convert it to a dynamic page that is rendered on the server on each request.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// opt into dynamic rendering&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Set &lt;strong&gt;placeholder values&lt;/strong&gt; for variables at build time and perform string replacement directly on the generated static HTML using &lt;code&gt;sed&lt;/code&gt; or &lt;code&gt;envsubst&lt;/code&gt; and a shell script included in &lt;code&gt;ENTRYPOINT ["scripts/entrypoint.sh"]&lt;/code&gt; in the Dockerfile.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Note that these will be &lt;strong&gt;start-time&lt;/strong&gt; variables, not true &lt;strong&gt;run-time&lt;/strong&gt; variables, but most of the time that is sufficient because they are unique to each environment. However, they cannot change during the app's run time once initialized.&lt;/p&gt;

&lt;p&gt;We won't go into much detail about this method, it could be a good topic for a future article since it is quite useful for static, presentational websites. If you want to read more, here is an interesting and practical tutorial: &lt;a href="https://phase.dev/blog/nextjs-public-runtime-variables/" rel="noopener noreferrer"&gt;https://phase.dev/blog/nextjs-public-runtime-variables/&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Client component
&lt;/h3&gt;

&lt;p&gt;Next.js prevents exposing any variables to the client without the &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; prefix, but since those are inlined at build time, we simply won't use them. For exposing environment variables to client components, we have a few options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pass variables as props from the parent server component like any other value. This is simple and convenient.&lt;/li&gt;
&lt;li&gt;Inside the dynamically generated root layout, render a &lt;code&gt;&amp;lt;script /&amp;gt;&lt;/code&gt; tag that injects a &lt;code&gt;window.__RUNTIME_ENV__&lt;/code&gt; property into the global &lt;code&gt;window&lt;/code&gt; object using the &lt;code&gt;dangerouslySetInnerHTML&lt;/code&gt; attribute. We will actually use this method. Then, on the client, we can access the variables on the &lt;code&gt;window&lt;/code&gt; object, for example &lt;code&gt;window.__RUNTIME_ENV__.API_URL&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Also this is a good moment to validate runtime vars with Zod.&lt;/p&gt;

&lt;p&gt;Here is the illustration code bellow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/layout.tsx&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;runtimeEnvSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;SITE_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;regex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[^/]&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SITE_URL should not end with a slash "/"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;API_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;regex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[^/]&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;API_URL should not end with a slash "/"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RootLayout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FC&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Props&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;runtimeEnvData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;SITE_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SITE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;API_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;API_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="c1"&gt;// validate vars with Zod before injecting&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parsedRuntimeEnv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;runtimeEnvSchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safeParse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;runtimeEnvData&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// if invalid vars abort&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;parsedRuntimeEnv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid runtime environment variable found...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;runtimeEnv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;parsedRuntimeEnv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;html&lt;/span&gt; &lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* Inline JSON injection */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;script&lt;/span&gt;
          &lt;span class="na"&gt;dangerouslySetInnerHTML&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;__html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`window.__RUNTIME_ENV__ = &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;runtimeEnv&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;

        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;html&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Same as for static pages: set &lt;strong&gt;placeholder values&lt;/strong&gt; and use &lt;code&gt;sed&lt;/code&gt; to replace them with a shell script inside the JavaScript bundle when the container starts.&lt;/li&gt;
&lt;li&gt;Expose variables through a dynamic API endpoint and perform an HTTP fetch in client components. This is a legitimate method, but note that it will make the variables asynchronous.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We can see from this that the first two methods are the simplest and most convenient, so we will use them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; Whenever an environment variable is available on the client, it is public by default. Make sure not to expose any secrets to the client.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;alizeait/next-public-env&lt;/code&gt; package
&lt;/h3&gt;

&lt;p&gt;We could do this manually as shown in the snippet above, but there is already the &lt;a href="https://github.com/alizeait/next-public-env" rel="noopener noreferrer"&gt;alizeait/next-public-env&lt;/a&gt; package that handles all of this and also provides some more advanced handling.&lt;/p&gt;

&lt;p&gt;Check these 2 files for example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/alizeait/next-public-env/blob/master/packages/next-public-env/src/server/FlushConfig.tsx" rel="noopener noreferrer"&gt;src/server/FlushConfig.tsx&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/alizeait/next-public-env/blob/master/packages/next-public-env/src/server/index.tsx#L130" rel="noopener noreferrer"&gt;src/server/index.tsx#L130&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Usage is obvious and straightforward: just define a Zod schema, mount &lt;code&gt;&amp;lt;PublicEnv /&amp;gt;&lt;/code&gt; in the root layout, and use &lt;code&gt;getPublicEnv()&lt;/code&gt; to access the variables wherever you need them.&lt;/p&gt;

&lt;p&gt;You can see bellow how I did it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# install package&lt;/span&gt;

pnpm add next-public-env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/frontend/apps/web/src/config/process-env.ts" rel="noopener noreferrer"&gt;frontend/apps/web/src/config/process-env.ts&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// frontend/apps/web/src/config/process-env.ts&lt;/span&gt;

&lt;span class="cm"&gt;/** Exports RUNTIME env. Must NOT call getPublicEnv() in global scope. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getPublicEnv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;PublicEnv&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createPublicEnv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;NODE_ENV&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NODE_ENV&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;SITE_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SITE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;API_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;API_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getProcessEnvSchemaProps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/frontend/apps/web/src/schemas/config.ts" rel="noopener noreferrer"&gt;frontend/apps/web/src/schemas/config.ts&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// frontend/apps/web/src/schemas/config.ts&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nodeEnvValues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;development&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ZodType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cm"&gt;/** For runtime env. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getProcessEnvSchemaProps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ZodType&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;NODE_ENV&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nodeEnvValues&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;SITE_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;regex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[^/]&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SITE_URL should not end with a slash "/"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;API_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;regex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[^/]&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;API_URL should not end with a slash "/"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="cm"&gt;/** For schema type. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;processEnvSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;getProcessEnvSchemaProps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/frontend/apps/web/src/app/layout.tsx" rel="noopener noreferrer"&gt;frontend/apps/web/src/app/layout.tsx&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// frontend/apps/web/src/app/layout.tsx&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PublicEnv&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/config/process-env&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Props&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ReactNode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RootLayout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FC&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Props&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;html&lt;/span&gt; &lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt; &lt;span class="na"&gt;suppressHydrationWarning&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;fontInter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PublicEnv&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ThemeProvider&lt;/span&gt; &lt;span class="na"&gt;attribute&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"class"&lt;/span&gt; &lt;span class="na"&gt;defaultTheme&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"light"&lt;/span&gt; &lt;span class="na"&gt;enableSystem&lt;/span&gt; &lt;span class="na"&gt;disableTransitionOnChange&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* Slot with server components */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Toaster&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;ThemeProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;html&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;RootLayout&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An example usage, for instance in &lt;code&gt;instrumentation.ts&lt;/code&gt;, to log the runtime values of all environment variables for debugging purposes:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/frontend/apps/web/src/instrumentation.ts" rel="noopener noreferrer"&gt;frontend/apps/web/src/instrumentation.ts&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// frontend/apps/web/src/instrumentation.ts&lt;/span&gt;

&lt;span class="cm"&gt;/** Runs only once on server start. */&lt;/span&gt;

&lt;span class="cm"&gt;/** Log loaded env vars. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;register&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEXT_RUNTIME&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nodejs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;prettyPrintObject&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/utils/log&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getPublicEnv&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/config/process-env&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nf"&gt;prettyPrintObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;getPublicEnv&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Runtime process.env&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Usage for &lt;code&gt;baseUrl&lt;/code&gt; for OpenAPI client
&lt;/h4&gt;

&lt;p&gt;This is another typical and very important spot for using the &lt;code&gt;API_URL&lt;/code&gt; environment variable. What makes it tricky is that it is included and runs on both the server and in the browser, but it is defined in a single place.&lt;/p&gt;

&lt;p&gt;However, &lt;code&gt;alizeait/next-public-env&lt;/code&gt; resolves this complexity very well on its own, and you can simply use &lt;code&gt;getPublicEnv()&lt;/code&gt; to get the &lt;code&gt;API_URL&lt;/code&gt; value while letting the package handle the rest.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/frontend/apps/web/src/lib/hey-api.ts" rel="noopener noreferrer"&gt;frontend/apps/web/src/lib/hey-api.ts&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// frontend/apps/web/src/lib/hey-api.ts&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getPublicEnv&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/config/process-env&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cm"&gt;/** Runtime config. Runs and imported both on server and in browser. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;createClientConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CreateClientConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;API_URL&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getPublicEnv&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;API_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;include&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nf"&gt;isServer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;serverFetch&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{}),&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Legitimate build-time environment variables
&lt;/h3&gt;

&lt;p&gt;Variables that are the same for every environment can be left as &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; and inlined into the bundle. They should also be versioned in Git (their &lt;code&gt;.env.*&lt;/code&gt; files). Since this is the case, the best approach is to store them as TypeScript constants directly in the source, because that is what they truly are - shared constants.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build and deploy reusable Docker image
&lt;/h2&gt;

&lt;p&gt;Build once - deploy everywhere. Use a single image and &lt;code&gt;.env&lt;/code&gt; file with no redundancy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Building
&lt;/h3&gt;

&lt;p&gt;Now that we have eliminated all build-time variables by converting them to run-time environment variables, we can simply remove all build arguments and environment variables from the &lt;code&gt;Dockerfile&lt;/code&gt;, Github Actions build workflow, &lt;code&gt;package.json&lt;/code&gt; build scripts, etc.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; During the build phase of a Next.js app, the global scope is also executed. Therefore, if you read any environment variables, such as &lt;code&gt;process.env.MY_VAR_XXX&lt;/code&gt;, your code must be able to handle a default &lt;code&gt;undefined&lt;/code&gt; value without throwing exceptions, as this would break the build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tip:&lt;/strong&gt; To access environment variables, always use &lt;code&gt;getPublicEnv()&lt;/code&gt; inside components and functions. Never call &lt;code&gt;getPublicEnv()&lt;/code&gt; or read &lt;code&gt;process.env&lt;/code&gt; in the global scope, this way, you won't need to handle &lt;code&gt;undefined&lt;/code&gt; environment variables explicitly for the build to pass.&lt;/p&gt;

&lt;p&gt;Simply remove all build arguments and build-time environment variables from the &lt;code&gt;Dockerfile&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# frontend/Dockerfile&lt;/span&gt;

&lt;span class="c"&gt;# Not needed anymore, remove all build args&lt;/span&gt;
ARG ARG_NEXT_PUBLIC_SITE_URL
ENV &lt;span class="nv"&gt;NEXT_PUBLIC_SITE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$ARG_NEXT_PUBLIC_SITE_URL&lt;/span&gt;
RUN &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"NEXT_PUBLIC_SITE_URL=&lt;/span&gt;&lt;span class="nv"&gt;$NEXT_PUBLIC_SITE_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

ARG ARG_NEXT_PUBLIC_API_URL
ENV &lt;span class="nv"&gt;NEXT_PUBLIC_API_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$ARG_NEXT_PUBLIC_API_URL&lt;/span&gt;
RUN &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"NEXT_PUBLIC_API_URL=&lt;/span&gt;&lt;span class="nv"&gt;$NEXT_PUBLIC_API_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the cleaned up &lt;code&gt;Dockerfile&lt;/code&gt; that I am using to build Next.js app inside the monorepo: &lt;a href="https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/frontend/Dockerfile" rel="noopener noreferrer"&gt;frontend/Dockerfile&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Also, don't forget to clean up unused build arguments from the Github Actions workflow and &lt;code&gt;package.json&lt;/code&gt; scripts for building the Docker image. You can see mine here: &lt;a href="https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/.github/workflows/build-push-frontend.yml" rel="noopener noreferrer"&gt;.github/workflows/build-push-frontend.yml&lt;/a&gt;, &lt;a href="https://github.com/nemanjam/full-stack-fastapi-template-nextjs/blob/main/frontend/package.json" rel="noopener noreferrer"&gt;frontend/package.json&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;frontend/package.json&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"docker:build:x86"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"docker buildx build -f ./Dockerfile -t nemanjamitic/full-stack-fastapi-template-nextjs-frontend --platform linux/amd64 ."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Deployment
&lt;/h3&gt;

&lt;p&gt;Once built, you can use that image to deploy to any environment. Naturally, you need to define and pass all runtime environment variables into the Docker container. In your &lt;code&gt;docker-compose.yml&lt;/code&gt;, use the &lt;code&gt;env_file:&lt;/code&gt; or &lt;code&gt;environment:&lt;/code&gt; keys.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# apps/full-stack-fastapi-template-nextjs/docker-compose.yml&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;frontend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nemanjamitic/full-stack-fastapi-template-nextjs-frontend:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;full-stack-fastapi-template-nextjs-frontend&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;.env&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PORT=3000&lt;/span&gt;

    &lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# apps/full-stack-fastapi-template-nextjs/.env&lt;/span&gt;

&lt;span class="nv"&gt;SITE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://full-stack-fastapi-template-nextjs.arm1.nemanjamitic.com
&lt;span class="nv"&gt;API_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://api-full-stack-fastapi-template-nextjs.arm1.nemanjamitic.com
&lt;span class="nv"&gt;NODE_ENV&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;production

&lt;span class="c"&gt;# ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can see &lt;code&gt;docker-compose.yml&lt;/code&gt; and &lt;code&gt;.env&lt;/code&gt; I am using here: &lt;a href="https://github.com/nemanjam/traefik-proxy/blob/main/apps/full-stack-fastapi-template-nextjs/docker-compose.yml" rel="noopener noreferrer"&gt;apps/full-stack-fastapi-template-nextjs/docker-compose.yml&lt;/a&gt;, &lt;a href="https://github.com/nemanjam/traefik-proxy/blob/main/apps/full-stack-fastapi-template-nextjs/.env.example" rel="noopener noreferrer"&gt;apps/full-stack-fastapi-template-nextjs/.env.example&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Alternative approaches
&lt;/h2&gt;

&lt;p&gt;In the Static page section, I already mentioned a few notes about runtime variables and static websites. Indeed, you have two options for runtime variables:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Convert the website from static to dynamically rendered SSR (rendered at request time). Note that this is a significant change: from this point, your website will require a Node.js runtime, which will greatly impact your deployment options, as you can no longer use static hosting.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is overkill just for the purpose of having runtime environment variables. Use it only if your website has additional reasons to use SSR.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Perform string replacement directly on bundle assets using &lt;code&gt;sed&lt;/code&gt;, &lt;code&gt;envsubst&lt;/code&gt;, etc. This is the right approach. There are other options, such as the Nginx &lt;code&gt;subs_filter&lt;/code&gt; config option, but be careful with it, as it runs on each request and can waste CPU.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Another option to consider is using an &lt;code&gt;./env.js&lt;/code&gt; file instead of the usual &lt;code&gt;.env&lt;/code&gt;. You can then host it with Nginx and load it into the app using &lt;code&gt;&amp;lt;script src="./env.js" /&amp;gt;&lt;/code&gt;. After that, you can reference the variables with &lt;code&gt;window.__RUNTIME_ENV__.MY_VAR&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Note that this won't work well for usage in pure HTML pages. For example, Astro omits any client-side JavaScript by default, so you would need to use an additional inline &lt;code&gt;&amp;lt;script /&amp;gt;&lt;/code&gt; tag to update the HTML, e.g., &lt;code&gt;getElementById("my-id")?.textContent = window.__RUNTIME_ENV__.MY_VAR&lt;/code&gt;, which is less optimal than the string replacement method.&lt;/p&gt;

&lt;p&gt;Here is a quick, approximate code for illustration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// env.js&lt;/span&gt;

&lt;span class="c1"&gt;// define variables&lt;/span&gt;

&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__RUNTIME_ENV__&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;SITE_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://my-static-website.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;PLAUSIBLE_DOMAIN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;my-static-website.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;PLAUSIBLE_SCRIPT_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://plausible.my-server.com/js/script.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml&lt;/span&gt;

&lt;span class="c1"&gt;# mount and host env.js file&lt;/span&gt;

&lt;span class="na"&gt;my-static-website&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:1.29.1-alpine3.22-slim&lt;/span&gt;
  &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-static-website&lt;/span&gt;
  &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
  &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./website:/usr/share/nginx/html&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./env.js:/usr/share/nginx/html/env.js&lt;/span&gt; &lt;span class="c1"&gt;# this&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./nginx/nginx.conf:/etc/nginx/nginx.conf&lt;/span&gt;

  &lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- src/components/BaseHead.astro --&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Load env.js file --&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;head&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;charset=&lt;/span&gt;&lt;span class="s"&gt;"UTF-8"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;My static website&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"./env.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- ... --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- src/components/MyComponent.astro --&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- example usage --&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- example 1: assign var to text content --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"my-element"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/span&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mySpan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;my-element&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;mySpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__RUNTIME_ENV__&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MY_VAR&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- example 2: assign var to script attribute --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;script&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;script&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;defer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/partytown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// dynamically set attributes from runtime env&lt;/span&gt;
  &lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__RUNTIME_ENV__&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PLAUSIBLE_DOMAIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__RUNTIME_ENV__&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PLAUSIBLE_SCRIPT_URL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;head&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So, to conclude, the best approach is to use a shell script with &lt;code&gt;sed&lt;/code&gt; or &lt;code&gt;envsubst&lt;/code&gt; and add it to the Nginx &lt;code&gt;Dockerfile&lt;/code&gt; &lt;code&gt;ENTRYPOINT&lt;/code&gt; or the &lt;code&gt;docker-compose.yml&lt;/code&gt; &lt;code&gt;command:&lt;/code&gt;. Here is the link to the already mentioned practical tutorial again: &lt;a href="https://phase.dev/blog/nextjs-public-runtime-variables/" rel="noopener noreferrer"&gt;https://phase.dev/blog/nextjs-public-runtime-variables/&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Completed code
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js app repository:&lt;/strong&gt; &lt;a href="https://github.com/nemanjam/full-stack-fastapi-template-nextjs/tree/main/frontend/apps/web" rel="noopener noreferrer"&gt;https://github.com/nemanjam/full-stack-fastapi-template-nextjs/tree/main/frontend/apps/web&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployment repository:&lt;/strong&gt; &lt;a href="https://github.com/nemanjam/traefik-proxy/tree/main/apps/full-stack-fastapi-template-nextjs" rel="noopener noreferrer"&gt;https://github.com/nemanjam/traefik-proxy/tree/main/apps/full-stack-fastapi-template-nextjs&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The relevant files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Next.js app repo&lt;/span&gt;

&lt;span class="c"&gt;# https://github.com/nemanjam/full-stack-fastapi-template-nextjs/tree/e990a3e29b7af60831851ff6f909c34df6a7f800&lt;/span&gt;

git checkout e990a3e29b7af60831851ff6f909c34df6a7f800

&lt;span class="c"&gt;# run-time vars configuration&lt;/span&gt;
frontend/apps/web/src/config/process-env.ts
frontend/apps/web/src/schemas/config.ts
frontend/apps/web/src/app/layout.tsx

&lt;span class="c"&gt;# usages&lt;/span&gt;
frontend/apps/web/src/instrumentation.ts
frontend/apps/web/src/lib/hey-api.ts

&lt;span class="c"&gt;# 2. Deployment repo&lt;/span&gt;

&lt;span class="c"&gt;# https://github.com/nemanjam/traefik-prox/tree/f3c087184e851db20e65409a6dd145767dd9bc2b&lt;/span&gt;

git checkout f3c087184e851db20e65409a6dd145767dd9bc2b

apps/full-stack-fastapi-template-nextjs/docker-compose.yml
apps/full-stack-fastapi-template-nextjs/.env.example
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;If you go by inertia and mix and scatter run-time and build-time variables around the source code, build, and deployment configuration, you will end up with development and production environments that are difficult to manage, hard to debug and replicate bugs, have an unreliable deployment process, constantly require troubleshooting for missing or invalid environment variables, and result in redundant Docker images, among other issues.&lt;/p&gt;

&lt;p&gt;So, take a proactive approach: understand properly and identify the variables you are dealing with. One way to do this is to leverage the power and convenience of run-time environment variables.&lt;/p&gt;

&lt;p&gt;What approach do you use to manage environment variables in Next.js apps? Feel free to share your experiences and opinions in the comments.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;How to use environment variables in Next.js, Next.js docs guide &lt;a href="https://nextjs.org/docs/app/guides/environment-variables#runtime-environment-variables" rel="noopener noreferrer"&gt;https://nextjs.org/docs/app/guides/environment-variables#runtime-environment-variables&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;How to self-host your Next.js application, Next.js docs guide &lt;a href="https://nextjs.org/docs/app/guides/self-hosting#environment-variables" rel="noopener noreferrer"&gt;https://nextjs.org/docs/app/guides/self-hosting#environment-variables&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Better support for runtime environment variables #44628, Github discussion &lt;a href="https://github.com/vercel/next.js/discussions/44628" rel="noopener noreferrer"&gt;https://github.com/vercel/next.js/discussions/44628&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docker image with NEXT*PUBLIC* env variables #17641, Github discussion &lt;a href="https://github.com/vercel/next.js/discussions/17641" rel="noopener noreferrer"&gt;https://github.com/vercel/next.js/discussions/17641&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Not possible to use different configurations in staging + production #22243, Github discussion &lt;a href="https://github.com/vercel/next.js/discussions/22243" rel="noopener noreferrer"&gt;https://github.com/vercel/next.js/discussions/22243&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Runtime variables for static website, tutorial &lt;a href="https://phase.dev/blog/nextjs-public-runtime-variables/" rel="noopener noreferrer"&gt;https://phase.dev/blog/nextjs-public-runtime-variables/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Runtime Environment Variables in Next.js, concise overview &lt;a href="https://dt.in.th/NextRuntimeEnv" rel="noopener noreferrer"&gt;https://dt.in.th/NextRuntimeEnv&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>nextjs</category>
      <category>docker</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Comparing BFS, DFS, Dijkstra, and A* algorithms on a practical maze solver example</title>
      <dc:creator>Nemanja Mitic</dc:creator>
      <pubDate>Tue, 05 Aug 2025 20:08:44 +0000</pubDate>
      <link>https://dev.to/nemanjam/comparing-bfs-dfs-dijkstra-and-a-algorithms-on-a-practical-maze-solver-example-5ekl</link>
      <guid>https://dev.to/nemanjam/comparing-bfs-dfs-dijkstra-and-a-algorithms-on-a-practical-maze-solver-example-5ekl</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Pathfinding is a fundamental topic in computer science, with applications in fields like navigation, AI/ML, network routing, and many others. In this article, we compare four core pathfinding algorithms: breadth-first search (BFS), depth-first search (DFS), Dijkstra’s algorithm, and A* (A star) through a practical maze-solving example. We don’t just explain them in theory, we built a demo app where you can tweak maze inputs or edit the algorithm code and instantly see how it affects the output and efficiency.&lt;/p&gt;

&lt;p&gt;One of the key takeaways is how a tiny change, just a single line in the code can drastically alter an algorithm’s behavior. This highlights how critical implementation details are, even when the overall structure looks the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem overview
&lt;/h2&gt;

&lt;p&gt;Paths in a maze form a tree structure or a graph if the maze contains cycles. That's why tree and graph traversal algorithms can be used for finding paths and the shortest path in a maze.&lt;/p&gt;

&lt;p&gt;All 4 algorithms differ in just a few lines of code, but their behavior differs dramatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  App architecture
&lt;/h2&gt;

&lt;p&gt;We create a pragmatic, simplified OOP model of the maze and its behavior as a tradeoff, favoring clarity and concise instantiation of maze objects in tests.&lt;/p&gt;

&lt;h3&gt;
  
  
  Maze representation
&lt;/h3&gt;

&lt;p&gt;A maze is represented as a binary matrix where &lt;code&gt;0&lt;/code&gt; stands for a free space, &lt;code&gt;1&lt;/code&gt; for a boundary, and &lt;code&gt;*&lt;/code&gt; for a path. It also has start and end points. In the sense of a weighted graph &lt;code&gt;0&lt;/code&gt; cell has zero weight and cell &lt;code&gt;1&lt;/code&gt; has infinite weight.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/maze.ts&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Maze&lt;/span&gt; &lt;span class="k"&gt;implements&lt;/span&gt; &lt;span class="nx"&gt;IMaze&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;board&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[][];&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Coordinate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Coordinate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Coordinate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// example maze&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;testMaze&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[][]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Coordinate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Coordinate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Class structure
&lt;/h3&gt;

&lt;p&gt;We use polymorphism and a simplified Factory pattern. &lt;code&gt;MazeSolver&lt;/code&gt; is an abstract class that declares the &lt;code&gt;findPath()&lt;/code&gt; method, which is implemented in each derived concrete solver class.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/solvers/maze-solver.ts&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * Abstract base class for maze solving algorithms.
 * Implements common functionality for maze solvers.
 */&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MazeSolver&lt;/span&gt; &lt;span class="k"&gt;implements&lt;/span&gt; &lt;span class="nx"&gt;IMazeSolver&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nx"&gt;maze&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;IMaze&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="nf"&gt;findPath&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;Coordinate&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We use encapsulation and coding towards interface, separating interfaces from implementations by exposing only the public class methods through the interfaces.&lt;/p&gt;

&lt;p&gt;Maze interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/types/maze.ts&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;IMaze&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;getBoard&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[][];&lt;/span&gt;

  &lt;span class="nl"&gt;getStart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;Coordinate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nl"&gt;formatPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ReadonlyArray&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Coordinate&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Maze implementation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/maze.ts&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Maze&lt;/span&gt; &lt;span class="k"&gt;implements&lt;/span&gt; &lt;span class="nx"&gt;IMaze&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;getBoard&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[][]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;getStart&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;Coordinate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;formatPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ReadonlyArray&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Coordinate&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Maze usage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/main.ts&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;_maze2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;IMaze&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Maze&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;testMaze&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Class diagram:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk2lld0pcaszqj82ajdbb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk2lld0pcaszqj82ajdbb.png" alt="Maze solver class diagram" width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Running the app
&lt;/h2&gt;

&lt;p&gt;We install dependencies, run the app, and run the tests as usual, like any other TypeScript app.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# install dependencies&lt;/span&gt;
yarn &lt;span class="nb"&gt;install&lt;/span&gt;

&lt;span class="c"&gt;# enable or disable logging in src/config.ts&lt;/span&gt;

&lt;span class="c"&gt;# run the app in dev mode&lt;/span&gt;
yarn dev

&lt;span class="c"&gt;# logging is disabled for tests by default&lt;/span&gt;

&lt;span class="c"&gt;# run tests&lt;/span&gt;
yarn &lt;span class="nb"&gt;test&lt;/span&gt;

&lt;span class="c"&gt;# run tests in verbose mode&lt;/span&gt;
yarn test-verbose

&lt;span class="c"&gt;# generate coverage report&lt;/span&gt;
yarn coverage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We can see that different algorithms require a different number of steps for the same input maze. Example output for a given maze input:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftgrvsblhqigrmqxsspc7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftgrvsblhqigrmqxsspc7.png" alt="App dev mode terminal output" width="492" height="779"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We can run tests that ensure for each algorithm:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;finds existing path&lt;/li&gt;
&lt;li&gt;doesn't find a false non existent path&lt;/li&gt;
&lt;li&gt;finds the shortest path.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpqfvkwx3a6mrefa7ig79.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpqfvkwx3a6mrefa7ig79.png" alt="Run tests in verbose mode" width="491" height="785"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And calculate the code coverage:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx42hfy82tay6xp9tmax2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx42hfy82tay6xp9tmax2.png" alt="Tests coverage table" width="665" height="601"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Algorithms analysis and discussion
&lt;/h2&gt;

&lt;p&gt;Now for the most important and interesting part: let's analyze the algorithm's code and explain how it affects their behavior and efficiency.&lt;/p&gt;

&lt;h3&gt;
  
  
  Unweighted graphs
&lt;/h3&gt;

&lt;p&gt;BFS and DFS are basic traversal algorithms that ignore the weights of the edges, so they are applicable only to unweighted graphs.&lt;/p&gt;

&lt;p&gt;The actual code for BFS and DFS differs by only a single line, but they exhibit completely opposite behavior. BFS uses a queue (FIFO), while DFS uses a stack (LIFO), and this has a fundamental impact on how the next node candidate for the path is selected.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// BFS&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;coord&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// DFS&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;coord&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the array of coordinates that represents possible directions for movement. This array is iterated over in the algorithm's inner loop.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/utils/constants.ts&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;directions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Direction&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// up&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// right&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// down&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// left&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is the complete BFS implementation (since all 4 algorithms share most of the same base) so we can have a better idea of what we are working with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/solvers/maze-solver-bfs.ts&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MazeSolverBFS&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;MazeSolver&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="cm"&gt;/**
   * Implements the Breadth-First Search (BFS) algorithm to find a path from the start to the end of the maze.
   */&lt;/span&gt;
  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nf"&gt;findPath&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;Coordinate&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maze&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getStart&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Initialize the BFS queue with the start position.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BFSQueueElement&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;coord&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}];&lt;/span&gt;

    &lt;span class="c1"&gt;// Keep track of visited coordinates (as strings).&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;visited&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;visited&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Count iterations.&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;incrementStep&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

      &lt;span class="c1"&gt;// The most important line.&lt;/span&gt;
      &lt;span class="c1"&gt;// FIFO - Takes the oldest element in the queue.&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;coord&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="c1"&gt;// Check if end and exit.&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maze&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isEnd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;coord&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="c1"&gt;// Print the current state of the maze.&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;printBoard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;coord&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;visited&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="c1"&gt;// Always loops 4 times.&lt;/span&gt;
      &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;direction&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;directions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Calculate the next coordinate by applying the direction.&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextCoord&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Coordinate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;coord&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;coord&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;
        &lt;span class="c1"&gt;// Create a key for nextCoord (to check for uniqueness in the visited set).&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;coordKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;nextCoord&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;nextCoord&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;// If nextCoord is not visited, is within bounds, and is walkable, add it to the potential path.&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;visited&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;coordKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
          &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maze&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isWithinBounds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nextCoord&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
          &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maze&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isWalkable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nextCoord&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;visited&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;coordKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;coord&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;nextCoord&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;nextCoord&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Return null if no path to the end is found.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  BFS
&lt;/h3&gt;

&lt;p&gt;Since BFS uses a queue, it respects this structure and attempts to change direction in every iteration of the outer loop. Without obstacles and boundaries, this causes the algorithm to thoroughly inspect nodes closer to the starting node before moving further away. That's why BFS can be inefficient for large trees and graphs where the end node is very distant from the starting node.&lt;/p&gt;

&lt;h3&gt;
  
  
  DFS
&lt;/h3&gt;

&lt;p&gt;In contrast, DFS also respects the initial order in the directions array but prioritizes the earlier elements. So, in the example above, it will always attempt to apply the &lt;code&gt;up&lt;/code&gt; direction first before exploring other directions. Without obstacles and boundaries, this causes the algorithm to inspect distant nodes in a straight line. DFS can be efficient for finding a distant end node but can also be very inefficient for finding a nearby node if it happens to be in a different direction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Weighted graphs
&lt;/h3&gt;

&lt;p&gt;Not all graphs have edges with uniform weights. In such cases, we must use algorithms that are aware of weights (the cost between two nodes), such as Dijkstra and A*.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dijkstra
&lt;/h3&gt;

&lt;p&gt;Dijkstra's algorithm is aware of the cost between two nodes (edge weight) and takes it into account when selecting the next node. It uses a priority queue to keep track of the cost history.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/solvers/maze-solver-dijkstra.ts&lt;/span&gt;

&lt;span class="c1"&gt;// Take the first element from the priority queue.&lt;/span&gt;
&lt;span class="c1"&gt;// Choose the node that ads minimal cost.&lt;/span&gt;
&lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cost&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;coord&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cost&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// ...&lt;/span&gt;

&lt;span class="c1"&gt;// Test how much cost every new node ads to the path before adding it to the queue.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextCost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cost&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maze&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getCost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nextCoord&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;costMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;coordKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;nextCost&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;costMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;coordKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dijkstra keeps a history of the cost of the current path and when selecting the next node chooses the node that adds the minimal cost. If there are cycles it may access the same node from multiple paths and will choose the one with the minimal weight (shortest path). In graphs with constant edge weights it reduces to BFS. This can be observed in the screenshot above, where both BFS and Dijkstra take an equal number of steps because the maze has uniform weights of 1 and Infinity.&lt;/p&gt;

&lt;h3&gt;
  
  
  A*
&lt;/h3&gt;

&lt;p&gt;A* is the same as Dijkstra but besides keeping the history, it uses a heuristic function to predict the future - the direction in which the end node could be.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/solvers/maze-solver-a-star.ts&lt;/span&gt;

&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nf"&gt;heuristic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Coordinate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Coordinate&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Manhattan distance as the heuristic.&lt;/span&gt;
    &lt;span class="c1"&gt;// Can only move horizontally or vertically, not diagonally. In "rectangles".&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// ...&lt;/span&gt;

&lt;span class="nx"&gt;openSet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="c1"&gt;// The most important line. The only difference from Dijkstra.&lt;/span&gt;
    &lt;span class="c1"&gt;// Cost = history + Manhattan distance from the end node.&lt;/span&gt;
    &lt;span class="c1"&gt;// prettier-ignore&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cost&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;heuristic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coord&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cost&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;heuristic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coord&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the heuristic function is well chosen it will make A* more efficient than the before mentioned algorithms. Consequently, if the heuristic function is poorly chosen it will degrade the algorithm efficiency.&lt;/p&gt;

&lt;h2&gt;
  
  
  Completed code
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Maze solver:&lt;/strong&gt; &lt;a href="https://github.com/nemanjam/maze-solver" rel="noopener noreferrer"&gt;https://github.com/nemanjam/maze-solver&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;In this example, we can see how algorithm analysis and design is a very sensitive and subtle discipline that leaves no room for low focus or a lack of understanding of the domain. Although BFS, DFS, Dijkstra, and A* share most of their implementation, even a subtle change in the code can lead to a dramatic change in behavior.&lt;/p&gt;

&lt;p&gt;In the demo app, you can tweak the predefined mazes in the &lt;code&gt;tests/fixtures/*.txt&lt;/code&gt; files and make your own observations. You can also check the resources and interactive playground listed in the References section.&lt;/p&gt;

&lt;p&gt;Have you experimented with maze-solving and pathfinding algorithms before? Let me know in the comments.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Some visualized algorithms behavior &lt;a href="https://www.youtube.com/watch?v=GC-nBgi9r0U" rel="noopener noreferrer"&gt;https://www.youtube.com/watch?v=GC-nBgi9r0U&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;BFS vs DFS, basic overview and implementation &lt;a href="https://www.geeksforgeeks.org/difference-between-bfs-and-dfs/" rel="noopener noreferrer"&gt;https://www.geeksforgeeks.org/difference-between-bfs-and-dfs/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;BFS vs Dijkstra for unweighted and weighted graphs &lt;a href="https://www.baeldung.com/cs/graph-algorithms-bfs-dijkstra" rel="noopener noreferrer"&gt;https://www.baeldung.com/cs/graph-algorithms-bfs-dijkstra&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;BFS vs Dijkstra similarities &lt;a href="https://stackoverflow.com/a/52676408/4383275" rel="noopener noreferrer"&gt;https://stackoverflow.com/a/52676408/4383275&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Visual playgrounds &lt;a href="https://visualmazesolver.vercel.app/" rel="noopener noreferrer"&gt;https://visualmazesolver.vercel.app/&lt;/a&gt;, &lt;a href="http://qiao.github.io/PathFinding.js/visual/" rel="noopener noreferrer"&gt;http://qiao.github.io/PathFinding.js/visual/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Starter project, Typescript, Jest &lt;a href="https://github.com/julianmateu/hello-ts" rel="noopener noreferrer"&gt;https://github.com/julianmateu/hello-ts&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>algorithms</category>
      <category>bfs</category>
      <category>dfs</category>
      <category>dijkstra</category>
    </item>
    <item>
      <title>Load balancing multiple Rathole tunnels with Traefik HTTP and TCP routers</title>
      <dc:creator>Nemanja Mitic</dc:creator>
      <pubDate>Mon, 02 Jun 2025 08:51:05 +0000</pubDate>
      <link>https://dev.to/nemanjam/title-load-balancing-multiple-rathole-tunnels-with-traefik-http-and-tcp-routers-2jo8</link>
      <guid>https://dev.to/nemanjam/title-load-balancing-multiple-rathole-tunnels-with-traefik-http-and-tcp-routers-2jo8</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;This article is a continuation of &lt;a href="https://nemanjamitic.com/blog/2025-04-29-rathole-traefik-home-server" rel="noopener noreferrer"&gt;Expose home server with Rathole tunnel and Traefik&lt;/a&gt; article, which explains how to permanently host websites from home by bypassing CGNAT. That setup works well for exposing a single home server (like a Raspberry Pi, server PC, or virtual machine), but it has a limitation: it requires one VPS (or at least one public network interface) per home server. This is because the Rathole server exclusively uses ports &lt;code&gt;80&lt;/code&gt; and &lt;code&gt;443&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But it doesn't have to be like this. We can reuse a single Rathole server for many tunnels and home servers, we just need a tool to load balance their traffic, as long as our VPS's network interface provides enough bandwidth for our websites and services.&lt;/p&gt;

&lt;p&gt;This article explains how to achieve that using Traefik HTTP and TCP routers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A working Rathole tunnel setup from the previous article (including a VPS and a domain name)&lt;/li&gt;
&lt;li&gt;More than one home server (Raspberry Pi, server PC, virtual machine, or LXC container)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Architecture overview
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The problem
&lt;/h3&gt;

&lt;p&gt;The main problem here is that we can't bind more than one port to ports &lt;code&gt;80&lt;/code&gt; and &lt;code&gt;443&lt;/code&gt;, respectively. Only one service can listen on a given port at the same time. So something like this doesn't exist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;rathole&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rapiz1/rathole:v0.5.0&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rathole&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;--server /config/rathole.server.toml&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# host:container&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;2333:2333&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;80:5080,5081&lt;/span&gt; &lt;span class="c1"&gt;# non existent syntax, can't bind two ports to a single port&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;443:5443,5444&lt;/span&gt; &lt;span class="c1"&gt;# same&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./rathole.server.toml:/config/rathole.server.toml:ro&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Neither the operating system nor Docker provides load balancing functionality out of the box, we need to handle it ourselves.&lt;/p&gt;

&lt;h3&gt;
  
  
  The solution
&lt;/h3&gt;

&lt;p&gt;We need to introduce a tool for load balancing traffic between tunnels. We will use Traefik, since we already use it with the Rathole client.&lt;/p&gt;

&lt;p&gt;For each home server, we need 2 tunnels: one for HTTP and another for HTTPS traffic:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The tunnel for HTTP traffic will use the Traefik HTTP router as usual.&lt;/li&gt;
&lt;li&gt;The tunnel for HTTPS traffic is a bit more interesting and challenging. For it, we will use the Traefik TCP router running in passthrough mode, since we don't want to terminate HTTPS traffic on the VPS. Instead, we want to delegate certificate resolution to the existing Traefik instance running on the client side to preserve the current setup and architecture.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F492hjhuusn1yf3atcgua.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F492hjhuusn1yf3atcgua.png" alt="Traefik load balancer architecture diagram" width="800" height="699"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reminder:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I already wrote about the advantage of resolving SSL certificates locally on the home server in the &lt;a href="https://nemanjamitic.com/blog/2025-04-29-rathole-traefik-home-server#architecture-overview" rel="noopener noreferrer"&gt;Architecture overview&lt;/a&gt; section of the previous article, but here is a quick recap:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The home server contains its entire configuration&lt;/li&gt;
&lt;li&gt;The home server is tunnel-agnostic and reusable&lt;/li&gt;
&lt;li&gt;No coupling between the tunnel server and client, no need to maintain state or version&lt;/li&gt;
&lt;li&gt;Decoupled debugging&lt;/li&gt;
&lt;li&gt;Improved security, an additional encryption layer further down the tunnel&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Traefik load balancer and Rathole server
&lt;/h2&gt;

&lt;p&gt;Since we passthrough encrypted HTTPS traffic, Traefik can't read the subdomain from an HTTP request as usual. Instead, we will run the Traefik router in TCP mode, using the &lt;a href="https://doc.traefik.io/traefik/v2.9/routing/routers/#rule_1" rel="noopener noreferrer"&gt;HostSNIRegexp&lt;/a&gt; matcher. This will run the router on layer 4 (TCP) instead of the usual layer 7 (HTTP).&lt;/p&gt;

&lt;p&gt;For more in-depth info on how this works, you can read here: &lt;a href="https://en.wikipedia.org/wiki/Server_Name_Indication" rel="noopener noreferrer"&gt;Server Name Indication (SNI)&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Now that we understand the principle, we can get to the practical implementation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Traefik HTTP and TCP routers
&lt;/h3&gt;

&lt;p&gt;Below is the complete &lt;code&gt;docker-compose.yml&lt;/code&gt; that defines the Traefik TCP router and the Rathole server with 2 HTTP/HTTPS tunnel pairs for 2 home servers: &lt;code&gt;pi&lt;/code&gt; (OrangePi) and &lt;code&gt;local&lt;/code&gt; (MiniPC), in my case.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml&lt;/span&gt;

&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3.8'&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;traefik&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik:v2.9.8&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--providers.docker=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--entrypoints.web.address=:80&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--entrypoints.websecure.address=:443&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--entrypoints.traefik.address=:8080&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--api.dashboard=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--api.insecure=false&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--log.level=DEBUG&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--accesslog=true&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;80:80&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;443:443&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;8080:8080&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run/docker.sock:/var/run/docker.sock:ro&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;proxy&lt;/span&gt;
    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# Enable the dashboard at http://traefik.amd2.nemanjamitic.com&lt;/span&gt;
      &lt;span class="c1"&gt;# http for simplicity, no acme.json file&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.enable=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.traefik.rule=Host(`traefik.amd2.${SITE_HOSTNAME}`)'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.http.routers.traefik.entrypoints=web&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.http.routers.traefik.service=api@internal&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.http.routers.traefik.middlewares=auth&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.http.middlewares.auth.basicauth.users=${TRAEFIK_AUTH}'&lt;/span&gt;

  &lt;span class="na"&gt;rathole&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rapiz1/rathole:v0.5.0&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rathole&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;--server /config/rathole.server.toml&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;2333:2333&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./rathole.server.toml:/config/rathole.server.toml:ro&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;proxy&lt;/span&gt;

    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;### HTTP port 80 - HTTP routers ###&lt;/span&gt;

      &lt;span class="c1"&gt;# pi.nemanjamitic.com, www.pi.nemanjamitic.com, *.pi.nemanjamitic.com, www.*.pi.nemanjamitic.com&lt;/span&gt;

      &lt;span class="c1"&gt;# Route *.pi.nemanjamitic.com -&amp;gt; 5080&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.rathole-pi.rule=HostRegexp(`pi.${SITE_HOSTNAME}`,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;`www.pi.${SITE_HOSTNAME}`,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;`{subdomain:[a-z0-9-]+}.pi.${SITE_HOSTNAME}`,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;`www.{subdomain:[a-z0-9-]+}.pi.${SITE_HOSTNAME}`)'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.http.routers.rathole-pi.entrypoints=web&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.http.routers.rathole-pi.service=rathole-pi&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.http.services.rathole-pi.loadbalancer.server.port=5080&lt;/span&gt;

      &lt;span class="c1"&gt;# Route *.local.nemanjamitic.com -&amp;gt; 5081&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.rathole-local.rule=HostRegexp(`local.${SITE_HOSTNAME}`,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;`www.local.${SITE_HOSTNAME}`,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;`{subdomain:[a-z0-9-]+}.local.${SITE_HOSTNAME}`,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;`www.{subdomain:[a-z0-9-]+}.local.${SITE_HOSTNAME}`)'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.http.routers.rathole-local.entrypoints=web&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.http.routers.rathole-local.service=rathole-local&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.http.services.rathole-local.loadbalancer.server.port=5081&lt;/span&gt;

      &lt;span class="c1"&gt;### HTTPS port 443 with TLS passthrough - TCP routers ###&lt;/span&gt;

      &lt;span class="c1"&gt;# Route *.pi.nemanjamitic.com -&amp;gt; 5443&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.tcp.routers.rathole-pi-secure.rule=HostSNIRegexp(`pi.${SITE_HOSTNAME}`,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;`www.pi.${SITE_HOSTNAME}`,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;`{subdomain:[a-z0-9-]+}.pi.${SITE_HOSTNAME}`,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;`www.{subdomain:[a-z0-9-]+}.pi.${SITE_HOSTNAME}`)'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.tcp.routers.rathole-pi-secure.entrypoints=websecure&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.tcp.routers.rathole-pi-secure.tls.passthrough=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.tcp.routers.rathole-pi-secure.service=rathole-pi-secure&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.tcp.services.rathole-pi-secure.loadbalancer.server.port=5443&lt;/span&gt;

      &lt;span class="c1"&gt;# Route *.local.nemanjamitic.com -&amp;gt; 5444&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.tcp.routers.rathole-local-secure.rule=HostSNIRegexp(`local.${SITE_HOSTNAME}`,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;`www.local.${SITE_HOSTNAME}`,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;`{subdomain:[a-z0-9-]+}.local.${SITE_HOSTNAME}`,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;`www.{subdomain:[a-z0-9-]+}.local.${SITE_HOSTNAME}`)'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.tcp.routers.rathole-local-secure.entrypoints=websecure&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.tcp.routers.rathole-local-secure.tls.passthrough=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.tcp.routers.rathole-local-secure.service=rathole-local-secure&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.tcp.services.rathole-local-secure.loadbalancer.server.port=5444&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;proxy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let's start with the most important part: the &lt;code&gt;labels&lt;/code&gt; on the &lt;code&gt;rathole&lt;/code&gt; container that define load balancing on the two tunnels.&lt;/p&gt;

&lt;p&gt;First, we define two HTTP routers using the &lt;code&gt;HostRegexp()&lt;/code&gt; matcher. It takes HTTP traffic from the entrypoint on port &lt;code&gt;80&lt;/code&gt; and load balances it between two tunnels on ports &lt;code&gt;5080&lt;/code&gt; and &lt;code&gt;5081&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The second pair of labels defines a TCP router that takes traffic from the HTTPS entrypoint on port &lt;code&gt;443&lt;/code&gt;, passes it through without decrypting, and load balances it between tunnels on ports &lt;code&gt;5443&lt;/code&gt; and &lt;code&gt;5444&lt;/code&gt;. Note that with the &lt;code&gt;HostSNIRegexp()&lt;/code&gt; matcher, you can't include escaped dots (&lt;code&gt;.&lt;/code&gt;) in the regex, you must repeat the entire domain sequence to handle the &lt;code&gt;www&lt;/code&gt; variant of the domain.&lt;/p&gt;

&lt;p&gt;Also note that we use separate regex variants to match the root subdomain explicitly, e.g. &lt;code&gt;pi.nemanjamitic.com&lt;/code&gt; and &lt;code&gt;www.pi.nemanjamitic.com&lt;/code&gt; for both HTTP and TCP routers.&lt;/p&gt;

&lt;p&gt;That's it, this is the main load balancing logic definition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; Because we use &lt;code&gt;HostRegexp()&lt;/code&gt; and &lt;code&gt;HostSNIRegexp()&lt;/code&gt; on the server, you will need to use &lt;code&gt;Host()&lt;/code&gt; and &lt;code&gt;HostSNI()&lt;/code&gt; matchers &lt;strong&gt;for the Traefik running on the client side of the tunnel&lt;/strong&gt;, or you will get &lt;code&gt;404&lt;/code&gt; errors without additional configuration. Regex matchers on both the server and client sides seem to be too loose.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rathole server config
&lt;/h3&gt;

&lt;p&gt;Now it's just left to write the config for the Rathole server that defines 2×2 tunnels. Just make sure to use &lt;strong&gt;a different token and port&lt;/strong&gt; for each tunnel.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# rathole.server.toml&lt;/span&gt;

&lt;span class="nn"&gt;[server]&lt;/span&gt;
&lt;span class="py"&gt;bind_addr&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0.0.0.0:2333"&lt;/span&gt;

&lt;span class="nn"&gt;[server.transport]&lt;/span&gt;
&lt;span class="py"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"noise"&lt;/span&gt;

&lt;span class="nn"&gt;[server.transport.noise]&lt;/span&gt;
&lt;span class="py"&gt;local_private_key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"private_key"&lt;/span&gt;

&lt;span class="c"&gt;# separated based on token, also can NOT use same ports&lt;/span&gt;

&lt;span class="c"&gt;# pi&lt;/span&gt;
&lt;span class="nn"&gt;[server.services.pi-traefik-http]&lt;/span&gt;
&lt;span class="py"&gt;token&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"secret_token_1"&lt;/span&gt;
&lt;span class="py"&gt;bind_addr&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0.0.0.0:5080"&lt;/span&gt;

&lt;span class="nn"&gt;[server.services.pi-traefik-https]&lt;/span&gt;
&lt;span class="py"&gt;token&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"secret_token_1"&lt;/span&gt;
&lt;span class="py"&gt;bind_addr&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0.0.0.0:5443"&lt;/span&gt;

&lt;span class="c"&gt;# local&lt;/span&gt;
&lt;span class="nn"&gt;[server.services.local-traefik-http]&lt;/span&gt;
&lt;span class="py"&gt;token&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"secret_token_2"&lt;/span&gt;
&lt;span class="py"&gt;bind_addr&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0.0.0.0:5081"&lt;/span&gt;

&lt;span class="nn"&gt;[server.services.local-traefik-https]&lt;/span&gt;
&lt;span class="py"&gt;token&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"secret_token_2"&lt;/span&gt;
&lt;span class="py"&gt;bind_addr&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0.0.0.0:5444"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Reminder:&lt;/strong&gt; You just need to open port &lt;code&gt;2333&lt;/code&gt; in the VPS firewall for the Rathole control channel and not for the ports &lt;code&gt;5080&lt;/code&gt;, &lt;code&gt;5081&lt;/code&gt;, &lt;code&gt;5443&lt;/code&gt;, or &lt;code&gt;5444&lt;/code&gt;, because they are used by Rathole internally.&lt;/p&gt;

&lt;h3&gt;
  
  
  Traefik dashboard
&lt;/h3&gt;

&lt;p&gt;Additionally, for the sake of debugging, we expose the Traefik dashboard using &lt;code&gt;labels&lt;/code&gt; on the &lt;code&gt;traefik&lt;/code&gt; container. To simplify the configuration and avoid handling the &lt;code&gt;acme.json&lt;/code&gt; file, we expose it using HTTP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Warning:&lt;/strong&gt; When setting the dashboard hashed password via the &lt;code&gt;TRAEFIK_AUTH&lt;/code&gt; environment variable, make sure to escape the &lt;code&gt;$&lt;/code&gt; characters properly or authentication will break. To do that, you need to use both double quotes &lt;code&gt;"..."&lt;/code&gt; and the escape slash '&lt;code&gt;\&lt;/code&gt;', as shown in the example below:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# install apache2-utils&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;apache2-utils

&lt;span class="c"&gt;# hash the password&lt;/span&gt;
htpasswd &lt;span class="nt"&gt;-nb&lt;/span&gt; admin yourpassword
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# .env&lt;/span&gt;

&lt;span class="c"&gt;# use BOTH "..." and \$ to escape $ properly&lt;/span&gt;

&lt;span class="c"&gt;# this will work correctly&lt;/span&gt;
&lt;span class="nv"&gt;TRAEFIK_AUTH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"admin:&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;asd1&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;E3lsdAo&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;3Mertp57JJ4LVU.HRR0"&lt;/span&gt;

&lt;span class="c"&gt;# this will break&lt;/span&gt;
&lt;span class="nv"&gt;TRAEFIK_AUTH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"admin:&lt;/span&gt;&lt;span class="nv"&gt;$asd1$E3lsdAo$3Mertp57JJ4LVU&lt;/span&gt;&lt;span class="s2"&gt;.HRR0"&lt;/span&gt;

&lt;span class="c"&gt;# this will also break&lt;/span&gt;
&lt;span class="nv"&gt;TRAEFIK_AUTH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;admin:&lt;span class="se"&gt;\$&lt;/span&gt;asd1&lt;span class="se"&gt;\$&lt;/span&gt;E3lsdAo&lt;span class="se"&gt;\$&lt;/span&gt;3Mertp57JJ4LVU.HRR0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Rathole client
&lt;/h2&gt;

&lt;p&gt;The client part of the tunnel is almost the same as for a single home server. The only thing to keep in mind is to bind the specific client only to the tunnels that are meant for it, and not to all tunnels. Kind of obvious and self-explanatory, but just in case, let's be very clear and explicit.&lt;/p&gt;

&lt;p&gt;Here, we define the &lt;code&gt;rathole.client.toml&lt;/code&gt; Rathole client config to bind the &lt;code&gt;pi&lt;/code&gt; home server to its HTTP &lt;code&gt;pi-traefik-http&lt;/code&gt; and HTTPS &lt;code&gt;pi-traefik-https&lt;/code&gt; tunnels.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# rathole.client.toml&lt;/span&gt;

&lt;span class="nn"&gt;[client]&lt;/span&gt;
&lt;span class="py"&gt;remote_addr&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"123.123.123.123:2333"&lt;/span&gt;

&lt;span class="nn"&gt;[client.transport]&lt;/span&gt;
&lt;span class="py"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"noise"&lt;/span&gt;

&lt;span class="nn"&gt;[client.transport.noise]&lt;/span&gt;
&lt;span class="py"&gt;remote_public_key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"public_key"&lt;/span&gt;

&lt;span class="c"&gt;# single client per tunnels pair&lt;/span&gt;

&lt;span class="c"&gt;# pi&lt;/span&gt;
&lt;span class="nn"&gt;[client.services.pi-traefik-http]&lt;/span&gt;
&lt;span class="py"&gt;token&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"secret_token_1"&lt;/span&gt;
&lt;span class="py"&gt;local_addr&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"traefik:80"&lt;/span&gt;

&lt;span class="nn"&gt;[client.services.pi-traefik-https]&lt;/span&gt;
&lt;span class="py"&gt;token&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"secret_token_1"&lt;/span&gt;
&lt;span class="py"&gt;local_addr&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"traefik:443"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Similarly, here we define the &lt;code&gt;rathole.client.toml&lt;/code&gt; config to bind the &lt;code&gt;local&lt;/code&gt; home server to it's HTTP &lt;code&gt;local-traefik-http&lt;/code&gt; and HTTPS &lt;code&gt;local-traefik-https&lt;/code&gt; tunnels.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# rathole.client.toml&lt;/span&gt;

&lt;span class="nn"&gt;[client]&lt;/span&gt;
&lt;span class="py"&gt;remote_addr&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"123.123.123.123:2333"&lt;/span&gt;

&lt;span class="nn"&gt;[client.transport]&lt;/span&gt;
&lt;span class="py"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"noise"&lt;/span&gt;

&lt;span class="nn"&gt;[client.transport.noise]&lt;/span&gt;
&lt;span class="py"&gt;remote_public_key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"public_key"&lt;/span&gt;

&lt;span class="c"&gt;# single client per tunnels pair&lt;/span&gt;

&lt;span class="c"&gt;# local&lt;/span&gt;
&lt;span class="nn"&gt;[client.services.local-traefik-http]&lt;/span&gt;
&lt;span class="py"&gt;token&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"secret_token_2"&lt;/span&gt;
&lt;span class="py"&gt;local_addr&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"traefik:80"&lt;/span&gt;

&lt;span class="nn"&gt;[client.services.local-traefik-https]&lt;/span&gt;
&lt;span class="py"&gt;token&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"secret_token_2"&lt;/span&gt;
&lt;span class="py"&gt;local_addr&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"traefik:443"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;docker-compose.yml&lt;/code&gt; for the Rathole client and Traefik is exactly the same as it was for a single home server. I am repeating it here just for the sake of completeness.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml&lt;/span&gt;

&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3.8'&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;rathole&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rapiz1/rathole:v0.5.0&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rathole&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;--client /config/rathole.client.toml&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./rathole.client.toml:/config/rathole.client.toml:ro&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;proxy&lt;/span&gt;

  &lt;span class="na"&gt;traefik&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik:v2.9.8'&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;rathole&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# moved from static conf to pass email as env var&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--certificatesresolvers.letsencrypt.acme.email=${TRAEFIK_LETSENCRYPT_EMAIL}'&lt;/span&gt;
    &lt;span class="na"&gt;security_opt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;no-new-privileges:true&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;proxy&lt;/span&gt;
    &lt;span class="c1"&gt;# rathole will pass traffic through proxy network directly on 80 and 443&lt;/span&gt;
    &lt;span class="c1"&gt;# defined in rathole.client.toml&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;TRAEFIK_AUTH=${TRAEFIK_AUTH}&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/etc/localtime:/etc/localtime:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run/docker.sock:/var/run/docker.sock:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./traefik-data/traefik.yml:/traefik.yml:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./traefik-data/acme.json:/acme.json&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./traefik-data/configurations:/configurations&lt;/span&gt;
    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.enable=true'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.docker.network=proxy'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.traefik-secure.entrypoints=websecure'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.traefik-secure.rule=Host(`traefik.${SITE_HOSTNAME}`)'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.traefik-secure.middlewares=user-auth@file'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.traefik-secure.service=api@internal'&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;proxy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Completed code
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Traefik load balancer and Rathole server:&lt;/strong&gt; &lt;a href="https://github.com/nemanjam/rathole-server" rel="noopener noreferrer"&gt;https://github.com/nemanjam/rathole-server&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rathole client and local Traefik:&lt;/strong&gt; &lt;a href="https://github.com/nemanjam/traefik-proxy/tree/main/core" rel="noopener noreferrer"&gt;https://github.com/nemanjam/traefik-proxy/tree/main/core&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;You can use this setup to expose as many home servers as you want, in a cost-effective and practical way, as long as your VPS has enough network bandwidth to support their traffic. It can bring your homelab to another level.&lt;/p&gt;

&lt;p&gt;What tool and method did you use to expose your home servers to the internet? Do you like this approach, are you willing to give it a try? Let me know in the comments.&lt;/p&gt;

&lt;p&gt;Happy self-hosting.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Traefik &lt;code&gt;v2.9&lt;/code&gt; &lt;code&gt;HostRegexp&lt;/code&gt; reference: &lt;a href="https://doc.traefik.io/traefik/v2.9/routing/routers/#rule" rel="noopener noreferrer"&gt;https://doc.traefik.io/traefik/v2.9/routing/routers/#rule&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Traefik &lt;code&gt;v2.9&lt;/code&gt; &lt;code&gt;HostSNIRegexp&lt;/code&gt; reference: &lt;a href="https://doc.traefik.io/traefik/v2.9/routing/routers/#rule_1" rel="noopener noreferrer"&gt;https://doc.traefik.io/traefik/v2.9/routing/routers/#rule_1&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;TLS Server Name Indication (SNI), Wikipedia &lt;a href="https://en.wikipedia.org/wiki/Server_Name_Indication" rel="noopener noreferrer"&gt;https://en.wikipedia.org/wiki/Server_Name_Indication&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>traefik</category>
      <category>docker</category>
      <category>tunnel</category>
    </item>
    <item>
      <title>Expose home server with Rathole tunnel and Traefik</title>
      <dc:creator>Nemanja Mitic</dc:creator>
      <pubDate>Thu, 01 May 2025 07:10:20 +0000</pubDate>
      <link>https://dev.to/nemanjam/expose-home-server-with-rathole-tunnel-and-traefik-1hd</link>
      <guid>https://dev.to/nemanjam/expose-home-server-with-rathole-tunnel-and-traefik-1hd</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;In the previous article, I wrote about a temporary SSH tunneling technique to bypass CGNAT. This method is not suitable for exposing permanent services, at least not without &lt;code&gt;autossh&lt;/code&gt; manager. Proper tools for this are &lt;a href="https://github.com/rapiz1/rathole" rel="noopener noreferrer"&gt;rapiz1/rathole&lt;/a&gt; or &lt;a href="https://github.com/fatedier/frp" rel="noopener noreferrer"&gt;fatedier/frp&lt;/a&gt;. I chose Rathole since it's written in Rust and offers better performance and benchmarks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A VPS server with a public IP and Docker, ideally small, you can't use ports &lt;code&gt;80&lt;/code&gt; and &lt;code&gt;443&lt;/code&gt; for any other services aside from Rathole&lt;/li&gt;
&lt;li&gt;A home server&lt;/li&gt;
&lt;li&gt;A domain name&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Architecture overview
&lt;/h2&gt;

&lt;p&gt;We will use Rathole for an encrypted tunnel between the VPS and the local network. We will also use Traefik since we want to host multiple websites on our home server, just like you would on any server.&lt;/p&gt;

&lt;p&gt;The main question is where to run Traefik:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;On the VPS&lt;/li&gt;
&lt;li&gt;On the home server&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I highly prefer option 2 because, that way, the entire configuration is stored on our home server. The home server is almost tunnel-agnostic, and you can reuse it on any tunneled or non-tunneled server. Otherwise, we would need to maintain state between the VPS and the home server, debug both together, etc.&lt;/p&gt;

&lt;p&gt;Another point is that, with option 2, we avoid the gap of unencrypted traffic on the VPS between Traefik (TLS) and Rathole (Noise Protocol). You can read more about the comparison of these two options in this article: &lt;a href="https://blog.mni.li/posts/caddy-rathole-zero-knowledge/" rel="noopener noreferrer"&gt;https://blog.mni.li/posts/caddy-rathole-zero-knowledge/&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The downside is that Rathole will exclusively occupy ports &lt;code&gt;80&lt;/code&gt; and &lt;code&gt;443&lt;/code&gt; on the VPS, preventing any other process from using them. We won't be able to run other web servers on that VPS, so it's best to use a small one dedicated to this purpose.&lt;/p&gt;

&lt;p&gt;Unless we use a load balancer &lt;a href="https://nemanjamitic.com/blog/2025-05-29-traefik-load-balancer" rel="noopener noreferrer"&gt;Load balancing multiple Rathole tunnels with Traefik HTTP and TCP routers&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9s3u4wfhv5f2c6q8vs9m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9s3u4wfhv5f2c6q8vs9m.png" alt="Rathole Traefik architecture diagram" width="800" height="443"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Rathole server
&lt;/h2&gt;

&lt;p&gt;We will run the Rathole server inside a Docker container on our VPS. Rathole uses the same binary for both the server and client, you just pass the right option (&lt;code&gt;--server&lt;/code&gt; or &lt;code&gt;--client&lt;/code&gt;) and the &lt;code&gt;.toml&lt;/code&gt; configuration file.&lt;/p&gt;

&lt;p&gt;Here is the Rathole server configuration &lt;a href="https://github.com/nemanjam/rathole-server/blob/5226ff53992abe930302098677a570151ebff927/rathole.server.toml" rel="noopener noreferrer"&gt;rathole.server.toml&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# rathole.server.toml&lt;/span&gt;

&lt;span class="nn"&gt;[server]&lt;/span&gt;
&lt;span class="py"&gt;bind_addr&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0.0.0.0:2333"&lt;/span&gt;

&lt;span class="nn"&gt;[server.transport]&lt;/span&gt;
&lt;span class="py"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"noise"&lt;/span&gt;

&lt;span class="nn"&gt;[server.transport.noise]&lt;/span&gt;
&lt;span class="py"&gt;local_private_key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"private_key"&lt;/span&gt;

&lt;span class="nn"&gt;[server.services.traefik-http]&lt;/span&gt;
&lt;span class="py"&gt;token&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"secret_token_1"&lt;/span&gt;
&lt;span class="py"&gt;bind_addr&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0.0.0.0:5080"&lt;/span&gt;

&lt;span class="nn"&gt;[server.services.traefik-https]&lt;/span&gt;
&lt;span class="py"&gt;token&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"secret_token_1"&lt;/span&gt;
&lt;span class="py"&gt;bind_addr&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0.0.0.0:5443"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let's explain it: we choose port &lt;code&gt;2333&lt;/code&gt; for the control channel and bind it to all interfaces inside the Docker container with the &lt;code&gt;0.0.0.0&lt;/code&gt; IP. We choose the &lt;code&gt;noise&lt;/code&gt; encryption protocol and specify a private key. The public key will be used on the Rathole client. The public and private key pair is generated with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;--rm&lt;/span&gt; rapiz1/rathole &lt;span class="nt"&gt;--genkey&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then we define two tunnels: one for HTTP and another for HTTPS. For the HTTP tunnel, we define the name &lt;code&gt;server.services.traefik-http&lt;/code&gt;, set the value for &lt;code&gt;token&lt;/code&gt;, and choose port &lt;code&gt;5080&lt;/code&gt;, and again we bind it to all container interfaces with &lt;code&gt;0.0.0.0&lt;/code&gt;. Similarly, for HTTPS, we set the name to &lt;code&gt;server.services.traefik-https&lt;/code&gt;, provide a &lt;code&gt;token&lt;/code&gt; value, and choose port &lt;code&gt;5443&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Every tunnel has to have a unique name, token value, and port. With that fulfilled, a single Rathole server instance can have as many Rathole clients as needed, which is pretty convenient. For example, besides the existing home server on ports &lt;code&gt;5080&lt;/code&gt; and &lt;code&gt;5443&lt;/code&gt;, we can expose another one using ports &lt;code&gt;5081&lt;/code&gt; and &lt;code&gt;5444&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Token is just a random base64 string, we generate it by running this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl rand &lt;span class="nt"&gt;-base64&lt;/span&gt; 32
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After configuration file we define a Rathole server container with &lt;a href="https://github.com/nemanjam/rathole-server/blob/5226ff53992abe930302098677a570151ebff927/docker-compose.yml" rel="noopener noreferrer"&gt;docker-compose.yml&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;rathole&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rapiz1/rathole:v0.5.0&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rathole&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;--server /config/rathole.server.toml&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# host:container&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;2333:2333&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;80:5080&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;443:5443&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./rathole.server.toml:/config/rathole.server.toml:ro&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the command, we set the &lt;code&gt;--server&lt;/code&gt; option, pass the &lt;code&gt;.toml&lt;/code&gt; configuration file, and mount it as a read-only bind-mount volume.&lt;/p&gt;

&lt;p&gt;The important part is the port mappings. Here, you can see that the Rathole server container will occupy ports &lt;code&gt;2333&lt;/code&gt;, &lt;code&gt;80&lt;/code&gt;, and &lt;code&gt;443&lt;/code&gt; exclusively on the host VPS. This practically means we won't be able to run any other web servers on ports &lt;code&gt;80&lt;/code&gt; and &lt;code&gt;443&lt;/code&gt;. We will also need to open ports &lt;code&gt;80&lt;/code&gt;, &lt;code&gt;443&lt;/code&gt;, and &lt;code&gt;2333&lt;/code&gt; in the VPS firewall. You don't need to open ports &lt;code&gt;5080&lt;/code&gt; and &lt;code&gt;5443&lt;/code&gt;, those are used only by Rathole internally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rathole client and connecting with Traefik
&lt;/h2&gt;

&lt;p&gt;We run the Rathole client and Traefik inside Docker containers on the home server. Configuring the Rathole client and connecting it to Traefik is a bit more complex and tricky.&lt;/p&gt;

&lt;p&gt;Here is the Rathole client configuration &lt;a href="https://github.com/nemanjam/traefik-proxy/blob/e8fece09e31ec99ddd21559f343d0ddea9fb55bf/core/rathole.client.toml.example" rel="noopener noreferrer"&gt;core/rathole.client.toml.example&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# core/rathole.client.toml.example&lt;/span&gt;

&lt;span class="nn"&gt;[client]&lt;/span&gt;
&lt;span class="py"&gt;remote_addr&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"123.123.123.123:2333"&lt;/span&gt;

&lt;span class="nn"&gt;[client.transport]&lt;/span&gt;
&lt;span class="py"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"noise"&lt;/span&gt;

&lt;span class="nn"&gt;[client.transport.noise]&lt;/span&gt;
&lt;span class="py"&gt;remote_public_key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"public_key"&lt;/span&gt;

&lt;span class="c"&gt;# this is the important part&lt;/span&gt;
&lt;span class="c"&gt;# Rathole knows traffic comes from 5080 and 5443, control channel told him&lt;/span&gt;
&lt;span class="c"&gt;# DON'T do ANY mapping in docker-compose.yml&lt;/span&gt;
&lt;span class="c"&gt;# just pass traffic from Rathole on ports which Traefik expects (80 and 443)&lt;/span&gt;

&lt;span class="nn"&gt;[client.services.traefik-http]&lt;/span&gt;
&lt;span class="py"&gt;token&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"secret_token_1"&lt;/span&gt;
&lt;span class="py"&gt;local_addr&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"traefik:80"&lt;/span&gt;

&lt;span class="nn"&gt;[client.services.traefik-https]&lt;/span&gt;
&lt;span class="py"&gt;token&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"secret_token_1"&lt;/span&gt;
&lt;span class="py"&gt;local_addr&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"traefik:443"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let's go through it. First, we define the VPS server IP &lt;code&gt;remote_addr&lt;/code&gt;, the control channel port &lt;code&gt;2333&lt;/code&gt;, set the &lt;code&gt;noise&lt;/code&gt; encryption protocol, and this time specify a public key &lt;code&gt;remote_public_key&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now comes the important and tricky part: defining tunnels and services. We repeat the service name and token that we used in the Rathole server config.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And now the most important part:&lt;/strong&gt; &lt;code&gt;local_addr&lt;/code&gt;, for this we target the Traefik hostname - service name from &lt;code&gt;core/docker-compose.local.yml&lt;/code&gt; and the Traefik listening ports &lt;code&gt;80&lt;/code&gt; and &lt;code&gt;443&lt;/code&gt;. That's it. It might look simple and obvious, this is the correct setup. I must emphasize: don't fall into temptation of setting any additional port mappings in &lt;code&gt;core/docker-compose.local.yml&lt;/code&gt;, functionality will break, all should be done in &lt;code&gt;core/rathole.client.toml&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Another note: You might wonder why ports &lt;code&gt;5080&lt;/code&gt; and &lt;code&gt;5443&lt;/code&gt; aren't repeated anywhere in the client config &lt;code&gt;core/rathole.client.toml&lt;/code&gt;. The answer is "no need for it", we already specified port &lt;code&gt;2333&lt;/code&gt; for the control channel, which will communicate all additional required information between the Rathole server and client.&lt;/p&gt;

&lt;p&gt;Now that we have configured the Rathole client, we need to define Rathole client and Traefik containers.&lt;/p&gt;

&lt;p&gt;Here is the Rathole client container and the important part of the Traefik container &lt;a href="https://github.com/nemanjam/traefik-proxy/blob/e8fece09e31ec99ddd21559f343d0ddea9fb55bf/core/docker-compose.local.yml" rel="noopener noreferrer"&gt;core/docker-compose.local.yml&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# core/docker-compose.local.yml&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;rathole&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# 1. default official x86 image&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rapiz1/rathole:v0.5.0&lt;/span&gt;

    &lt;span class="c1"&gt;# 2. custom built ARM image (for Raspberry pi)&lt;/span&gt;
    &lt;span class="c1"&gt;# image: nemanjamitic/my-rathole-arm64:v1.0&lt;/span&gt;

    &lt;span class="c1"&gt;# 3. build for arm - AVOID, use prebuilt ARM image above&lt;/span&gt;
    &lt;span class="c1"&gt;# build: https://github.com/rapiz1/rathole.git#main&lt;/span&gt;
    &lt;span class="c1"&gt;# platform: linux/arm64&lt;/span&gt;

    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rathole&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;--client /config/rathole.client.toml&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./rathole.client.toml:/config/rathole.client.toml:ro&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;proxy&lt;/span&gt;

  &lt;span class="na"&gt;traefik&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik:v2.9.8'&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

    &lt;span class="c1"&gt;# for this to work both services must be defined in the same docker-compose.yml file&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;rathole&lt;/span&gt;

    &lt;span class="c1"&gt;# other config...&lt;/span&gt;

    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;proxy&lt;/span&gt;

    &lt;span class="c1"&gt;# leave this commented out, just for explanation&lt;/span&gt;
    &lt;span class="c1"&gt;# Rathole will pass Traffic through proxy network directly on 80 and 443&lt;/span&gt;
    &lt;span class="c1"&gt;# defined in rathole.client.toml&lt;/span&gt;
    &lt;span class="c1"&gt;# ports:&lt;/span&gt;
    &lt;span class="c1"&gt;#   - '80:80'&lt;/span&gt;
    &lt;span class="c1"&gt;#   - '443:443'&lt;/span&gt;

    &lt;span class="c1"&gt;# other config...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let's start with the Rathole service. Similarly to the server command, we run the Rathole binary, this time in client mode with &lt;code&gt;--client&lt;/code&gt; and we pass the client config file &lt;code&gt;/config/rathole.client.toml&lt;/code&gt; which we also bind mount as volume. An important part is that we set both the Rathole and Traefik containers on the same &lt;strong&gt;external&lt;/strong&gt; network &lt;code&gt;proxy&lt;/code&gt; so they can communicate with each other and with the host.&lt;/p&gt;

&lt;p&gt;Additional notes about the Rathole image:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Always make sure to use the same Rathole image version for both the server and client for compatibility.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;x86&lt;/code&gt; - By default, Rathole provides only the &lt;code&gt;x86&lt;/code&gt; image. If your home server uses that architecture, you are good to go.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ARM&lt;/code&gt; - If you have an ARM home server (e.g., Raspberry Pi), you will have to build the image yourself or use a prebuilt, unofficial one. &lt;strong&gt;Avoid&lt;/strong&gt; building images on low-power ARM single-board computers, as it will take a long time and require a lot of RAM and CPU power. Instead, either pre-build one yourself and push it to Docker Hub, or you can reuse my &lt;code&gt;nemanjamitic/my-rathole-arm64:v1.0&lt;/code&gt; image (which uses Rathole &lt;code&gt;v0.5.0&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now, the Traefik container. It must be on the same &lt;code&gt;proxy&lt;/code&gt; external network as Rathole. Another important part: It must &lt;strong&gt;wait&lt;/strong&gt; for the Rathole container to boot up &lt;code&gt;depends_on: rathole&lt;/code&gt;, because the traffic will come from the Rathole tunnel. &lt;strong&gt;Do not&lt;/strong&gt; expose ports &lt;code&gt;80&lt;/code&gt; and &lt;code&gt;443&lt;/code&gt;, Rathole has already bound those Traefik container ports, as we defined in the Rathole client config &lt;code&gt;core/rathole.client.toml&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The rest of the Traefik container definition is left out here because it's the usual configuration, unrelated to the Rathole tunnel. Below is a quick reminder about the general Traefik configuration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Traefik reminder&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Provide the &lt;code&gt;.env&lt;/code&gt; file with variables needed for Traefik:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# .env&lt;/span&gt;

&lt;span class="nv"&gt;SITE_HOSTNAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;homeserver.my-domain.com

&lt;span class="c"&gt;# important: must put value in quotes "..." and escape $ with \$&lt;/span&gt;
&lt;span class="nv"&gt;TRAEFIK_AUTH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;

&lt;span class="c"&gt;# will receive expiration notifications&lt;/span&gt;
&lt;span class="nv"&gt;TRAEFIK_LETSENCRYPT_EMAIL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;myname@example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;On your home server host OS you must create an external Docker network:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker network create proxy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Create &lt;code&gt;acme.json&lt;/code&gt; file with permission &lt;code&gt;600&lt;/code&gt;:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;touch&lt;/span&gt; ~/homelab/traefik-proxy/core/traefik-data/acme.json

&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;600 ~/homelab/traefik-proxy/core/traefik-data/acme.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Always start with the staging Acme server for testing and swap to production once satisfied:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# core/traefik-data/traefik.yml&lt;/span&gt;

&lt;span class="na"&gt;certificatesResolvers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;letsencrypt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;acme&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# always start with staging certificate&lt;/span&gt;
      &lt;span class="na"&gt;caServer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;https://acme-staging-v02.api.letsencrypt.org/directory'&lt;/span&gt;
      &lt;span class="c1"&gt;# caServer: 'https://acme-v02.api.letsencrypt.org/directory'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;To clear the temporary staging certificates, clear the contents of &lt;code&gt;acme.json&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;truncate&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; 0 acme.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Once done, we can run Rathole client and Traefik containers on our home server with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.local.yml up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fggjzla3rvfcfaxefqk0f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fggjzla3rvfcfaxefqk0f.png" alt="Running containers on the home server" width="800" height="78"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Exposing multiple servers
&lt;/h2&gt;

&lt;p&gt;Fortunately, Rathole makes it trivial to run multiple tunnels using a single Rathole server. We don't need to open any additional ports in the firewall or run multiple container instances. What we do need are different tunnel names, token values, and ports. Those must be unique for each tunnel/service. Also, you will need a load balancer to bind ports &lt;code&gt;80&lt;/code&gt; and &lt;code&gt;443&lt;/code&gt; to more than one destination port, respectively.&lt;/p&gt;

&lt;p&gt;I wrote a detailed tutorial on how to expose multiple home servers using a single Rathole server. You can read it here: &lt;a href="https://nemanjamitic.com/blog/2025-05-29-traefik-load-balancer" rel="noopener noreferrer"&gt;Load balancing multiple Rathole tunnels with Traefik HTTP and TCP routers&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open the firewall on the VPS
&lt;/h2&gt;

&lt;p&gt;Like for any webserver, on the VPS you will need to open ports &lt;code&gt;80&lt;/code&gt; and &lt;code&gt;443&lt;/code&gt; to listen for HTTP/HTTPS traffic. Additionally you will need to open the port &lt;code&gt;2333&lt;/code&gt; for the Rathole control channel - tunnel.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxbi42j6rbl343zix6mxp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxbi42j6rbl343zix6mxp.png" alt="Opened port for Rathole tunnel in the firewall" width="800" height="161"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Completed code
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rathole server:&lt;/strong&gt; &lt;a href="https://github.com/nemanjam/rathole-server" rel="noopener noreferrer"&gt;https://github.com/nemanjam/rathole-server&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rathole client and local Traefik:&lt;/strong&gt; &lt;a href="https://github.com/nemanjam/traefik-proxy/tree/main/core" rel="noopener noreferrer"&gt;https://github.com/nemanjam/traefik-proxy/tree/main/core&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Most consumer-grade internet connections are behind a CGNAT. This setup allows you to bypass CGNAT and host an unlimited number of websites on your home server almost for free. You can use it for web servers in virtual machines, LXC containers, SBC computers, etc. - anywhere you can run Docker.&lt;/p&gt;

&lt;p&gt;It is simple, cheap, and you can set it up in 30 minutes. Like anything, it also has some downsides, one of them is the overhead latency caused by an additional network hop between the VPS and your home network, but it's a reasonable tradeoff.&lt;/p&gt;

&lt;p&gt;Did you make something similar yourself? Can you see room for improvement? Did you use a different method? You tried to run the code and need help with troubleshooting? Let me know in the comments.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjenj0e5wzt25wpnyvc4w.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjenj0e5wzt25wpnyvc4w.gif" alt="Orange Pi hero image" width="400" height="225"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Rathole repository &lt;a href="https://github.com/rapiz1/rathole" rel="noopener noreferrer"&gt;https://github.com/rapiz1/rathole&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Local or remote Traefik discussion &lt;a href="https://github.com/rapiz1/rathole/issues/169" rel="noopener noreferrer"&gt;https://github.com/rapiz1/rathole/issues/169&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Local and remote Traefik comparison, Tailscale benchmarks &lt;a href="https://blog.mni.li/posts/caddy-rathole-zero-knowledge/" rel="noopener noreferrer"&gt;https://blog.mni.li/posts/caddy-rathole-zero-knowledge/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Rathole Docker example configuration &lt;a href="https://nitinja.in/tech/" rel="noopener noreferrer"&gt;https://nitinja.in/tech/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Rathole &lt;code&gt;.toml&lt;/code&gt; environment variables discussion &lt;a href="https://github.com/rapiz1/rathole/issues/218" rel="noopener noreferrer"&gt;https://github.com/rapiz1/rathole/issues/218&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;frp repository &lt;a href="https://github.com/fatedier/frp" rel="noopener noreferrer"&gt;https://github.com/fatedier/frp&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>docker</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Expose local dev server with SSH tunnel and Docker</title>
      <dc:creator>Nemanja Mitic</dc:creator>
      <pubDate>Wed, 23 Apr 2025 09:44:06 +0000</pubDate>
      <link>https://dev.to/nemanjam/expose-local-dev-server-with-ssh-tunnel-and-docker-4aha</link>
      <guid>https://dev.to/nemanjam/expose-local-dev-server-with-ssh-tunnel-and-docker-4aha</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Most consumer-grade internet connections are hidden behind CG-NAT and are not reachable from the internet. This is done to save IP addresses, as IPv4 has a limited range. If you have a static public IPv4 or any IPv6 address, you won’t need the setup from this tutorial.&lt;/p&gt;

&lt;p&gt;There are already services like &lt;a href="https://github.com/localtunnel/localtunnel" rel="noopener noreferrer"&gt;localtunnel&lt;/a&gt; or &lt;a href="https://github.com/ngrok" rel="noopener noreferrer"&gt;ngrok&lt;/a&gt; for this purpose, but when you actually start using them, you will often find out that they have limitations on their free plans. So, we will configure our own custom setup once and have it always available for convenient and practical usage which will save a lot of time and nerves in the long run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is this useful
&lt;/h2&gt;

&lt;p&gt;This is useful whenever you need to share your local project with others or provide a publicly accessible URL for your service so that external systems can reach it. This is often the case if you work remotely.&lt;/p&gt;

&lt;p&gt;Yes, you can use test deployments, but having a tunnel setup configured and being able to run it with a single terminal command saves a lot of time and energy.&lt;/p&gt;

&lt;p&gt;Possible use cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sharing work in progress with clients or teammates&lt;/li&gt;
&lt;li&gt;Remote debugging or pair programming&lt;/li&gt;
&lt;li&gt;Demos for presentations or team meetings&lt;/li&gt;
&lt;li&gt;Testing the frontend on different devices (mobile, different resolution, OS, browser)&lt;/li&gt;
&lt;li&gt;Testing webhooks from external services (Stripe, GitHub, Oauth, Slack, Contentful, Twilio, etc.)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A VPS server with a public IP and Docker&lt;/li&gt;
&lt;li&gt;A local machine with a working SSH and dev server that you want to expose&lt;/li&gt;
&lt;li&gt;A domain name (optional)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Demo video
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/EPKlTb7annI"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture overview
&lt;/h2&gt;

&lt;p&gt;Without going too deep into computer networking theory, let's explain port forwarding in simplified terms. Port forwarding is a mapping (binding) between two points (services) on a private network (or even on the same machine) that would otherwise be unreachable. You can think of it as a VPN for a single service (port).&lt;/p&gt;

&lt;p&gt;So it's exactly what we need: we want to bind (redirect traffic from) a public port (1081 in our case) on the VPS, which acts as a gateway, to port 3000 on our local dev server that is not directly reachable from the internet. That’s it for the tunneling part, this setup is sufficient for serving HTTP traffic.&lt;/p&gt;

&lt;p&gt;Additionally, to support HTTPS and provide a user-friendly URL, we will add Traefik, which will handle HTTPS certificates and route traffic from port 443 to port 1081 of the tunnel.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Felolb86z14ce5xoz83ga.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Felolb86z14ce5xoz83ga.png" alt="SSH tunnel architecture diagram"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Running the SSH server in Docker
&lt;/h2&gt;

&lt;p&gt;We already use SSH to access our VPS, but we prefer to keep that configuration untouched. So, we will run a separate SSH server inside a Docker container specifically for tunneling.&lt;/p&gt;

&lt;p&gt;For this, we will use &lt;a href="https://github.com/linuxserver/docker-openssh-server" rel="noopener noreferrer"&gt;linuxserver/openssh-server&lt;/a&gt; image. &lt;a href="https://github.com/linuxserver" rel="noopener noreferrer"&gt;Linuxserver&lt;/a&gt; is an organization that maintains very stable Dokcer images for all kinds of purposes.&lt;/p&gt;

&lt;p&gt;By default, the SSH server doesn’t allow tunneling, so we need to modify the config in &lt;code&gt;/etc/ssh/sshd_config&lt;/code&gt; and enable it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/ssh/sshd_config&lt;/span&gt;

AllowTcpForwarding &lt;span class="nb"&gt;yes
&lt;/span&gt;GatewayPorts &lt;span class="nb"&gt;yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nano /etc/ssh/sshd_config

&lt;span class="c"&gt;# edit config...&lt;/span&gt;

&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart sshd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But since we are using a Docker container, we will do it differently.&lt;/p&gt;

&lt;p&gt;We will use &lt;a href="https://github.com/linuxserver/docker-mods/tree/openssh-server-ssh-tunnel" rel="noopener noreferrer"&gt;openssh-server-ssh-tunnel&lt;/a&gt; mod, which enables tunnelling in the &lt;code&gt;linuxserver/openssh-server&lt;/code&gt; image. You can think of mods as presets (additional layers and configurations) for these images.&lt;/p&gt;

&lt;p&gt;Here is &lt;code&gt;docker-compose.yml&lt;/code&gt; for the SSH tunnel container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml&lt;/span&gt;

&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3.8'&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;openssh-server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;linuxserver/openssh-server&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;openssh-server&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;openssh-server&lt;/span&gt; &lt;span class="c1"&gt;#optional&lt;/span&gt;
    &lt;span class="na"&gt;expose&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="m"&gt;1081&lt;/span&gt; &lt;span class="c1"&gt;# tunneled service port, for Traefik&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;1080:2222&lt;/span&gt; &lt;span class="c1"&gt;# 1080 is the main SSH connection port&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# https://github.com/linuxserver/docker-mods/tree/openssh-server-ssh-tunnel&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DOCKER_MODS=linuxserver/mods:openssh-server-ssh-tunnel&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SHELL_NOLOGIN=false&lt;/span&gt;
      &lt;span class="c1"&gt;# set correct for current host user&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PUID=1001&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PGID=1001&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;TZ=Etc/UTC&lt;/span&gt;
      &lt;span class="c1"&gt;# important&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PUBLIC_KEY&lt;/span&gt;
      &lt;span class="c1"&gt;# optional env vars bellow&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SUDO_ACCESS=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;USER_NAME=username&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PASSWORD_ACCESS=false&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./config:/config&lt;/span&gt;
    &lt;span class="c1"&gt;# Traefik configuration bellow&lt;/span&gt;
    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.enable=true'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.docker.network=proxy'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.ssh-tunnel.rule=Host(`preview.${SITE_HOSTNAME}`)'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.ssh-tunnel.entrypoints=websecure'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.ssh-tunnel.service=ssh-tunnel'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.http.services.ssh-tunnel.loadbalancer.server.port=1081'&lt;/span&gt; &lt;span class="c1"&gt;# matches exposed port&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;proxy&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;proxy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lets explain the code above:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;openssh-server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;linuxserver/openssh-server&lt;/span&gt;
    &lt;span class="c1"&gt;# ...&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;1080:2222&lt;/span&gt; &lt;span class="c1"&gt;# 1080 is the main SSH connection port&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By default, &lt;code&gt;linuxserver/docker-openssh-server&lt;/code&gt; runs the SSH service on port &lt;code&gt;2222&lt;/code&gt;, to avoid conflicting with the usual port &lt;code&gt;22&lt;/code&gt; that is used for host's SSH service and it's hardcoded in the &lt;a href="https://github.com/linuxserver/docker-openssh-server/blob/76dd1c4a0101a694ec848c1e975c9e33a7945d0a/Dockerfile#L39" rel="noopener noreferrer"&gt;Dockerfile&lt;/a&gt;. We will choose &lt;strong&gt;port &lt;code&gt;1080&lt;/code&gt; for the main SSH connection&lt;/strong&gt;, so we need to map it to port &lt;code&gt;2222&lt;/code&gt; with SSH in the container. Port &lt;code&gt;1080&lt;/code&gt; is used for the actual connection over the internet, and &lt;strong&gt;it is required to allow that port in VPS firewall.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So, let's establish clear and precise naming from the beginning:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Port &lt;code&gt;1080&lt;/code&gt; - the main SSH connection port&lt;/li&gt;
&lt;li&gt;Ports &lt;code&gt;1081, 1082, 1083, ...&lt;/code&gt; - tunneled services remote ports&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Additionally, you need to configure the SSH client on your dev machine to use port &lt;code&gt;1080&lt;/code&gt; for SSH when connecting to this container. In this example I have named VPS host &lt;code&gt;amd1&lt;/code&gt; and SSH container host &lt;code&gt;amd1c&lt;/code&gt;, you can use your own naming logic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# ~/.ssh/config&lt;/span&gt;

&lt;span class="c"&gt;# ssh amd1 ssh container&lt;/span&gt;
Host amd1c 123.123.123.123 &lt;span class="c"&gt;# VPS IP&lt;/span&gt;
    HostName 123.123.123.123
    IdentityFile ~/.ssh/my-keys/amd1_ssh_container__id_ed25519 &lt;span class="c"&gt;# private key file name&lt;/span&gt;
    User username
    Port 1080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the client SSH config above, you will notice the private key file &lt;code&gt;amd1_ssh_container__id_ed25519&lt;/code&gt;. The public key is passed to the SSH container as an environment variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;openssh-server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;linuxserver/openssh-server&lt;/span&gt;
    &lt;span class="c1"&gt;# ...&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PUBLIC_KEY&lt;/span&gt; &lt;span class="c1"&gt;# important&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You generate SSH key pairs as usual, e.g.:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; ed25519 &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"myemail@gmail.com"&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; ~/.ssh/my-keys/amd1_ssh_container__id_ed25519
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, we choose which remote port we will use to expose our local dev server. If you're using Traefik and don’t access this port directly via the browser, you don’t need to allow it in the VPS's firewall.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;openssh-server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;linuxserver/openssh-server&lt;/span&gt;
    &lt;span class="c1"&gt;# ...&lt;/span&gt;
    &lt;span class="na"&gt;expose&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="m"&gt;1081&lt;/span&gt; &lt;span class="c1"&gt;# tunneled service remote port&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The other environment variables worth mentioning are the following:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;openssh-server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;linuxserver/openssh-server&lt;/span&gt;
    &lt;span class="c1"&gt;# ...&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# https://github.com/linuxserver/docker-mods/tree/openssh-server-ssh-tunnel&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DOCKER_MODS=linuxserver/mods:openssh-server-ssh-tunnel&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PUID=1001&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PGID=1001&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We use &lt;code&gt;DOCKER_MODS&lt;/code&gt; variable to specify &lt;code&gt;openssh-server-ssh-tunnel&lt;/code&gt; mod. &lt;code&gt;PUID&lt;/code&gt; and &lt;code&gt;PGID&lt;/code&gt; are user and group IDs used to handle permissions between the host and the container. You get their values by running &lt;code&gt;id -u &amp;amp;&amp;amp; id -g&lt;/code&gt; on the VPS host. It is also a good idea to export them as global environment variables in &lt;code&gt;~/.bashrc&lt;/code&gt; file to make them available for all containers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# ~/.bashrc&lt;/span&gt;

&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;MY_UID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;MY_GID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then you can pass them like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml&lt;/span&gt;

&lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PUID=$MY_UID&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PGID=$MY_GID&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SSH tunnel is now configured. Now you can access your local dev server by HTTP via the your VPS IP e.g. &lt;code&gt;http://123.123.123.123:1081&lt;/code&gt; or domain &lt;code&gt;http://my-domain.com:1081&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuring HTTPS with Traefik
&lt;/h2&gt;

&lt;p&gt;Some browsers disallow insecure HTTP traffic by default, and you need to tweak the browser settings to allow it explicitly. This can be inconvenient when sending a demo link to a non-technical person. Additionally, some OAuth providers require HTTPS even for testing (e.g. Facebook). So let's make an extra effort to do things properly and configure a HTTPS with a subdomain using Traefik.&lt;/p&gt;

&lt;p&gt;If you are running a VPS, chances are you already use a reverse proxy for handling certificates and subdomain routing. This example shows how to do it with Traefik.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;openssh-server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;linuxserver/openssh-server&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;openssh-server&lt;/span&gt;
    &lt;span class="c1"&gt;# ...&lt;/span&gt;
    &lt;span class="na"&gt;expose&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="m"&gt;1081&lt;/span&gt; &lt;span class="c1"&gt;# tunneled service remote port&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;1080:2222&lt;/span&gt; &lt;span class="c1"&gt;# 1080 is the main SSH connection port&lt;/span&gt;

    &lt;span class="c1"&gt;# Traefik configuration bellow&lt;/span&gt;
    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.enable=true'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.docker.network=proxy'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.ssh-tunnel.rule=Host(`preview.${SITE_HOSTNAME}`)'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.ssh-tunnel.entrypoints=websecure'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.ssh-tunnel.service=ssh-tunnel'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.http.services.ssh-tunnel.loadbalancer.server.port=1081'&lt;/span&gt; &lt;span class="c1"&gt;# matches exposed port&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;proxy&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;proxy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The truth is, there is not much work to do here. All you need to do is to map the remote port of the tunnel &lt;code&gt;1081&lt;/code&gt; to Traefik and define the URL on which you want to expose your local dev server via the environment variable e.g. &lt;code&gt;SITE_HOSTNAME=preview.my-domain.com&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Everything else is just generic Traefik configuration. Also, don't forget to add the wildcard A record for your subdomains (e.g., you might add a &lt;code&gt;*.tunnels&lt;/code&gt; "namespace") in your DNS provider's dashboard and point it to your VPS IP. Additionally, create an external Docker network, e.g. named &lt;code&gt;proxy&lt;/code&gt; as shown in the example above.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;openssh-server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;linuxserver/openssh-server&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;openssh-server&lt;/span&gt;
    &lt;span class="c1"&gt;# ...&lt;/span&gt;
    &lt;span class="na"&gt;expose&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="m"&gt;1081&lt;/span&gt; &lt;span class="c1"&gt;# tunneled service remote port, passed to Traefik&lt;/span&gt;

    &lt;span class="c1"&gt;# ...&lt;/span&gt;
    &lt;span class="c1"&gt;# Traefik configuration bellow&lt;/span&gt;
    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# ...&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.ssh-tunnel.rule=Host(`preview.${SITE_HOSTNAME}`)'&lt;/span&gt; &lt;span class="c1"&gt;# in .env file: SITE_HOSTNAME=my-domain.com&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.http.services.ssh-tunnel.loadbalancer.server.port=1081'&lt;/span&gt; &lt;span class="c1"&gt;# matches the exposed port&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the end, you just need to define 2 environment variables for your &lt;code&gt;docker-compose.yml&lt;/code&gt; inside the &lt;code&gt;.env&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# .env&lt;/span&gt;

&lt;span class="c"&gt;# full with subdomain, without 'https://'&lt;/span&gt;
&lt;span class="nv"&gt;SITE_HOSTNAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;my-domain.com &lt;span class="c"&gt;# or e.g. preview.my-domain.com&lt;/span&gt;

&lt;span class="c"&gt;# public ssh key&lt;/span&gt;
&lt;span class="nv"&gt;PUBLIC_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;my-public-ssh-key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Above is shown only the relevant Traefik configuration for the SSH tunnel container. A complete Traefik reverse proxy configuration requires additional static and dynamic configurations for the Traefik container, but that is outside the scope of this tutorial. You can search for examples of Traefik configurations or reuse mine, which is available in this repository: &lt;a href="https://github.com/nemanjam/traefik-proxy" rel="noopener noreferrer"&gt;nemanjam/traefik-proxy&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tunneling multiple services
&lt;/h2&gt;

&lt;p&gt;Sometimes your app runs more than a single service, e.g. frontend and backend. If you expose just the frontend from port 3000, note that &lt;code&gt;localhost&lt;/code&gt; from, e.g. &lt;code&gt;localhost:5000&lt;/code&gt; won't be resolved. Therefore, you need to tunnel all services and set the tunneled URLs in your &lt;code&gt;.env&lt;/code&gt; files.&lt;/p&gt;

&lt;p&gt;How to have more than one tunnel? Your first thought might be to run multiple SSH server containers, but fortunately, that is not necessary. You can tunnel as many services as you want through a single SSH connection. You just need to expose multiple ports on the SSH container and map them to multiple Traefik hosts with labels, as shown below:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml&lt;/span&gt;

&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3.8'&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;openssh-server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;linuxserver/openssh-server&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;openssh-server&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;openssh-server&lt;/span&gt; &lt;span class="c1"&gt;#optional&lt;/span&gt;
    &lt;span class="c1"&gt;# tunneled services, remote ports&lt;/span&gt;
    &lt;span class="na"&gt;expose&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="m"&gt;1081&lt;/span&gt; &lt;span class="c1"&gt;# tunnel1&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="m"&gt;1082&lt;/span&gt; &lt;span class="c1"&gt;# tunnel2&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="m"&gt;1083&lt;/span&gt; &lt;span class="c1"&gt;# tunnel3&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;1080:2222&lt;/span&gt; &lt;span class="c1"&gt;# 1080 is the main SSH connection port&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# https://github.com/linuxserver/docker-mods/tree/openssh-server-ssh-tunnel&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DOCKER_MODS=linuxserver/mods:openssh-server-ssh-tunnel&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SHELL_NOLOGIN=false&lt;/span&gt;
      &lt;span class="c1"&gt;# set correct for current host user&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PUID=1001&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PGID=1001&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;TZ=Etc/UTC&lt;/span&gt;
      &lt;span class="c1"&gt;# important&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PUBLIC_KEY&lt;/span&gt;
      &lt;span class="c1"&gt;# optional env vars bellow&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SUDO_ACCESS=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;USER_NAME=username&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PASSWORD_ACCESS=false&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./config:/config&lt;/span&gt;
    &lt;span class="c1"&gt;# Traefik configuration bellow&lt;/span&gt;
    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# common config&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.enable=true'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.docker.network=proxy'&lt;/span&gt;

      &lt;span class="c1"&gt;# tunnel1 (port 3000 -&amp;gt; 1081)&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.ssh-tunnel1.rule=Host(`preview1.${SITE_HOSTNAME}`)'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.ssh-tunnel1.entrypoints=websecure'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.ssh-tunnel1.service=ssh-tunnel1'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.http.services.ssh-tunnel1.loadbalancer.server.port=1081'&lt;/span&gt;

      &lt;span class="c1"&gt;# tunnel2 (port 5000 -&amp;gt; 1082)&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.ssh-tunnel2.rule=Host(`preview2.${SITE_HOSTNAME}`)'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.ssh-tunnel2.entrypoints=websecure'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.ssh-tunnel2.service=ssh-tunnel2'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.http.services.ssh-tunnel2.loadbalancer.server.port=1082'&lt;/span&gt;

      &lt;span class="c1"&gt;# tunnel3 (port 5001 -&amp;gt; 1083)&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.ssh-tunnel3.rule=Host(`preview3.${SITE_HOSTNAME}`)'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.ssh-tunnel3.entrypoints=websecure'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.ssh-tunnel3.service=ssh-tunnel3'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;traefik.http.services.ssh-tunnel3.loadbalancer.server.port=1083'&lt;/span&gt;

    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;proxy&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;proxy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you have a large number of services to tunnel, you might want to use a VPN to access all ports by default, but that's rarely the case.&lt;/p&gt;

&lt;p&gt;Another point to make is that the SSH tunnel technique is most suitable for temporarily exposing services for demo purposes. For permanent tunnels, you would need to add &lt;code&gt;autossh&lt;/code&gt; to keep the connection alive, but there are better tools for permanent tunnels, such as &lt;a href="https://github.com/rapiz1/rathole" rel="noopener noreferrer"&gt;rapiz1/rathole&lt;/a&gt; or &lt;a href="https://github.com/fatedier/frp" rel="noopener noreferrer"&gt;fatedier/frp&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open the firewall on the VPS
&lt;/h2&gt;

&lt;p&gt;For the main SSH connection, you will need to open a port in your VPS firewall, port &lt;code&gt;1080&lt;/code&gt; in this example. Additionally, if you want to access tunnels directly via a port in the browser without Traefik, you will need to open those ports as well. Be mindful not to open too many unnecessary ports, as every newly opened port increases the attack surface.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8rc6a2a97h2swdtlji2l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8rc6a2a97h2swdtlji2l.png" alt="Example opened ports in the firewall"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Running the tunnel
&lt;/h2&gt;

&lt;p&gt;You start the tunnel with a single command like below. The &lt;code&gt;-R&lt;/code&gt; option means remote port forwarding, followed by two &lt;code&gt;IP:port&lt;/code&gt; pairs. The first pair is remote, and the second is local. At the end, you have the VPS host.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# command format&lt;/span&gt;
ssh &lt;span class="nt"&gt;-R&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;remote_addr:]remote_port:local_addr:local_port &lt;span class="o"&gt;[&lt;/span&gt;user@]gateway_addr

&lt;span class="c"&gt;# example:&lt;/span&gt;
&lt;span class="c"&gt;# amd1c host is defined in ~/.ssh/config&lt;/span&gt;
ssh &lt;span class="nt"&gt;-R&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;:1081:localhost:3000 amd1c

&lt;span class="c"&gt;# access the url, e.g.&lt;/span&gt;
https://preview1.my-domain.com

&lt;span class="c"&gt;# terminate tunnel, like any ssh connection&lt;/span&gt;
&lt;span class="nb"&gt;exit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can open multiple tunnels with a single command. Just specify the tunnels one after another before the host. Note that you must have these tunnels defined in your &lt;code&gt;docker-compose.yml&lt;/code&gt; for the SSH server (exposed ports and Traefik host labels).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# tunnel frontend at port 3000 and backend at port 5000&lt;/span&gt;
ssh &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-R&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;:1081:localhost:3000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-R&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;:1082:localhost:5000 &lt;span class="se"&gt;\&lt;/span&gt;
  amd1c

&lt;span class="c"&gt;# access the urls, e.g.&lt;/span&gt;
https://preview1.my-domain.com
https://preview2.my-domain.com/api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Completed code
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SSH tunnel configuration:&lt;/strong&gt; &lt;a href="https://github.com/nemanjam/traefik-proxy/tree/a1feed0a4d7dba53f39bb2d5431c0e4d2e170336/apps/ssh-server" rel="noopener noreferrer"&gt;https://github.com/nemanjam/traefik-proxy/tree/main/apps/ssh-server&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Traefik configuration:&lt;/strong&gt; &lt;a href="https://github.com/nemanjam/traefik-proxy/tree/a1feed0a4d7dba53f39bb2d5431c0e4d2e170336/core" rel="noopener noreferrer"&gt;https://github.com/nemanjam/traefik-proxy/tree/main/core&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Port forwarding is a basic networking technique that is very familiar to network engineers, but perhaps not often utilized by developers. It can be very useful and practical, especially in a remote work setting. As described in this tutorial, you just need to run a single container, configure the client and firewall, and once you have it set up, it can save you a lot of time and energy in the long run.&lt;/p&gt;

&lt;p&gt;SSH remote port forwarding is just one of the many useful and cool SSH networking tricks. There are many others like dynamic port forwarding, SSH agent forwarding, X11 forwarding, SSH file system, etc. Do you use some of them? Please share in the comments bellow.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Local and remote port forwarding tutorial &lt;a href="https://iximiuz.com/en/posts/ssh-tunnels" rel="noopener noreferrer"&gt;https://iximiuz.com/en/posts/ssh-tunnels&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;linuxserver/docker-openssh-server&lt;/code&gt; image repository &lt;a href="https://github.com/linuxserver/docker-openssh-server" rel="noopener noreferrer"&gt;https://github.com/linuxserver/docker-openssh-server&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;openssh-server-ssh-tunnel&lt;/code&gt; mod repository &lt;a href="https://github.com/linuxserver/docker-mods/tree/openssh-server-ssh-tunnel" rel="noopener noreferrer"&gt;https://github.com/linuxserver/docker-mods/tree/openssh-server-ssh-tunnel&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Useful discussion that suggests to use the existing tunnel mod &lt;a href="https://github.com/linuxserver/docker-openssh-server/issues/22" rel="noopener noreferrer"&gt;https://github.com/linuxserver/docker-openssh-server/issues/22&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The list of all available Linuxserver mods &lt;a href="https://github.com/linuxserver/docker-mods" rel="noopener noreferrer"&gt;https://github.com/linuxserver/docker-mods&lt;/a&gt;, &lt;a href="https://mods.linuxserver.io" rel="noopener noreferrer"&gt;https://mods.linuxserver.io&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The list of all available Linuxserver images &lt;a href="https://www.linuxserver.io/our-images" rel="noopener noreferrer"&gt;https://www.linuxserver.io/our-images&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>docker</category>
      <category>ssh</category>
    </item>
    <item>
      <title>Build a random image component with Astro and React</title>
      <dc:creator>Nemanja Mitic</dc:creator>
      <pubDate>Wed, 09 Apr 2025 09:59:16 +0000</pubDate>
      <link>https://dev.to/nemanjam/build-a-random-image-component-with-astro-and-react-4p5f</link>
      <guid>https://dev.to/nemanjam/build-a-random-image-component-with-astro-and-react-4p5f</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;For the sake of practice and fun let's build a component that displays a random image on mouse click. It looks more fun and interactive than a static hero image. You can see it in action on the Home page of the my website.&lt;/p&gt;

&lt;p&gt;This functionality shares some common parts with the image gallery described in the previous article, such as the component hierarchy and including urls in the client. However, it also introduces some new elements, like a proper blur preloader.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we will be building
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Demo:&lt;/strong&gt; &lt;a href="https://nemanjamitic.com/" rel="noopener noreferrer"&gt;https://nemanjamitic.com/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Github repository:&lt;/strong&gt; &lt;a href="https://github.com/nemanjam/nemanjam.github.io" rel="noopener noreferrer"&gt;https://github.com/nemanjam/nemanjam.github.io&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/mMlD-0Ixw4c"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Component hierarchy
&lt;/h2&gt;

&lt;p&gt;Again, we will use the similar structure &lt;code&gt;MDX (index.mdx) -&amp;gt; Astro component (ImageRandom.astro) -&amp;gt; React components (ImageRandomReact.jsx and ImageBlurPreloader.jsx)&lt;/code&gt;, and again, the client React components contain the most complexity.&lt;/p&gt;

&lt;p&gt;Code (paraphrased):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/pages/index.mdx&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ImageRandom&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// src/components/ImageRandom.astro&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ImageRandomReact&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;galleryImages&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;client&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="na"&gt;load&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// src/components/react/ImageRandomReact.tsx&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ImageBlurPreloader&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Responsive image
&lt;/h2&gt;

&lt;p&gt;This is the functionality shared with the image gallery. This time, we’ll use a fixed low-resolution image for the blur effect and a responsive, high-resolution hero image as the main one. The blur and main images will have different resolutions but share the same &lt;code&gt;16:9&lt;/code&gt; aspect ratio.&lt;/p&gt;

&lt;p&gt;The code is as follows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/constants/image.ts&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;IMAGE_SIZES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;FIXED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// blur image&lt;/span&gt;
    &lt;span class="na"&gt;BLUR_16_9&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;RESPONSIVE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// main image&lt;/span&gt;
    &lt;span class="na"&gt;POST_HERO&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;widths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;TW_SCREENS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;XS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;TW_SCREENS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;TW_SCREENS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;TW_SCREENS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LG&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;sizes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`(max-width: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;TW_SCREENS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;XS&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px) &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;TW_SCREENS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;XS&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px, (max-width: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;TW_SCREENS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SM&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px) &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;TW_SCREENS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SM&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px, (max-width: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;TW_SCREENS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MD&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px) &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;TW_SCREENS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MD&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;TW_SCREENS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LG&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// actual &amp;lt;img /&amp;gt; tag attributes that are generated with the POST_HERO&lt;/span&gt;

&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;
  &lt;span class="nx"&gt;sizes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;(max-width: 475px) 475px, (max-width: 640px) 640px, (max-width: 768px) 768px, 1024px&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;3264&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1836&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="nx"&gt;srcset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;
    /_astro/amfi1.Cv2xkJ5B_1Lofkq.webp 475w,
    /_astro/amfi1.Cv2xkJ5B_Oxmi8.webp 640w,
    /_astro/amfi1.Cv2xkJ5B_X0wXS.webp 768w,
    /_astro/amfi1.Cv2xkJ5B_Z1u01H4.webp 1024w
  &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/_astro/amfi1.Cv2xkJ5B_26HGs8.webp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// src/libs/gallery/transform.ts&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;heroImageOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;IMAGE_SIZES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RESPONSIVE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;POST_HERO&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// src/libs/gallery/images.ts&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getHeroImages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HeroImage&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getCustomImages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blurImageOptions&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hero&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getCustomImages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;heroImageOptions&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;heroImages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mergeArrays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blur&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hero&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;blur&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hero&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;blur&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;imageResultToImageAttributes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blur&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;hero&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;imageResultToImageAttributes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hero&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;}));&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;heroImages&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// src/components/ImageRandom.astro&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;galleryImages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getHeroImages&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Responsive main image in action:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/_a8j9HeLLnk"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Random image in a static website
&lt;/h2&gt;

&lt;p&gt;Again, we have the same situation as in the image gallery. The key point is to include all image urls in the client and execute &lt;code&gt;getRandomElementFromArray()&lt;/code&gt; in the client React component to display a random image at runtime. If we called the random function on the server, in the Astro component, we would end up with a single image that was randomly picked at build time - which is not what we want.&lt;/p&gt;

&lt;p&gt;This is the code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="o"&gt;---&lt;/span&gt;
&lt;span class="c1"&gt;// src/components/ImageRandom.astro&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;galleryImages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getHeroImages&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;---&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* include all the images in the client and let the client pick the random image */&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ImageRandomReact&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;galleryImages&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;client&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="na"&gt;load&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// src/components/react/ImageRandom.tsx&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ImageRandomReact&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FC&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Props&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;galleryImages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;divClassName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// cache randomized images&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;randomImage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMemo&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getRandomElementFromArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;galleryImages&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;galleryImages&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setImage&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;initialImage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// pick initial random image on mount&lt;/span&gt;
  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;randomImage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;setImage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;randomImage&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="c1"&gt;// pick random image onClick&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleClick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;randomImage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getRandomElementFromArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;galleryImages&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;setImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;randomImage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ImageBlurPreloader&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;blurAttributes&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;blur&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Blur image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;mainAttributes&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hero&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;handleClick&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Hero image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;cn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cursor-pointer my-0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;divClassName&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;divClassName&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Blur preloader
&lt;/h2&gt;

&lt;p&gt;This is the most interesting part of the feature. The first instinct when swapping the blur and main images might be to use a ternary operator to mount or unmount the appropriate image. But we actually can’t do that here. Why? Because both images need to remain mounted in the DOM to ensure the &lt;code&gt;onLoad&lt;/code&gt; event works correctly for both the blur and main images. So instead of unmounting, we will use absolute positioning to place the main image above the blur image and toggle its opacity to show or hide it.&lt;/p&gt;

&lt;p&gt;But there is more. Note that with the &lt;code&gt;onLoad&lt;/code&gt; event, we have three possible values for the image’s src attribute (although the main image actually uses the &lt;code&gt;srcset&lt;/code&gt; and &lt;code&gt;sizes&lt;/code&gt; attributes). These are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;An empty string &lt;code&gt;''&lt;/code&gt; when both blur and main images are still loading. In this case we will show an empty &lt;code&gt;&amp;lt;div /&amp;gt;&lt;/code&gt; of the same size as the main image.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;src&lt;/code&gt; attribute of the blur image, when the blur image is loaded but the main image is still loading.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;srcset&lt;/code&gt; and &lt;code&gt;sizes&lt;/code&gt; attributes of the main image, once the main image has fully loaded.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is the code &lt;a href="https://github.com/nemanjam/nemanjam.github.io/blob/c1e105847d8e7b4ab4aaffad3078726c37f67528/src/components/react/ImageBlurPreloader.tsx" rel="noopener noreferrer"&gt;src/components/react/ImageBlurPreloader.tsx&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/components/react/ImageBlurPreloader.tsx&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;initialAttributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ImgTagAttributes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ImageBlurPreloader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FC&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Props&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;blurAttributes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;initialAttributes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;mainAttributes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;initialAttributes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;onMainLoaded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;divClassName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isLoadingMain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setIsLoadingMain&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isLoadingBlur&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setIsLoadingBlur&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prevMainAttributes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;usePrevious&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mainAttributes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isNewImage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;prevMainAttributes&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;mainAttributes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;prevMainAttributes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;srcSet&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;mainAttributes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;srcSet&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// reset isLoading on main image change&lt;/span&gt;
  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isNewImage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setIsLoadingBlur&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;setIsLoadingMain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isNewImage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setIsLoadingMain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setIsLoadingBlur&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="c1"&gt;// important: main image must be in DOM for onLoad to work&lt;/span&gt;
  &lt;span class="c1"&gt;// unmount and display: none; will fail&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleLoadMain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setIsLoadingMain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;onMainLoaded&lt;/span&gt;&lt;span class="p"&gt;?.();&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;commonAttributes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// blur image must use size from main image&lt;/span&gt;
    &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;mainAttributes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;mainAttributes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blurAlt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isLoadingBlur&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;blurAttributes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alt&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mainAlt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isLoadingMain&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;mainAttributes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alt&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hasImage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;isLoadingMain&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;mainAttributes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;mainAttributes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;srcSet&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;blurAttributes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;blurAttributes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;srcSet&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;cn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;relative size-full&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;divClassName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;hasImage&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
          &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* blur image */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;img&lt;/span&gt;
            &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;blurAttributes&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;commonAttributes&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;blurAlt&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;onLoad&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setIsLoadingBlur&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;cn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object-cover absolute top-0 left-0 size-full&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;

          &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* main image */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;img&lt;/span&gt;
            &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;mainAttributes&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;commonAttributes&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;mainAlt&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;onLoad&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleLoadMain&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;cn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
              &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object-cover absolute top-0 left-0 size-full&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="c1"&gt;// important: don't hide main image until next blur image is loaded&lt;/span&gt;
              &lt;span class="nx"&gt;isLoadingMain&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isLoadingBlur&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;opacity-0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;opacity-100&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="nx"&gt;className&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a lot of code, so let’s break it down. First, note the use of &lt;code&gt;relative&lt;/code&gt; and &lt;code&gt;absolute&lt;/code&gt; classes to position the images on top of each other.&lt;/p&gt;

&lt;p&gt;We set the initial &lt;code&gt;src&lt;/code&gt; to an empty string in the &lt;code&gt;initialAttributes&lt;/code&gt; variable. This sets the &lt;code&gt;hasImage&lt;/code&gt; flag to &lt;code&gt;true&lt;/code&gt;, unmounts the images, and displays an empty &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; that fills the parent container thanks to the &lt;code&gt;size-full&lt;/code&gt; class (which is needed to prevent layout shift).&lt;/p&gt;

&lt;p&gt;Next, note that we track the separate states &lt;code&gt;isLoadingMain&lt;/code&gt; and &lt;code&gt;isLoadingBlur&lt;/code&gt; for the main and blur images. Both are necessary so we can correctly show/hide the main image by changing its opacity from &lt;code&gt;opacity-0&lt;/code&gt; to &lt;code&gt;opacity-100&lt;/code&gt;. The general idea is this: "Always keep the blur image below, just show or hide the main image above."&lt;/p&gt;

&lt;p&gt;Additionally, we track the previous main image, &lt;code&gt;prevMainAttributes&lt;/code&gt;, to detect when a new image is selected via the &lt;code&gt;onClick&lt;/code&gt; event passed from the parent component.&lt;/p&gt;

&lt;p&gt;Finally, while an image is loading, we set its &lt;code&gt;alt&lt;/code&gt; attribute (using the &lt;code&gt;blurAlt&lt;/code&gt; and &lt;code&gt;mainAlt&lt;/code&gt; variables) to an empty string to avoid rendering text in place of an empty image, as it doesn't look nice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bonus tip:&lt;/strong&gt; You can also experiment with the &lt;code&gt;&amp;lt;img style={{imageRendering: 'pixelated'}} /&amp;gt;&lt;/code&gt; scaling style on the blur image if you find it more aesthetically pleasing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cumulative layout shift
&lt;/h2&gt;

&lt;p&gt;This is also an interesting part. In general, the server always sends images with their sizes (at least it should), which makes handling layout shifts easier, so we should be able to solve it properly.&lt;/p&gt;

&lt;p&gt;The key point is this: Set the component's actual size &lt;strong&gt;in the server component&lt;/strong&gt; &lt;code&gt;ImageRandom.astro&lt;/code&gt; and use &lt;code&gt;w-full h-full&lt;/code&gt; (&lt;code&gt;size-full&lt;/code&gt;) in the client &lt;code&gt;ImageRandom.tsx&lt;/code&gt; React component to stretch it to fill the parent. This way, the size is resolved on the server, and there is no shift when hydrating the client component.&lt;/p&gt;

&lt;p&gt;Lets see it in practice &lt;a href="https://github.com/nemanjam/nemanjam.github.io/blob/cb36b621ebae583dee693dd6ef6e6ece0028c468/src/components/ImageRandom.astro#L21" rel="noopener noreferrer"&gt;src/components/ImageRandom.astro#L21&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/components/ImageRandom.astro"&lt;/span&gt;

&lt;span class="o"&gt;---&lt;/span&gt;
&lt;span class="c1"&gt;// add 'px' suffix or styles will fail&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;IMAGE_SIZES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FIXED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MDX_XL_16_9&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px`&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;---&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* height and width MUST be defined ON SERVER component to prevent layout shift */&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* set height and width to image size but set real size with max-height and max-width */&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;
  &lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;cn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;max-w-full max-h-64 md:max-h-96 my-8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ImageRandomReact&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;galleryImages&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;client&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="na"&gt;load&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We use the &lt;code&gt;max-w-...&lt;/code&gt; and &lt;code&gt;max-h-...&lt;/code&gt; classes to set the actual (responsive) size for the server component, which the client component will fill.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;my-8&lt;/code&gt; margin is there to override the vertical margin styles for the image component in the markdown (&lt;code&gt;prose&lt;/code&gt; class). Remember, we have two actual, absolutely positioned &lt;code&gt;&amp;lt;img /&amp;gt;&lt;/code&gt; tags in the DOM, so &lt;code&gt;prose&lt;/code&gt; will add double margins, and we need to correct that.&lt;/p&gt;

&lt;p&gt;Client component &lt;a href="https://github.com/nemanjam/nemanjam.github.io/blob/c1e105847d8e7b4ab4aaffad3078726c37f67528/src/components/react/ImageBlurPreloader.tsx" rel="noopener noreferrer"&gt;src/components/react/ImageBlurPreloader.tsx&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/components/react/ImageBlurPreloader.tsx&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ImageBlurPreloader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FC&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Props&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;divClassName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;cn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;relative size-full&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;divClassName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;hasImage&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
          &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* blur image */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;img&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;cn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object-cover absolute top-0 left-0 size-full&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;

          &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* main image */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;img&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;cn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object-cover absolute top-0 left-0 size-full&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the client component, we simply stretch all elements with &lt;code&gt;size-full&lt;/code&gt; to fill the parent server component.&lt;/p&gt;

&lt;p&gt;With this in place, we achieve the following score for the cumulative layout shift:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv7vkqymd2q602pyai2gl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv7vkqymd2q602pyai2gl.png" alt="Lighthouse score layout shift"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Completed code and demo
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Demo:&lt;/strong&gt; &lt;a href="https://nemanjamitic.com/" rel="noopener noreferrer"&gt;https://nemanjamitic.com/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Github repository:&lt;/strong&gt; &lt;a href="https://github.com/nemanjam/nemanjam.github.io" rel="noopener noreferrer"&gt;https://github.com/nemanjam/nemanjam.github.io&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The relevant files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# https://github.com/nemanjam/nemanjam.github.io/tree/c1e105847d8e7b4ab4aaffad3078726c37f67528&lt;/span&gt;
git checkout c1e105847d8e7b4ab4aaffad3078726c37f67528

&lt;span class="c"&gt;# random image code&lt;/span&gt;
src/pages/index.mdx
src/components/ImageRandom.astro
src/components/react/ImageRandom.tsx
src/components/react/ImageBlurPreloader.tsx

&lt;span class="c"&gt;# common code with gallery&lt;/span&gt;
src/libs/gallery/images.ts
src/libs/gallery/transform.ts
src/constants/image.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Outro
&lt;/h2&gt;

&lt;p&gt;Once again, we played around with images, Astro, and React. Have you implemented any similar components yourself, maybe a carousel? What was your approach? Do you have suggestions for improvements or have you spotted anything incorrect? Don’t hesitate to leave a comment below.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;React image preloader tutorial &lt;a href="https://benhoneywill.com/progressive-image-loading-with-react-hooks/" rel="noopener noreferrer"&gt;https://benhoneywill.com/progressive-image-loading-with-react-hooks/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Astro documentation, tutorial how to use &lt;code&gt;getImage()&lt;/code&gt; function &lt;a href="https://docs.astro.build/en/recipes/build-custom-img-component/" rel="noopener noreferrer"&gt;https://docs.astro.build/en/recipes/build-custom-img-component/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;"Squared" image scaling algorithm styles &lt;a href="https://www.w3schools.com/cssref/css3_pr_image-rendering.php" rel="noopener noreferrer"&gt;https://www.w3schools.com/cssref/css3_pr_image-rendering.php&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>astro</category>
      <category>react</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Build an image gallery with Astro and React</title>
      <dc:creator>Nemanja Mitic</dc:creator>
      <pubDate>Tue, 08 Apr 2025 08:33:36 +0000</pubDate>
      <link>https://dev.to/nemanjam/build-an-image-gallery-with-astro-and-react-38e6</link>
      <guid>https://dev.to/nemanjam/build-an-image-gallery-with-astro-and-react-38e6</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;I wanted to have a simple, Instagram-like, scroll paginated gallery page on the website where I could share my everyday photos. Initially I implemented it using &lt;a href="https://github.com/benhowell/react-grid-gallery" rel="noopener noreferrer"&gt;benhowell/react-grid-gallery&lt;/a&gt; package for gallery, and &lt;a href="https://github.com/frontend-collective/react-image-lightbox" rel="noopener noreferrer"&gt;frontend-collective/react-image-lightbox&lt;/a&gt; for lightbox component. It worked ok, but since those are a bit legacy packages I was unable to upgrade to React 19, it loaded all images at once without scroll pagination and Lighthouse score wasn't so great.&lt;/p&gt;

&lt;p&gt;You can see that implementation if you navigate back in Git history &lt;a href="https://github.com/nemanjam/nemanjam.github.io/tree/e0165b295db2ccc72bbbb7be4bdd7eb48f7dedae" rel="noopener noreferrer"&gt;e0165b&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# in git history navigate back to the old gallery commit&lt;/span&gt;
git checkout e0165b295db2ccc72bbbb7be4bdd7eb48f7dedae

&lt;span class="c"&gt;# preview&lt;/span&gt;
yarn clean &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; yarn &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; yarn dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I decided to reimplement it, did a quick research and decided to make my own gallery component and use &lt;a href="https://github.com/dimsemenov/photoswipe" rel="noopener noreferrer"&gt;dimsemenov/photoswipe&lt;/a&gt; package for lightbox. And that's how this article got created, while implementing I took notes about the most important and interesting parts from the process. Look at it as not necessarily the absolute best way to make image gallery with Astro and React but as one of the ways that is proven in practice and works well.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we will be building
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Demo:&lt;/strong&gt; &lt;a href="https://nemanjamitic.com/gallery" rel="noopener noreferrer"&gt;https://nemanjamitic.com/gallery&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Github repository:&lt;/strong&gt; &lt;a href="https://github.com/nemanjam/nemanjam.github.io" rel="noopener noreferrer"&gt;https://github.com/nemanjam/nemanjam.github.io&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/BiYwyBfjaXI"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Image - server component, client component, slot, props
&lt;/h2&gt;

&lt;p&gt;This is the first dilemma and initial decision that affects all the future code that we write. Since this is a static website example we are naturally inclined to pre-render everything we can at build time, but can this work for images too?&lt;/p&gt;

&lt;p&gt;Astro provides &lt;code&gt;&amp;lt;Image /&amp;gt;&lt;/code&gt; component and it's a server component like any other Astro component. It is clear that we will need &lt;code&gt;onLoad&lt;/code&gt;, &lt;code&gt;onClick&lt;/code&gt; events on a image and events aren't possible on a server component. Yes, but maybe we can use client component wrapper and pass Astro &lt;code&gt;&amp;lt;Image /&amp;gt;&lt;/code&gt; component as a slot so we can have best from both - Astro component for image optimization and a &lt;code&gt;&amp;lt;div /&amp;gt;&lt;/code&gt; for events, could this work?&lt;/p&gt;

&lt;p&gt;Not really, for any preload effects &lt;code&gt;onLoad&lt;/code&gt; event needs to be on the &lt;code&gt;&amp;lt;img /&amp;gt;&lt;/code&gt; tag, but more important is that we can't pass any client props to the slot &lt;code&gt;&amp;lt;Image /&amp;gt;&lt;/code&gt; component, we can generate only a single instance at build time. For any props values we would need to pregenerate separate image HTML which in this case is highly impractical.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conclusion:&lt;/strong&gt; We will use a React client component that supports interactivity and Astro &lt;code&gt;getImage()&lt;/code&gt; function to optimize the images.&lt;/p&gt;

&lt;h2&gt;
  
  
  API route vs &lt;code&gt;import.meta.glob()&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;We want to stick to a static website, for performance reasons and convenient deployments. What way should we use to pass the image urls to the client? We could make a static API endpoint that serves JSON array. We could even make an parametrized API endpoint that serves optimized images.&lt;/p&gt;

&lt;p&gt;Right away, why having an extra HTTP call for JSON on client when we can pregenerate image urls at build time, it's not what we want.&lt;/p&gt;

&lt;p&gt;For a static API endpoint, since it's static we would need to pre-render all params at build time, so we could do &lt;code&gt;http://localhost/api/gallery/xl/image1.webp&lt;/code&gt; but not &lt;code&gt;http://localhost/api/gallery/300x200/image1.webp&lt;/code&gt; and &lt;code&gt;http://localhost/api/gallery/301x200/image1.webp&lt;/code&gt;, for that we would need to enable Astro server side rending and have Node.js runtime in production.&lt;/p&gt;

&lt;p&gt;If we log a &lt;code&gt;src&lt;/code&gt; attribute of an imported image in dev and prod mode we will see something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// in dev&lt;/span&gt;
&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="c1"&gt;//localhost:3000/_image?href=/@fs/home/username/Desktop/nemanjam.github.io/src/assets/images/all-images/morning1.jpg?origWidth=4608&amp;amp;origHeight=2592&amp;amp;origFormat=jpg&amp;amp;w=1280&amp;amp;h=720&amp;amp;f=webp&lt;/span&gt;

&lt;span class="c1"&gt;// in prod&lt;/span&gt;
&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="c1"&gt;//localhost:3000/_astro/morning1.CEdGhKb3_nVk9T.webp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So Astro is already serving images for us, with a dedicated API endpoint we would just accomplish human friendly url rewriting, that could be useful only if some external service fetches those images, which we don't have here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conclusion:&lt;/strong&gt; We will use &lt;code&gt;import.meta.glob('/src/assets/images/all-images/*.jpg')&lt;/code&gt; from Vite to import images as modules to obtain images at build time and pass them as props into the Gallery component.&lt;/p&gt;

&lt;p&gt;The code is as follows &lt;a href="https://github.com/nemanjam/nemanjam.github.io/blob/6ef147e4b13b718d43ac24df6122dd1033e3d194/src/libs/gallery/images.ts#L16" rel="noopener noreferrer"&gt;src/libs/gallery/images.ts#L16&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/libs/gallery/images.ts&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getGalleryImagesMetadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;ImageMetadata&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;imageModules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;glob&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ImageMetadata&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="c1"&gt;// can't be a variable&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/src/assets/images/all-images/*.jpg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;eager&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// convert map to array&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;imagesMetadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;imageModules&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;// filter excluded filenames&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;EXCLUDE_IMAGES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;excludedFileName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;excludedFileName&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
    &lt;span class="c1"&gt;// return metadata array&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;imageModules&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;imagesMetadata&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Code structure
&lt;/h2&gt;

&lt;p&gt;We will structure code like this: &lt;code&gt;MDX (gallery.mdx) -&amp;gt; Astro component (Gallery.astro) -&amp;gt; React component (Gallery.jsx)&lt;/code&gt;. The call stack is top-down, MDX is a declarative presentation layer, Astro component will resolve data - images, React component will handle events and define logic, it's the most complex layer.&lt;/p&gt;

&lt;p&gt;Code (paraphrased):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/pages/gallery.mdx&lt;/span&gt;

&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Gallery&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;not-prose grow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// src/components/Gallery.astro&lt;/span&gt;

&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ReactGallery&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nx"&gt;only&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="nx"&gt;images&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;randomizedGalleryImages&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;
&lt;span class="c1"&gt;// src/components/react/Gallery.tsx&lt;/span&gt;

&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;grid grid-cols-1 gap-1 sm:grid-cols-2 lg:grid-cols-3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;loadedImages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt; &lt;span class="p"&gt;{...&lt;/span&gt;&lt;span class="nx"&gt;imageProps&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;  &lt;span class="p"&gt;)}&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Static generation, include image urls and &lt;code&gt;map()&lt;/code&gt; on the client
&lt;/h2&gt;

&lt;p&gt;Again, interesting and important point that is easy to forget is that &lt;code&gt;images.map()&lt;/code&gt; needs to be in React component in order to have infinite scroll pagination. For that all image urls (and other props) need to be bundled and available on client, that is passed as props from Astro to the React component.&lt;/p&gt;

&lt;p&gt;If we placed &lt;code&gt;images.map()&lt;/code&gt; in the Astro component we would we would have a single image list as is without any interactivity (pagination on scroll).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reminder:&lt;/strong&gt; Static "backend" runs only once - at build time. We have a Node.js runtime only in development, and not in production - in there we have just a webserver static folder for serving assets. Kind of obvious, but it can sometimes be overlooked when we decide whether to put certain code in a server or client component.&lt;/p&gt;

&lt;h2&gt;
  
  
  Responsive, optimized images - &lt;code&gt;getImage()&lt;/code&gt; and &lt;code&gt;&amp;lt;img srcset sizes /&amp;gt;&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Astro provides &lt;a href="https://docs.astro.build/en/guides/images/#generating-images-with-getimage" rel="noopener noreferrer"&gt;getImage()&lt;/a&gt; function that we will use to optimize images and generate &lt;code&gt;&amp;lt;img /&amp;gt;&lt;/code&gt; tag attributes for the client. It accepts the same arguments as the &lt;code&gt;&amp;lt;Image /&amp;gt;&lt;/code&gt; component. Note, &lt;code&gt;&amp;lt;img /&amp;gt;&lt;/code&gt; tag supports &lt;code&gt;srcset&lt;/code&gt; and &lt;code&gt;sizes&lt;/code&gt; attributes for responsive images which is sufficient for our use case. This time we don't need &lt;code&gt;&amp;lt;picture /&amp;gt;&lt;/code&gt; support for different images (art direction) and different formats.&lt;/p&gt;

&lt;p&gt;We will prepare different image presets (sizes) in &lt;a href="https://github.com/nemanjam/nemanjam.github.io/blob/6ef147e4b13b718d43ac24df6122dd1033e3d194/src/libs/gallery/transform.ts#L7" rel="noopener noreferrer"&gt;src/libs/gallery/transform.ts#L7&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;Note that only thumbnail uses responsive image, and lightbox uses a fixed size image since Photoswipe lightbox doesn't support responsive image (at least without a custom component).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/libs/gallery/transform.ts&lt;/span&gt;

&lt;span class="c1"&gt;// common props&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;defaultAstroImageOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// thumbnail preset&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;thumbnailImageOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;IMAGE_SIZES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RESPONSIVE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GALLERY_THUMBNAIL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// lightbox preset&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lightboxImageOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;IMAGE_SIZES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FIXED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MDX_2XL_16_9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// getImage() wrapper&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getCustomImage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UnresolvedImageTransform&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;GetImageResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;getImage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;defaultAstroImageOptions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that we use &lt;code&gt;getCustomImage()&lt;/code&gt; to optimize gallery images that we previously loaded with &lt;code&gt;import.meta.glob()&lt;/code&gt; in &lt;a href="https://github.com/nemanjam/nemanjam.github.io/blob/6ef147e4b13b718d43ac24df6122dd1033e3d194/src/libs/gallery/images.ts#L50" rel="noopener noreferrer"&gt;src/libs/gallery/images.ts#L50&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/libs/gallery/images.ts&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getGalleryImages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;GalleryImage&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;thumbnails&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getCustomImages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;thumbnailImageOptions&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lightBoxes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getCustomImages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lightboxImageOptions&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;galleryImages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mergeArrays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;thumbnails&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lightBoxes&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;thumbnail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lightbox&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;thumbnail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;imageResultToImageAttributes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;thumbnail&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;lightbox&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;imageResultToImageAttributes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lightbox&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;}));&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;galleryImages&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// select only needed attributes for the &amp;lt;img /&amp;gt; tag&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;imageResultToImageAttributes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;imageResult&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;GetImageResult&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;ImgTagAttributes&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;imageResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;srcSet&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;imageResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;srcSet&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;attribute&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;imageResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now we have the ready &lt;code&gt;&amp;lt;img /&amp;gt;&lt;/code&gt; attributes (props) available to pass into the React gallery client component.&lt;/p&gt;

&lt;p&gt;Interesting part is configuring &lt;code&gt;&amp;lt;img /&amp;gt;&lt;/code&gt; &lt;code&gt;sizes&lt;/code&gt; (&lt;code&gt;sizes&lt;/code&gt; and &lt;code&gt;widths&lt;/code&gt; args in &lt;code&gt;getImage()&lt;/code&gt;) attribute for responsive images in &lt;a href="https://github.com/nemanjam/nemanjam.github.io/blob/38a37b0e6d87f7723fac7875399ff12e128d26ac/src/constants/image.ts#L86" rel="noopener noreferrer"&gt;src/constants/image.ts#L86&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/constants/image.ts&lt;/span&gt;

&lt;span class="nx"&gt;GALLERY_THUMBNAIL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;widths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;TW_SCREENS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;XS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;TW_SCREENS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SM&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="nx"&gt;sizes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`(max-width: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;TW_SCREENS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SM&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px) &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;TW_SCREENS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SM&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;TW_SCREENS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;XS&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;

&lt;span class="c1"&gt;// actual &amp;lt;img /&amp;gt; tag attributes that are generated with the GALLERY_THUMBNAIL&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;
  &lt;span class="nx"&gt;sizes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;(max-width: 640px) 640px, 475px&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="nx"&gt;srcset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;
    /_astro/river16.CcFOUvED_Z2d5kbP.webp 475w,
    /_astro/river16.CcFOUvED_Z16pb6L.webp 640w
  &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/_astro/river16.CcFOUvED_Z1Dswo2.webp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;4000&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2252&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// src/components/react/Gallery.tsx&lt;/span&gt;

&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt;
  &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;GALLERY_ID&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pswp-gallery grid grid-cols-1 gap-1 sm:grid-cols-2 lg:grid-cols-3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you are not familiar with defining responsive images, it's not that complicated as it seems. The code above basically says, bellow &lt;code&gt;SM&lt;/code&gt; screen breakpoint (&lt;code&gt;640px&lt;/code&gt;) use &lt;code&gt;SM&lt;/code&gt; size (width) (&lt;code&gt;640px&lt;/code&gt;) image, and if screen is wider than &lt;code&gt;SM&lt;/code&gt; use smaller &lt;code&gt;XS&lt;/code&gt; (&lt;code&gt;475px&lt;/code&gt;) image. Maybe unexpected to use smaller image for larger screen, but it makes sense when you look at responsive grid that is used for the gallery layout.&lt;/p&gt;

&lt;p&gt;You can see in grid classes that bellow &lt;code&gt;sm:&lt;/code&gt; breakpoint image uses full width of the layout and above &lt;code&gt;sm:&lt;/code&gt; there are 2 images per row, above &lt;code&gt;lg:&lt;/code&gt; 3 images per row, so it makes sense to use the larger image on smaller screens.&lt;/p&gt;

&lt;p&gt;While configuring responsive images it's advisable to preview what is generated in the browser and ensure that result meets the expectation, we have sharp images at all resolutions and not too large image files.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/Gr7R9sH1rss"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Blur preloader, CSS transition
&lt;/h2&gt;

&lt;p&gt;Large lightbox image will handle Photoswipe on its own, we won't interfere with it for now. But we can have some nice effect on thumbnail images on infinite scroll. They are already small enough to load fast so no need to use smaller resolution image for blur preloader, we can achieve the same effect with a simple CSS transition.&lt;/p&gt;

&lt;p&gt;The following code does that &lt;a href="https://github.com/nemanjam/nemanjam.github.io/blob/38a37b0e6d87f7723fac7875399ff12e128d26ac/src/components/react/Gallery.tsx#L132" rel="noopener noreferrer"&gt;src/components/react/Gallery.tsx#L132&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/components/react/Gallery.tsx&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;loadedImages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setLoadedImages&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;GalleryImage&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;([]);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isLoadingPageImages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMemo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;loadedStates&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;every&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;loadedStates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;loadedImages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;IntersectionObserverCallback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// must wait here for images to load&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isEnd&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isLoadingPageImages&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;isIntersecting&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setPage&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;prevPage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;prevPage&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="c1"&gt;// ...&lt;/span&gt;

  &lt;span class="c1"&gt;// page dependency is important for initial load to work for all resolutions&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;observerTarget&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isEnd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isLoadingPageImages&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;


&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleLoad&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;setLoadedStates&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;loadedImages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;img&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;thumbnail&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;onLoad&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;handleLoad&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;thumbnail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;loadedStates&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;thumbnail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Gallery image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;cn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;w-full transition-all duration-[2s] ease-in-out&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;loadedStates&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;thumbnail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
          &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;opacity-100 blur-0 grayscale-0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
          &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;opacity-75 blur-sm grayscale&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;))}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that we have a &lt;code&gt;map()&lt;/code&gt; call here and we are storing loading states for an array of images. This is because we want to have a smooth transition for the entire new page of images, not for each image separately because they will load randomly and that's less esthetic. Important part is &lt;code&gt;isLoadingPageImages&lt;/code&gt; variable, it is used to block loading a new page until all images from the previous page are loaded. This happens in the observer callback condition &lt;code&gt;if (!isEnd &amp;amp;&amp;amp; !isLoadingPageImages &amp;amp;&amp;amp; entries[0].isIntersecting)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Another part is CSS transition, &lt;code&gt;duration-[...]&lt;/code&gt; should be picked so it takes more than actual thumbnail image loading time. For the transition effect, you can play around with opacity and Tailwind's &lt;a href="https://tailwindcss.com/docs/filter" rel="noopener noreferrer"&gt;filter&lt;/a&gt; classes and see what looks nicest to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Infinite scroll
&lt;/h2&gt;

&lt;p&gt;We want to implement pagination through infinite scroll like e.g. Instagram. Obviously, for this, Gallery needs to be a client component and we will use IntersectionObserver to detect the bottom of the gallery and trigger loading a new page of images. For the observer we could use ready-made hooks from utility libraries like &lt;a href="https://usehooks.com/useintersectionobserver" rel="noopener noreferrer"&gt;uidotdev/usehooks&lt;/a&gt; or &lt;a href="https://streamich.github.io/react-use/?path=/story/sensors-useintersection--docs" rel="noopener noreferrer"&gt;streamich/react-use&lt;/a&gt; but lets go with our own custom implementation this time.&lt;/p&gt;

&lt;p&gt;The code for this is in &lt;a href="https://github.com/nemanjam/nemanjam.github.io/blob/38a37b0e6d87f7723fac7875399ff12e128d26ac/src/components/react/Gallery.tsx#L76" rel="noopener noreferrer"&gt;src/components/react/Gallery.tsx#L76&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/components/react/Gallery.tsx&lt;/span&gt;

&lt;span class="c1"&gt;// sets only page&lt;/span&gt;
&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;IntersectionObserverCallback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// must wait here for images to load&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isEnd&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isLoadingPageImages&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;isIntersecting&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setPage&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;prevPage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;prevPage&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;debouncedCallback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;debounce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;OBSERVER_DEBOUNCE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;IntersectionObserverInit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;observer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;IntersectionObserver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;debouncedCallback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;observerRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;observerTarget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;observerRef&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;observer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;observerRef&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;observerRef&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;observer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unobserve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;observerRef&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="c1"&gt;// page dependency is important for initial load to work for all resolutions&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;observerTarget&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isEnd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isLoadingPageImages&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are 3 important parts in this code:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;We need to include &lt;code&gt;page&lt;/code&gt; state variable in the &lt;code&gt;useEffect&lt;/code&gt; dependencies array because we want to trigger effect execution every time new page of images loads and height of gallery increases. Also note that we read &lt;code&gt;page&lt;/code&gt; state value from the state setter callback argument &lt;code&gt;setPage((prevPage) =&amp;gt; prevPage + 1)&lt;/code&gt;, that's why we must also list &lt;code&gt;page&lt;/code&gt; in &lt;code&gt;useEffect&lt;/code&gt; dependencies array.&lt;/li&gt;
&lt;li&gt;We need to be precise about when we are loading new page of images. Note this condition &lt;code&gt;if (!isEnd &amp;amp;&amp;amp; !isLoadingPageImages &amp;amp;&amp;amp; entries[0].isIntersecting)&lt;/code&gt;, it practically means "load new page of images whenever 1. we haven't loaded all images AND 2. previous page of images is fully loaded - for esthetics AND 3. the gallery is scrolled to the bottom - main prerequisite.&lt;/li&gt;
&lt;li&gt;The observer &lt;code&gt;callback()&lt;/code&gt; triggers quite often, so we need to limit the frequency by debouncing. Note &lt;code&gt;OBSERVER_DEBOUNCE&lt;/code&gt; constant value needs to be fine tuned and validated through practical trial and error.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Another important and interesting part is detecting bottom of the page and displaying loader UI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/components/react/Gallery.tsx&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* control threshold with margin-top */&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* must be on top so loader doesn't affect it */&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;observerTarget&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"mt-0"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;
  &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;cn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="c1"&gt;// duration-500 is related to OBSERVER_DEBOUNCE: 300&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;flex items-center justify-center transition-all duration-500 ease-in-out&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;shouldShowLoader&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;min-h-48&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;min-h-0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;shouldShowLoader&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PiSpinnerGapBold&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"size-10 sm:size-12 animate-spin mt-4"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This can be tricky because they are circularly dependent - detection triggers showing loader and displaying loader affects position of detection &lt;code&gt;&amp;lt;div ref={observerTarget}/&amp;gt;&lt;/code&gt;. Another thing ot note is that detection &lt;code&gt;div&lt;/code&gt; has zero height and is placed either above or bellow the loader. It is important to be the above loader because we are interested in the bottom of the images, not the loader that will disappear from the UI in a few milliseconds anyway.&lt;/p&gt;

&lt;p&gt;Another important part is controlling and fine-tuning the threshold of the observed element &lt;code&gt;&amp;lt;div ref={observerTarget}/&amp;gt;&lt;/code&gt;. We do this by adjusting the positioning with &lt;code&gt;className="mt-0"&lt;/code&gt;, controlling the observers callback execution frequency with &lt;code&gt;OBSERVER_DEBOUNCE&lt;/code&gt;, setting the transition timing for the loader element &lt;code&gt;duration-500&lt;/code&gt;, specifying how many images we load (number of rows in the gallery) using the &lt;code&gt;pageSize&lt;/code&gt; constant, and how many pages of images we load initially on the first screen &lt;code&gt;initialPage&lt;/code&gt; constant.&lt;/p&gt;

&lt;p&gt;All of these parameters are connected together and you need to fine tune them for smooth infinite scroll experience. Also note that &lt;code&gt;pageSize&lt;/code&gt; and &lt;code&gt;initialPage&lt;/code&gt; constants are responsive and need to be defined for each breakpoint independently for full and ergonomic control.&lt;/p&gt;

&lt;p&gt;You can see that in the constants file in &lt;a href="https://github.com/nemanjam/nemanjam.github.io/blob/38a37b0e6d87f7723fac7875399ff12e128d26ac/src/constants/gallery.ts#L7" rel="noopener noreferrer"&gt;src/constants/gallery.ts#L7&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/constants/gallery.ts&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GALLERY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;GALLERY_ID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;my-gallery&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// Todo: make it responsive&lt;/span&gt;
  &lt;span class="cm"&gt;/** step. */&lt;/span&gt;
  &lt;span class="na"&gt;PAGE_SIZE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;XS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;SM&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;LG&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="cm"&gt;/** page dependency in useEffect is more important. To load first screen quickly, set to 3 pages */&lt;/span&gt;
  &lt;span class="na"&gt;INITIAL_PAGE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;XS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;SM&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;LG&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="cm"&gt;/** fine tuned for scroll */&lt;/span&gt;
  &lt;span class="na"&gt;OBSERVER_DEBOUNCE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the mapping to translate constants into usable &lt;code&gt;pageSize&lt;/code&gt; and &lt;code&gt;initialPage&lt;/code&gt; values are defined in utility functions in &lt;a href="https://github.com/nemanjam/nemanjam.github.io/blob/38a37b0e6d87f7723fac7875399ff12e128d26ac/src/utils/gallery.ts#L8" rel="noopener noreferrer"&gt;src/utils/gallery.ts#L8&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/utils/gallery.ts&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PAGE_SIZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;INITIAL_PAGE&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;GALLERY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// related to gallery grid css&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;breakpointToPageKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;XXS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;XS&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;XS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;XS&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;SM&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;MD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;LG&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;LG&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;XL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;LG&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;_2XL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;LG&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;defaultPageKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;LG&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getPageSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;breakpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Breakpoint&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;breakpointToPageKey&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;breakpoint&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;defaultPageKey&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pageSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;PAGE_SIZE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;pageSize&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getInitialPage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;breakpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Breakpoint&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;breakpointToPageKey&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;breakpoint&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;defaultPageKey&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;initialPage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;INITIAL_PAGE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;initialPage&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this, we have a smooth scrolling experience on all screen sizes:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/eEH-Aszkur0"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Also pay attention how we "fetch" a new page of images to update:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/components/react/Gallery.tsx&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fetchImagesUpToPage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;GalleryImage&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="nx"&gt;pageSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;nextPage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;GalleryImage&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;endIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;nextPage&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;pageSize&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isLastPage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;endIndex&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;images&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// for fetchPageImages pagination startIndex must use loadedImages and not all images and page&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;selectedImages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;images&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;endIndex&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// load all images for last page&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isLastPage&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;sliceToModN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;selectedImages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pageSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;selectedImages&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// converts page to loaded images&lt;/span&gt;
&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;upToPageImages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetchImagesUpToPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;images&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pageSize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;setLoadedImages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;upToPageImages&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;images&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pageSize&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are 2 important moments here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Since we have a static website all image urls are already included and available on the client so we don't need to calculate the starting index and can simply use zero &lt;code&gt;images.slice(0, endIndex);&lt;/code&gt;. Usually pagination implies a network and database calls that require both &lt;code&gt;startIndex&lt;/code&gt; and &lt;code&gt;endIndex&lt;/code&gt;, and if we went that path we would need to calculate &lt;code&gt;startIndex&lt;/code&gt; by finding the last element of the &lt;code&gt;loadedImages&lt;/code&gt; state array in the &lt;code&gt;images&lt;/code&gt; array and pass those as arguments.&lt;/li&gt;
&lt;li&gt;Since the &lt;code&gt;pageSize&lt;/code&gt; constant is responsive it can change when e.g. user resizes the browser window, so we call &lt;code&gt;sliceToModN(selectedImages, pageSize)&lt;/code&gt; for evenly loaded new row. Note that we don't call this for the last page because, eventually, we want to load all images, and the correct &lt;code&gt;loadedImages&lt;/code&gt; array length is important for calculating the &lt;code&gt;isEnd&lt;/code&gt; variable.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Cumulative layout shift
&lt;/h2&gt;

&lt;p&gt;Layout shift is important web vitals parameter and it's more challenging to optimize here since we are dealing with a dynamic client components. In the Gallery component we handle this by setting &lt;code&gt;initialPage&lt;/code&gt; constant to load enough images to fill the initial gallery screen.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/constants/gallery.ts&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GALLERY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="na"&gt;PAGE_SIZE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;XS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;SM&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;LG&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;INITIAL_PAGE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;XS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;SM&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;LG&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Another optimization we can do is to stretch the empty gallery container element with &lt;code&gt;flex grow&lt;/code&gt;. For that we need to modify the Page layout and pass the required Tailwind classes via the MDX frontmatter and &lt;code&gt;articleClass&lt;/code&gt; prop.&lt;/p&gt;

&lt;p&gt;You can see that in &lt;a href="https://github.com/nemanjam/nemanjam.github.io/blob/38a37b0e6d87f7723fac7875399ff12e128d26ac/src/layouts/Page.astro#L38" rel="noopener noreferrer"&gt;src/layouts/Page.astro#L38&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/layouts/Page.astro&lt;/span&gt;
&lt;span class="o"&gt;---&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Centered&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/layouts/Centered.astro&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getOpenGraphImagePath&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/libs/api/open-graph/image-path&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cn&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/utils/styles&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Content&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="nl"&gt;class&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="cm"&gt;/** for flex flex-grow min-height to prevent layout shift for client components */&lt;/span&gt;
  &lt;span class="nl"&gt;articleClass&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// ...&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;articleClass&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// ...&lt;/span&gt;

&lt;span class="o"&gt;---&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Centered&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;cn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* in general must not have flex, it will disable margin collapsing in MDX */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;article&lt;/span&gt; &lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;cn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;my-prose&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;articleClass&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;slot&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;article&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Centered&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Flex class is passed from MDX frontmatter in &lt;a href="https://github.com/nemanjam/nemanjam.github.io/blob/38a37b0e6d87f7723fac7875399ff12e128d26ac/src/pages/gallery.mdx#L7" rel="noopener noreferrer"&gt;src/pages/gallery.mdx#L7&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# src/pages/gallery.mdx&lt;/span&gt;
&lt;span class="p"&gt;
---
&lt;/span&gt;
layout: '../layouts/Page.astro'
...
class: 'max-w-5xl'
articleClass: 'grow flex flex-col'
&lt;span class="p"&gt;
---
&lt;/span&gt;
import Gallery from '../components/Gallery.astro';

&lt;span class="gh"&gt;# Gallery&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;Gallery&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"not-prose grow"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This will reduce the shift of DOM elements size, it won't make it perfect like in fully static page but for our use case it's good enough.&lt;/p&gt;

&lt;p&gt;Another point to make is that &lt;code&gt;flex&lt;/code&gt; container will disable margin collapsing which is important for proper vertical spacings in MDX generated HTML. So if you do that you will need to add an additional &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; wrapper element without flex to re-enable proper margin collapsing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lighthouse score, old gallery:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F96p1qv2byaexanq5f5cr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F96p1qv2byaexanq5f5cr.png" alt="Lighthouse score, old gallery"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lighthouse score, new gallery:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9t4z508rsbxtmb5d3pem.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9t4z508rsbxtmb5d3pem.png" alt="Lighthouse score, new gallery"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Please ignore the "Accessibility" score above, since the accessibility attributes aren't yet tackled on the entire website.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lightbox with Photoswipe
&lt;/h2&gt;

&lt;p&gt;For previewing images in full screen lightbox we will use ready made library &lt;a href="https://github.com/dimsemenov/photoswipe" rel="noopener noreferrer"&gt;Photoswipe&lt;/a&gt; that looks solid, reliable and flexible. We will use a basic &lt;a href="https://photoswipe.com/react-image-gallery/" rel="noopener noreferrer"&gt;React example&lt;/a&gt; from the documentation.&lt;/p&gt;

&lt;p&gt;This is the code &lt;a href="https://github.com/nemanjam/nemanjam.github.io/blob/38a37b0e6d87f7723fac7875399ff12e128d26ac/src/components/react/Gallery.tsx#L98" rel="noopener noreferrer"&gt;src/components/react/Gallery.tsx#L98&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/components/react/Gallery.tsx&lt;/span&gt;

&lt;span class="c1"&gt;// lightbox&lt;/span&gt;
&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;lightbox&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PhotoSwipeLightbox&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PhotoSwipeLightbox&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;gallery&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;GALLERY_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;pswpModule&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;photoswipe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;lightbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;lightbox&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;destroy&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;lightbox&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;

&lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;
      &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;GALLERY_ID&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"pswp-gallery grid grid-cols-1 gap-1 sm:grid-cols-2 lg:grid-cols-3"&lt;/span&gt;
    &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;loadedImages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;a&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;GALLERY_ID&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;--&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lightbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="c1"&gt;// lightbox doesn't support responsive image&lt;/span&gt;
          &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lightbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;data-pswp-width&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lightbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;data-pswp-height&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lightbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"_blank"&lt;/span&gt;
          &lt;span class="na"&gt;rel&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"noreferrer"&lt;/span&gt;
        &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;img&lt;/span&gt;
              &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;thumbnail&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="c1"&gt;// ...&lt;/span&gt;
          &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;a&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that for a simplicity sake we are using a simple fixed image and Photoswipe implements scale transition on its own. By default it uses a simple link &lt;code&gt;&amp;lt;a href={image.lightbox.src}&amp;gt;&lt;/code&gt; to load the &lt;code&gt;&amp;lt;img src /&amp;gt;&lt;/code&gt; in the full page lightbox.&lt;/p&gt;

&lt;p&gt;This is a tradeoff for simplicity. Loading a responsive image with &lt;code&gt;srcset&lt;/code&gt; would require integrating a custom component which could be a topic for another article. Another possible improvement is to enable closing lightbox on backdrop click on mobile which is not the case with the default config.&lt;/p&gt;

&lt;p&gt;Lightbox image size is defined in &lt;a href="https://github.com/nemanjam/nemanjam.github.io/blob/6ef147e4b13b718d43ac24df6122dd1033e3d194/src/libs/gallery/transform.ts#L24" rel="noopener noreferrer"&gt;src/libs/gallery/transform.ts#L24&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/libs/gallery/transform.ts&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lightboxImageOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;IMAGE_SIZES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FIXED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MDX_2XL_16_9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// src/constants/image.ts&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;IMAGE_SIZES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;FIXED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="na"&gt;MDX_2XL_16_9&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TW_SCREENS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_2XL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TW_SCREENS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HEIGHTS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_2XL&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Completed code and demo
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Demo:&lt;/strong&gt; &lt;a href="https://nemanjamitic.com/gallery" rel="noopener noreferrer"&gt;https://nemanjamitic.com/gallery&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Github repository:&lt;/strong&gt; &lt;a href="https://github.com/nemanjam/nemanjam.github.io" rel="noopener noreferrer"&gt;https://github.com/nemanjam/nemanjam.github.io&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The relevant files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# new gallery https://github.com/nemanjam/nemanjam.github.io/tree/c1e105847d8e7b4ab4aaffad3078726c37f67528&lt;/span&gt;
git checkout c1e105847d8e7b4ab4aaffad3078726c37f67528

src/pages/gallery.mdx
src/components/Gallery.astro
src/components/react/Gallery.tsx
src/libs/gallery/images.ts
src/libs/gallery/transform.ts
src/utils/gallery.ts
src/constants/gallery.ts
src/constants/image.ts
src/components/react/hooks/useScrollDown.tsx
src/components/react/hooks/useWidth.tsx

&lt;span class="c"&gt;# old gallery https://github.com/nemanjam/nemanjam.github.io/tree/e0165b295db2ccc72bbbb7be4bdd7eb48f7dedae&lt;/span&gt;
git checkout e0165b295db2ccc72bbbb7be4bdd7eb48f7dedae
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Outro
&lt;/h2&gt;

&lt;p&gt;That was a pretty long read, thank you for your attention and dedication. Have you implemented an Astro image gallery yourself and used a different approach? Do you have suggestions for improvements or spotted anything incorrect? Don't hesitate to leave a comment below.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Astro gallery example, inspiration to take Photoswipe for a lightbox component &lt;a href="https://github.com/EmaSuriano/astro-art-portfolio" rel="noopener noreferrer"&gt;https://github.com/EmaSuriano/astro-art-portfolio&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Photoswipe documentation &lt;a href="https://photoswipe.com/getting-started" rel="noopener noreferrer"&gt;https://photoswipe.com/getting-started&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Astro documentation, tutorial how to use &lt;code&gt;getImage()&lt;/code&gt; function &lt;a href="https://docs.astro.build/en/recipes/build-custom-img-component/" rel="noopener noreferrer"&gt;https://docs.astro.build/en/recipes/build-custom-img-component/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Infinite scroll with React and IntersectionObserver tutorial &lt;a href="https://blog.logrocket.com/react-infinite-scroll/" rel="noopener noreferrer"&gt;https://blog.logrocket.com/react-infinite-scroll/&lt;/a&gt; and Codesandbox example &lt;a href="https://codesandbox.io/p/github/Elijah-trillionz/react-infinite-scroll/master" rel="noopener noreferrer"&gt;https://codesandbox.io/p/github/Elijah-trillionz/react-infinite-scroll/master&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Images in Astro as client components, useful Reddit discussion &lt;a href="https://www.reddit.com/r/astrojs/comments/1bia6lq/how%5C_to%5C_utilize%5C_image%5C_with%5C_react%5C_component" rel="noopener noreferrer"&gt;https://www.reddit.com/r/astrojs/comments/1bia6lq/how\_to\_utilize\_image\_with\_react\_component&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>astro</category>
      <category>react</category>
    </item>
  </channel>
</rss>
