Sitecore Astro Verticals Demo Website: Next.js vs Astro Components Comparison

Anton Tishchenko
Anton Tishchenko
Cover Image for Sitecore Astro Verticals Demo Website: Next.js vs Astro Components Comparison

We made a Sitecore Verticals Astro Demo. Now, we have Astro and Next.js Sitecore websites side by side. They are feature-equal and look the same. Let’s compare the code.

Simple Complexity Component: Hero

Hero is the very basic component. It is not required to explain what it does, as the name Hero is self-explanatory.

Next.js (React) code:

import React from 'react';
import {
  Field,
  ImageField,
  Image,
  RichTextField,
  Text,
  RichText,
  useSitecoreContext,
  Link,
  LinkField,
} from '@sitecore-jss/sitecore-jss-nextjs';

interface Fields {
  Title: Field<string>;
  Text: RichTextField;
  Image: ImageField;
  Link: LinkField;
}

export type AppPromoProps = {
  params: { [key: string]: string };
  fields: Fields;
};

export const Default = (props: AppPromoProps): JSX.Element => {
  const id = props.params.RenderingIdentifier;
  const { sitecoreContext } = useSitecoreContext();
  const isPageEditing = sitecoreContext.pageEditing;

  return (
    <div className={`component hero ${props.params.styles.trimEnd()}`} id={id ? id : undefined}>
      <picture>
        <Image field={props.fields.Image} className=""></Image>
      </picture>
      <div className="container content-container">
        <div className="top-layout">
          <div className="title">
            <Text field={props.fields.Title} />
          </div>
          <div className="subtitle">
            <RichText field={props.fields.Text} />
          </div>
        </div>
        <div className="bottom-layout">
          <div className="btn-array">
            {(isPageEditing || props.fields?.Link?.value?.href) && (
              <Link field={props.fields.Link} className="button button-main mt-3" />
            )}
          </div>
        </div>
      </div>
    </div>
  );
};

Astro code:

---
import {
  Field,
  ImageField,
  RichTextField,
  LinkField,
  AstroImage,
  Text,
  RichText,
  Link,
  SitecoreContextMap,
} from "@astro-sitecore-jss/astro-sitecore-jss";

interface Fields {
  Title: Field<string>;
  Text: RichTextField;
  Image: ImageField;
  Link: LinkField;
}

export type AppPromoProps = {
  params: { [key: string]: string };
  fields: Fields;
};

const props: AppPromoProps = Astro.props.route;
const id = props.params.RenderingIdentifier;
const sitecoreContext = SitecoreContextMap.get()["scContext"];
const isPageEditing = sitecoreContext.pageEditing;
---

<div class={`component hero ${props.params.styles.trimEnd()}`} id={id ? id : undefined}>
  <picture>
    <AstroImage field={props.fields.Image} class="" />
  </picture>
  <div class="container content-container">
    <div class="top-layout">
      <div class="title">
        <Text field={props.fields.Title} />
      </div>
      <div class="subtitle">
        <RichText field={props.fields.Text} />
      </div>
    </div>
    <div class="bottom-layout">
      <div class="btn-array">
        {
          (isPageEditing || props.fields?.Link?.value?.href) && (
            <Link field={props.fields.Link} class="button button-main mt-3" />
          )
        }
      </div>
    </div>
  </div>
</div>

The code looks very similar. The only big difference is the return syntax. But the difference could be neglected. It is more depends on your taste. However, based on the State of JavaScript survey developers prefer to work more with Astro rather than Next.js or React.

The Astro code is faster. It is due to the origin of how it is designed:

  • Next.js will render the JavaScript code of React components on the server. Then Next.js will prepare HTML from them. Then Next.js will pass this HTML to the browser and the HTML code will be hydrated.
  • Astro will render HTML on the server side. Then pass the HTML to the web browser and that is it.

If the code is about the same, but for the second case you get the more performant result. The choice should be obvious.

Medium Сomplexity Component: Carousel

Everyone knows what is carousel. And some part of people are confident that no one should use carousels! But it is a good comparison example for components with some small user interactions.

Next.js (React):

import React, { useState } from 'react';
import {
  ComponentParams,
  ComponentRendering,
  Field,
  ImageField,
  Image,
  RichTextField,
  LinkField,
  Text,
  Link,
  RichText,
  useSitecoreContext,
} from '@sitecore-jss/sitecore-jss-nextjs';

interface Fields {
  Title: Field<string>;
  Text: RichTextField;
  Image: ImageField;
  Link: LinkField;
  Video: ImageField;
}

export type CarouselItemProps = {
  id: string;
  fields: Fields;
};

interface CarouselComponentProps {
  rendering: ComponentRendering & { params: ComponentParams };
  params: ComponentParams;
  fields: {
    items: CarouselItemProps[];
  };
}

export const Default = (props: CarouselComponentProps): JSX.Element => {
  const id = props.params.RenderingIdentifier;
  const [index, setIndex] = useState(0);
  const { sitecoreContext } = useSitecoreContext();
  const isPageEditing = sitecoreContext.pageEditing;

  const handleNext = () => {
    setIndex((prevIndex) => (prevIndex < props.fields.items.length - 1 ? prevIndex + 1 : 0));
  };

  const handlePrev = () => {
    setIndex((prevIndex) => (prevIndex > 0 ? prevIndex - 1 : props.fields.items.length - 1));
  };

  return (
    <section
      className={`component carousel ${props.params.styles.trimEnd()}`}
      id={id ? id : undefined}
    >
      <div className="carousel-inner">
        {props.fields.items.map((item, i) => (
          <div key={i} className={'carousel-item ' + (i == index ? 'active' : '')}>
            {!isPageEditing && item.fields?.Video?.value?.src ? (
              <video
                className="object-fit-cover d-block w-100 h-100"
                key={item.id}
                autoPlay={true}
                loop={true}
                muted
                playsInline
                poster={item.fields.Image?.value?.src}
              >
                <source src={item.fields.Video.value.src} type="video/webm" />
              </video>
            ) : (
              <Image
                field={item.fields.Image}
                className="object-fit-cover d-block w-100 h-100"
                height={' '}
              ></Image>
            )}

            <div className="side-content">
              <div className="container">
                <div className="col-lg-5 col-md-6 offset-md-6 offset-lg-7">
                  <h1 className="display-6 fw-bold">
                    <Text field={item.fields.Title}></Text>
                  </h1>
                  <RichText field={item.fields.Text}></RichText>
                  {!isPageEditing && item.fields?.Link?.value?.href && (
                    <Link field={item.fields.Link} className="button button-accent"></Link>
                  )}
                </div>
              </div>
            </div>
          </div>
        ))}
      </div>
      <ol className="carousel-indicators">
        {props.fields.items.map((_item, i) => (
          <li
            key={i}
            aria-label="Slide"
            className={i == index ? 'active' : ''}
            onClick={() => setIndex(i)}
          ></li>
        ))}
      </ol>
      <button
        className="carousel-control-prev"
        type="button"
        data-bs-target="#carouselExampleCaptions"
        data-bs-slide="prev"
        onClick={handlePrev}
      >
        <span className="carousel-control-prev-icon" aria-hidden="true">
          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor">
            <path d="M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z" />
          </svg>
        </span>
        <span className="visually-hidden">Previous</span>
      </button>
      <button
        className="carousel-control-next"
        type="button"
        data-bs-target="#carouselExampleCaptions"
        data-bs-slide="next"
        onClick={handleNext}
      >
        <span className="carousel-control-next-icon" aria-hidden="true">
          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor">
            <path d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z" />
          </svg>
        </span>
        <span className="visually-hidden">Next</span>
      </button>
    </section>
  );
};

Astro:

---
import {
  AstroImage,
  Text,
  RichText,
  Link,
  Field,
  ImageField,
  RichTextField,
  LinkField,
  SitecoreContextMap,
} from "@astro-sitecore-jss/astro-sitecore-jss";

import {
  ComponentParams,
  ComponentRendering,
} from "@sitecore-jss/sitecore-jss/layout";

interface Fields {
  Title: Field<string>;
  Text: RichTextField;
  Image: ImageField;
  Link: LinkField;
  Video: ImageField;
}

export type CarouselItemProps = {
  id: string;
  fields: Fields;
};

interface CarouselComponentProps {
  rendering: ComponentRendering & { params: ComponentParams };
  params: ComponentParams;
  fields: {
    items: CarouselItemProps[];
  };
}

const props: CarouselComponentProps = Astro.props.route;
const id = props.params.RenderingIdentifier;
const sitecoreContext = SitecoreContextMap.get()["scContext"];
const isPageEditing = sitecoreContext.pageEditing;
---

<section
  class={`component carousel ${props.params.styles.trimEnd()}`}
  id={id ? id : undefined}
>
  <div class="carousel-inner">
    {
      props.fields.items.map((item, i) => {
        return (
          <div class={"carousel-item " + (i == 0 ? "active" : "")}>
            {!isPageEditing && item.fields?.Video?.value?.src ? (
              <video
                class="object-fit-cover d-block w-100 h-100"
                key={item.id}
                autoPlay={true}
                loop={true}
                muted
                playsInline
                poster={item.fields.Image?.value?.src}
              >
                <source src={item.fields.Video.value.src} type="video/webm" />
              </video>
            ) : (
              <AstroImage
                field={item.fields.Image}
                class="object-fit-cover d-block w-100 h-100"
              />
            )}

            <div class="side-content">
              <div class="container">
                <div class="col-lg-5 col-md-6 offset-md-6 offset-lg-7">
                  <h1 class="display-6 fw-bold">
                    <Text field={item.fields.Title} />
                  </h1>
                  <RichText field={item.fields.Text} />
                  {!isPageEditing && item.fields?.Link?.value?.href && (
                    <Link
                      field={item.fields.Link}
                      class="button button-accent"
                    />
                  )}
                </div>
              </div>
            </div>
          </div>
        );
      })
    }
  </div>
  <ol class="carousel-indicators">
    {props.fields.items.map((_item, i) => <li aria-label="Slide" />)}
  </ol>
  <button
    class="carousel-control-prev"
    type="button"
    data-bs-target="#carouselExampleCaptions"
    data-bs-slide="prev"
  >
    <span class="carousel-control-prev-icon" aria-hidden="true">
      <svg
        xmlns="http://www.w3.org/2000/svg"
        viewBox="0 0 16 16"
        fill="currentColor"
      >
        <path
          d="M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z"
        ></path>
      </svg>
    </span>
    <span class="visually-hidden">Previous</span>
  </button>
  <button
    class="carousel-control-next"
    type="button"
    data-bs-target="#carouselExampleCaptions"
    data-bs-slide="next"
  >
    <span class="carousel-control-next-icon" aria-hidden="true">
      <svg
        xmlns="http://www.w3.org/2000/svg"
        viewBox="0 0 16 16"
        fill="currentColor"
      >
        <path
          d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"
        ></path>
      </svg>
    </span>
    <span class="visually-hidden">Next</span>
  </button>
</section>

<script>
  const carousels = document.querySelectorAll(".carousel");

  carousels.forEach((carousel) => {
    setCarousel(carousel);
  });

  function setCarousel(carousel: Element) {
    let index = 0;

    const items = carousel.querySelectorAll(".carousel-item");
    const indicators = carousel.querySelectorAll(".carousel-indicators li");

    setActive();

    const prevButton = carousel.querySelector(".carousel-control-prev");
    const nextButton = carousel.querySelector(".carousel-control-next");
    prevButton?.addEventListener("click", handlePrev);
    nextButton?.addEventListener("click", handleNext);

    indicators.forEach((item, i) =>
      item.addEventListener("click", () => {
        index = i;
        setActive();
      })
    );

    function setActive() {
      items.forEach((item, i) => item.classList.toggle("active", i === index));
      indicators.forEach((item, i) =>
        item.classList.toggle("active", i === index)
      );
    }

    function handleNext() {
      index = index < items.length - 1 ? index + 1 : 0;
      setActive();
    }

    function handlePrev() {
      index = index > 0 ? index - 1 : items.length - 1;
      setActive();
    }
  }
</script>

The logic part of the React approach is cleaner than the JavaScript written for Astro. But the price is performance. The Astro component will be much faster as it doesn’t have any React overhead. It is not a big deal for one component. But small improvements on each component give you a big improvement on the page!

Complex Component: Loan Calculator

This component is a rich user-interactive component. You have many input fields. Many output places. It should use UI frameworks. We implemented it on Astro only for demo purposes to compare the code.

Next.js (React):

import React, { useState, useEffect, ReactNode } from 'react';
import { Field, Text } from '@sitecore-jss/sitecore-jss-nextjs';
import { useI18n } from 'next-localization';

interface Fields {
  BankFee: Field<number>;
  Currency: Field<string>;
  InterestRate: Field<number>;
  MaxAmount: Field<number>;
  MaxTerm: Field<number>;
  MinAmount: Field<number>;
  MinTerm: Field<number>;
  TermName: Field<string>;
}

export type LoanCalculatorProps = {
  params: { [key: string]: string };
  fields: Fields;
};

const ResultLine = ({ left, right }: { left: ReactNode; right: ReactNode }) => {
  return (
    <div className="row align-items-center justify-content-between">
      <div className="col-auto">
        <span>{left}</span>
      </div>
      <div className="col-auto">
        <span className="fw-bold">{right}</span>
      </div>
    </div>
  );
};

export const Default = (props: LoanCalculatorProps): JSX.Element => {
  const id = props.params.RenderingIdentifier;
  const { t } = useI18n();

  const [loanAmount, setLoanAmount] = useState(
    Math.round((props.fields.MinAmount.value + props.fields.MaxAmount.value) / 2)
  );
  const [loanTerm, setLoanTerm] = useState(
    Math.round((props.fields.MinTerm.value + props.fields.MaxTerm.value) / 2)
  );
  const [monthlyPayment, setMonthlyPayment] = useState(0);
  const [totalDebt, setTotalDebt] = useState(0);
  const [totalInterest, setTotalInterest] = useState(0);

  useEffect(() => {
    const monthlyInterestRate = props.fields.InterestRate.value / 100 / 12;

    const monthlyPaymentCalculation =
      (loanAmount * monthlyInterestRate) / (1 - Math.pow(1 + monthlyInterestRate, -loanTerm));
    setMonthlyPayment(monthlyPaymentCalculation);

    const totalDebtCalculation = monthlyPaymentCalculation * loanTerm + props.fields.BankFee.value;
    setTotalDebt(totalDebtCalculation);

    const totalInterestCalculation = totalDebtCalculation - loanAmount - props.fields.BankFee.value;
    setTotalInterest(parseFloat(totalInterestCalculation.toFixed(2)));
  }, [loanAmount, loanTerm, props.fields.InterestRate.value, props.fields.BankFee.value]);

  return (
    <div
      className={`component loan-calculator ${props.params.styles.trimEnd()}`}
      id={id ? id : undefined}
    >
      <div className="loan-calculator-input-group">
        <div className="row justify-content-between">
          <div className="col-auto">
            <label htmlFor="loan-amount">{t('Amount') || 'Amount'}</label>
          </div>
          <div className="col-auto">
            <div className="loan-calculator-input-wrapper">
              <svg
                xmlns="http://www.w3.org/2000/svg"
                width="16"
                height="26"
                viewBox="0 0 24 24"
                fill="currentColor"
              >
                <path d="M7.127 22.562l-7.127 1.438 1.438-7.128 5.689 5.69zm1.414-1.414l11.228-11.225-5.69-5.692-11.227 11.227 5.689 5.69zm9.768-21.148l-2.816 2.817 5.691 5.691 2.816-2.819-5.691-5.689z" />
              </svg>
              <input
                type="number"
                id="loan-amount"
                name="loan-amount"
                min={props.fields.MinAmount.value}
                max={props.fields.MaxAmount.value}
                value={loanAmount}
                onChange={(e) => setLoanAmount(parseInt(e.target.value))}
              />
              <span className="fw-bold">
                <Text field={props.fields.Currency} />
              </span>
            </div>
          </div>
        </div>
        <div className="row">
          <div className="col-12">
            <div className="loan-calculator-range-wrapper">
              <input
                type="range"
                id="loan-amount-range"
                name="loan-amount-range"
                min={props.fields.MinAmount.value}
                max={props.fields.MaxAmount.value}
                value={loanAmount}
                onChange={(e) => setLoanAmount(parseInt(e.target.value))}
                style={{
                  backgroundSize: `${loanAmount < props.fields.MinAmount.value
                      ? '0'
                      : loanAmount > props.fields.MaxAmount.value
                        ? '100'
                        : ((loanAmount - props.fields.MinAmount.value) * 100) /
                        (props.fields.MaxAmount.value - props.fields.MinAmount.value)
                    }% 100%`,
                }}
              />
            </div>
          </div>
        </div>
        <div className="row justify-content-between">
          <div className="col-auto">
            <span>
              <Text field={props.fields.MinAmount} /> <Text field={props.fields.Currency} />
            </span>
          </div>
          <div className="col-auto">
            <span>
              <Text field={props.fields.MaxAmount} /> <Text field={props.fields.Currency} />
            </span>
          </div>
        </div>
      </div>

      <div className="loan-calculator-input-group">
        <div className="row justify-content-between">
          <div className="col-auto">
            <label htmlFor="loan-amount">{t('Term of repayment') || 'Term of repayment'}</label>
          </div>
          <div className="col-auto">
            <div className="loan-calculator-input-wrapper">
              <svg
                xmlns="http://www.w3.org/2000/svg"
                width="16"
                height="26"
                viewBox="0 0 24 24"
                fill="currentColor"
              >
                <path d="M7.127 22.562l-7.127 1.438 1.438-7.128 5.689 5.69zm1.414-1.414l11.228-11.225-5.69-5.692-11.227 11.227 5.689 5.69zm9.768-21.148l-2.816 2.817 5.691 5.691 2.816-2.819-5.691-5.689z" />
              </svg>
              <input
                type="number"
                id="loan-term"
                name="loan-term"
                min={props.fields.MinTerm.value}
                max={props.fields.MaxTerm.value}
                value={loanTerm}
                onChange={(e) => setLoanTerm(parseInt(e.target.value))}
              />
              <span className="fw-bold">
                <Text field={props.fields.TermName} />
              </span>
            </div>
          </div>
        </div>
        <div className="row">
          <div className="col-12">
            <div className="loan-calculator-range-wrapper">
              <input
                type="range"
                id="loan-term-range"
                name="loan-term-range"
                min={props.fields.MinTerm.value}
                max={props.fields.MaxTerm.value}
                value={loanTerm}
                onChange={(e) => setLoanTerm(parseInt(e.target.value))}
                style={{
                  backgroundSize: `${loanTerm < props.fields.MinTerm.value
                      ? '0'
                      : loanTerm > props.fields.MaxTerm.value
                        ? '100'
                        : ((loanTerm - props.fields.MinTerm.value) * 100) /
                        (props.fields.MaxTerm.value - props.fields.MinTerm.value)
                    }% 100%`,
                }}
              />
            </div>
          </div>
        </div>
        <div className="row justify-content-between">
          <div className="col-auto">
            <span>
              <Text field={props.fields.MinTerm} /> <Text field={props.fields.TermName} />
            </span>
          </div>
          <div className="col-auto">
            <span>
              <Text field={props.fields.MaxTerm} /> <Text field={props.fields.TermName} />
            </span>
          </div>
        </div>
      </div>

      <div className="loan-calculator-results">
        <div className="loan-calculator-monthly-payment">
          <ResultLine
            left={t('Monthly payment') || 'Monthly payment'}
            right={
              <>
                {monthlyPayment.toFixed(2)} <Text field={props.fields.Currency} />
              </>
            }
          />
        </div>
        <ResultLine
          left={t('Interest rate') || 'Interest rate'}
          right={
            <>
              <Text field={props.fields.InterestRate} />%
            </>
          }
        />
        <ResultLine
          left={t('Bank package fee') || 'Bank package fee'}
          right={
            <>
              <Text field={props.fields.BankFee} /> <Text field={props.fields.Currency} />
            </>
          }
        />
        <ResultLine
          left={t('Total interest') || 'Total interest'}
          right={
            <>
              {totalInterest.toFixed(2)} <Text field={props.fields.Currency} />
            </>
          }
        />
        <ResultLine
          left={t('Total debt') || 'Total debt'}
          right={
            <>
              {totalDebt.toFixed(2)} <Text field={props.fields.Currency} />
            </>
          }
        />
      </div>
    </div>
  );
};

Astro:

---
import {
  Field,
  Text,
  useTranslations,
} from "@astro-sitecore-jss/astro-sitecore-jss";
import ResultLine from "components/NestedComponents/LoanCalculatorResultLine.astro";

interface Fields {
  BankFee: Field<number>;
  Currency: Field<string>;
  InterestRate: Field<number>;
  MaxAmount: Field<number>;
  MaxTerm: Field<number>;
  MinAmount: Field<number>;
  MinTerm: Field<number>;
  TermName: Field<string>;
}

export type LoanCalculatorProps = {
  params: { [key: string]: string };
  fields: Fields;
};

const props: LoanCalculatorProps = Astro.props.route;
const id = props.params.RenderingIdentifier;

const t = useTranslations();

let loanAmount = Math.round(
  (props.fields.MinAmount.value + props.fields.MaxAmount.value) / 2
);
let loanTerm = Math.round(
  (props.fields.MinTerm.value + props.fields.MaxTerm.value) / 2
);

const bgLoanAmount = `${
  loanAmount < props.fields.MinAmount.value
    ? "0"
    : loanAmount > props.fields.MaxAmount.value
      ? "100"
      : ((loanAmount - props.fields.MinAmount.value) * 100) /
        (props.fields.MaxAmount.value - props.fields.MinAmount.value)
}% 100%`;

const bgLoanTerm = `${
  loanTerm < props.fields.MinTerm.value
    ? "0"
    : loanTerm > props.fields.MaxTerm.value
      ? "100"
      : ((loanTerm - props.fields.MinTerm.value) * 100) /
        (props.fields.MaxTerm.value - props.fields.MinTerm.value)
}% 100%`;
---

<div
  class={`component loan-calculator ${props.params.styles.trimEnd()}`}
  id={id ? id : undefined}
  data-interest-rate={props.fields.InterestRate.value}
  data-min-amount={props.fields.MinAmount.value}
  data-max-amount={props.fields.MaxAmount.value}
  data-min-term={props.fields.MinTerm.value}
  data-max-term={props.fields.MaxTerm.value}
  data-bank-fee={props.fields.BankFee.value}
>
  <div class="loan-calculator-input-group">
    <div class="row justify-content-between">
      <div class="col-auto">
        <label for="loan-amount">{t("Amount") || "Amount"}</label>
      </div>
      <div class="col-auto">
        <div class="loan-calculator-input-wrapper">
          <svg
            xmlns="http://www.w3.org/2000/svg"
            width="16"
            height="26"
            viewBox="0 0 24 24"
            fill="currentColor"
          >
            <path
              d="M7.127 22.562l-7.127 1.438 1.438-7.128 5.689 5.69zm1.414-1.414l11.228-11.225-5.69-5.692-11.227 11.227 5.689 5.69zm9.768-21.148l-2.816 2.817 5.691 5.691 2.816-2.819-5.691-5.689z"
            ></path>
          </svg>
          <input
            type="number"
            id="loan-amount"
            name="loan-amount"
            min={props.fields.MinAmount.value}
            max={props.fields.MaxAmount.value}
            value={loanAmount}
          /><span class="fw-bold"><Text field={props.fields.Currency} /></span>
        </div>
      </div>
    </div>
    <div class="row">
      <div class="col-12">
        <div class="loan-calculator-range-wrapper">
          <input
            type="range"
            id="loan-amount-range"
            name="loan-amount-range"
            min={props.fields.MinAmount.value}
            max={props.fields.MaxAmount.value}
            value={loanAmount}
            style={{
              backgroundSize: bgLoanAmount,
            }}
          />
        </div>
      </div>
    </div>
    <div class="row justify-content-between">
      <div class="col-auto">
        <span>
          <Text field={props.fields.MinAmount} />
          <Text field={props.fields.Currency} />
        </span>
      </div>
      <div class="col-auto">
        <span>
          <Text field={props.fields.MaxAmount} />
          <Text field={props.fields.Currency} />
        </span>
      </div>
    </div>
  </div>
  <div class="loan-calculator-input-group">
    <div class="row justify-content-between">
      <div class="col-auto">
        <label for="loan-amount">
          {t("Term of repayment") || "Term of repayment"}
        </label>
      </div>
      <div class="col-auto">
        <div class="loan-calculator-input-wrapper">
          <svg
            xmlns="http://www.w3.org/2000/svg"
            width="16"
            height="26"
            viewBox="0 0 24 24"
            fill="currentColor"
          >
            <path
              d="M7.127 22.562l-7.127 1.438 1.438-7.128 5.689 5.69zm1.414-1.414l11.228-11.225-5.69-5.692-11.227 11.227 5.689 5.69zm9.768-21.148l-2.816 2.817 5.691 5.691 2.816-2.819-5.691-5.689z"
            ></path>
          </svg>
          <input
            type="number"
            id="loan-term"
            name="loan-term"
            min={props.fields.MinTerm.value}
            max={props.fields.MaxTerm.value}
            value={loanTerm}
          /><span class="fw-bold"><Text field={props.fields.TermName} /></span>
        </div>
      </div>
    </div>
    <div class="row">
      <div class="col-12">
        <div class="loan-calculator-range-wrapper">
          <input
            type="range"
            id="loan-term-range"
            name="loan-term-range"
            min={props.fields.MinTerm.value}
            max={props.fields.MaxTerm.value}
            value={loanTerm}
            style={{
              backgroundSize: bgLoanTerm,
            }}
          />
        </div>
      </div>
    </div>
    <div class="row justify-content-between">
      <div class="col-auto">
        <span>
          <Text field={props.fields.MinTerm} />
          <Text field={props.fields.TermName} />
        </span>
      </div>
      <div class="col-auto">
        <span>
          <Text field={props.fields.MaxTerm} />
          <Text field={props.fields.TermName} />
        </span>
      </div>
    </div>
  </div>

  <div class="loan-calculator-results">
    <div class="loan-calculator-monthly-payment">
      <ResultLine class="monthly-payment">
        <span slot="left">
          {t("Monthly payment") || "Monthly payment"}
        </span>
        <span slot="right">
          <>
            <span class="monthly-payment">{0}</span>
            <Text field={props.fields.Currency} />
          </>
        </span>
      </ResultLine>
    </div>
    <ResultLine>
      <span slot="left">
        {t("Interest rate") || "Interest rate"}
      </span>
      <span slot="right">
        <>
          <Text field={props.fields.InterestRate} />%
        </>
      </span>
    </ResultLine>
    <ResultLine>
      <span slot="left">
        {t("Bank package fee") || "Bank package fee"}
      </span>
      <span slot="right">
        <>
          <Text field={props.fields.BankFee} />
          <Text field={props.fields.Currency} />
        </>
      </span>
    </ResultLine>
    <ResultLine>
      <span slot="left">
        {t("Total interest") || "Total interest"}
      </span>
      <span slot="right">
        <>
          <span class="total-interest">{0}</span>
          <Text field={props.fields.Currency} />
        </>
      </span>
    </ResultLine>
    <ResultLine>
      <span slot="left">
        {t("Total debt") || "Total debt"}
      </span>
      <span slot="right">
        <>
          <span class="total-debt">{0}</span>
          <Text field={props.fields.Currency} />
        </>
      </span>
    </ResultLine>
  </div>
</div>

<script>
  const calculators = document.querySelectorAll(".loan-calculator");

  calculators.forEach((calculator: Element) => {
    setCalculator(calculator as HTMLElement);
  });

  function setCalculator(calculator: HTMLElement) {
    const termInput = calculator.querySelector(
      "#loan-term"
    ) as HTMLInputElement;
    const termRangeInput = calculator.querySelector(
      "#loan-term-range"
    ) as HTMLInputElement;
    const amountInput = calculator.querySelector(
      "#loan-amount"
    ) as HTMLInputElement;
    const amountRangeInput = calculator.querySelector(
      "#loan-amount-range"
    ) as HTMLInputElement;

    const monthlyPaymentEl = calculator.querySelector(".monthly-payment");
    const totalDebtEl = calculator.querySelector(".total-debt");
    const totalInterestEl = calculator.querySelector(".total-interest");

    const interestRate = parseFloat(calculator.dataset.interestRate as string);
    const minAmount = parseFloat(calculator.dataset.minAmount as string);
    const maxAmount = parseFloat(calculator.dataset.maxAmount as string);
    const minTerm = parseFloat(calculator.dataset.minTerm as string);
    const maxTerm = parseFloat(calculator.dataset.maxTerm as string);
    const bankFee = parseFloat(calculator.dataset.bankFee as string);

    const monthlyInterestRate = interestRate / 100 / 12;

    let loanAmount = Math.round((minAmount + maxAmount) / 2);
    let loanTerm = Math.round((minTerm + maxTerm) / 2);

    function calculate() {
      const monthlyPayment =
        (loanAmount * monthlyInterestRate) /
        (1 - Math.pow(1 + monthlyInterestRate, -loanTerm));
      const totalDebt = monthlyPayment * loanTerm + bankFee;
      const totalInterestCalculation = totalDebt - loanAmount - bankFee;
      const totalInterest = parseFloat(totalInterestCalculation.toFixed(2));

      monthlyPaymentEl!.textContent = monthlyPayment.toFixed(2);
      totalDebtEl!.textContent = totalDebt.toFixed(2);
      totalInterestEl!.textContent = totalInterest.toFixed(2);

      setRangeBackground();
    }

    function setRangeBackground() {
      const bgLoanAmount = `${
        loanAmount < minAmount
          ? "0"
          : loanAmount > maxAmount
            ? "100"
            : ((loanAmount - minAmount) * 100) / (maxAmount - minAmount)
      }% 100%`;

      amountRangeInput!.style.backgroundSize = bgLoanAmount;

      const bgLoanTerm = `${
        loanTerm < minTerm
          ? "0"
          : loanTerm > maxTerm
            ? "100"
            : ((loanTerm - minTerm) * 100) / (maxTerm - minTerm)
      }% 100%`;

      termRangeInput!.style.backgroundSize = bgLoanTerm;
    }

    function getInputValue(
      value: string,
      minValue: number,
      maxValue: number
    ): number {
      const numberValue = parseInt(value);
      if (numberValue < minValue) return minValue;
      if (numberValue > maxValue) return maxValue;
      return numberValue;
    }

    termInput.addEventListener("input", (e) => {
      const value = (e.target as HTMLInputElement).value as string;
      loanTerm = getInputValue(value, minTerm, maxTerm);
      termRangeInput.value = loanTerm.toString();
      calculate();
    });

    termRangeInput.addEventListener("input", (e) => {
      const value = (e.target as HTMLInputElement).value as string;
      loanTerm = getInputValue(value, minTerm, maxTerm);
      termInput.value = loanTerm.toString();
      calculate();
    });

    amountInput.addEventListener("input", (e) => {
      const value = (e.target as HTMLInputElement).value as string;
      loanAmount = getInputValue(value, minAmount, maxAmount);
      amountRangeInput.value = loanAmount.toString();
      calculate();
    });

    amountRangeInput.addEventListener("input", (e) => {
      const value = (e.target as HTMLInputElement).value as string;
      loanAmount = getInputValue(value, minAmount, maxAmount);
      amountInput.value = loanAmount.toString();
      calculate();
    });

    calculate();
  }
</script>

The React code for complex user interactive components will be cleaner and maintainable compared to Astro. But, you are not forced to use only .astro components on the Astro website. You could and should use React/Vue/Angular/Svelte/Solid.js for complex user interactive elements on the Astro website!

Conclusion

Always use the proper tools for the proper tasks! For example, ASP.Net MVC will not be the best choice for a highly interactive website. Any SPA approach will be better. However, the SPA approach will be overhead for static websites. You will spend more time on the implementation and will get worse performance.

Astro allows you to get the best of two worlds! Use static .astro components for static parts. Use React(or any other UI framework) for user interactive parts. And you will get a highly maintainable and highly performant website.