Я ищу хороший стек, на который я могу переключиться для своих новых проектов. Итак, я решил попробовать и оценить Next.js, Попутный ветер CSSа также Призма стек с небольшим проектом.

Прежде чем я начну, я должен признать, что у меня нет опыта или вообще нет опыта работы с Реагировать, Tailwind CSS и Prisma. Я также не чувствую, что глубоко разбираюсь в JavaScript. И это отличная новость, потому что вы увидите этот стек глазами новичка, а не страстного поклонника этих технологий.


Почему Next.js, Tailwind CSS и Prisma


Next.js

Я выбрал Next.js для тестирования, потому что он популярен и имеет хороший инструментарий. Да, популярность для меня хороший фактор. Я устал искать решения нечастых ошибок для «подпольного» фреймворка, который я выбрал в прошлом.

Я хочу найти один широко распространенный стек с хорошим опытом разработки, который является производительным и масштабируемым. Я не говорю, что Next.js удовлетворяет моим критериям, но похоже, что может. Вот почему я создаю приложение, чтобы протестировать его.


Попутный ветер CSS

я в основном использовал Твиттер Начальная загрузка как CSS-фреймворк, но Tailwind CSS стал довольно популярным за последние несколько лет. И он предлагает подход, отличный от того, что вы делаете с фреймворком Bootstrap.

С самого начала Twitter Bootstrap сосредоточился на предоставлении множества удобных компонентов, таких как кнопки, ползунки, таблицы и т. д.

Tailwind CSS — это прежде всего служебная среда. Это означает, что по умолчанию компонентов вообще нет, и вы сами решаете, как вы хотите оформить свою страницу.

Однако в последнее время я заметил большое сходство обоих подходов. Twitter Bootstrap добавляет все больше и больше служебных классов, и вы можете купить Интерфейс попутного ветра или взгляните на ромашка пользовательский интерфейс а также Укускоторые предоставляют множество компонентов, которые вы, возможно, захотите использовать вместе с Tailwind CSS.


Призма

Существует множество популярных библиотек ORM для Node.js: Призма, Сиквел, ТипORMи другие.

Сначала я решил попробовать Prisma, потому что мне нравится простота их подхода. Мне не нужно создавать модель, как в ORM, на основе шаблонов Active Record и Data Mapper.

Prisma дает мне типизированную абстракцию над базой данных, и я могу решить, как я хочу ее использовать. Я могу создавать модели или использовать Prisma напрямую — это зависит исключительно от моих потребностей.

В качестве базы данных я взял SQLite для простоты разработки. Но вы можете использовать Поддерживаются MySQL, PostgreSQL, MongoDB и многие другие БД. от Призмы.


Заворачивать

Next.js, Tailwind CSS и Prisma могут стать хорошим стартовым выбором для инди-хакерыиндивидуальные предприниматели, а также малые и корпоративные команды.

Давайте напишем код и попробуем этот стек!


Знакомьтесь: «Охота за целевой страницей»

Ты когда-нибудь видел ПродуктХант, бета-список, Инструменты для авторовили же CtrlAlt.CC?

Мы создадим небольшое приложение — каталог сайтов, содержащий список целевых страниц со скриншотами. Пользователь приложения может лайкать страницы и отправлять свои целевые страницы.

Предварительный просмотр Lading Page Hunt

Вот и все функции, которые мы собираемся реализовать. Я хочу сосредоточиться только на основных функциях приложения; все остальное не имеет значения. Я немного расширю тему аутентификации и подписок в конце поста.

Исходный код проекта доступен на github.com/screenshotone/целевая страница-охота.


Быстрый старт

Давайте создадим приложение Next.js:

npx create-next-app landing-page-hunt && cd landing-page-hunt
Войти в полноэкранный режим

Выйти из полноэкранного режима

Затем установите Tailwind CSS:

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
Войти в полноэкранный режим

Выйти из полноэкранного режима

Нам нужно обновить наши пути к шаблонам в tailwind.config.js чтобы соответствовать структуре каталогов Next.js:

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./pages/**/*.{js,ts,jsx,tsx}",
    "./components/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Чтобы включить поддержку классов CSS Tailwind, нам нужно импортировать его в globals.css:

@tailwind base;
@tailwind components;
@tailwind utilities;
Войти в полноэкранный режим

Выйти из полноэкранного режима

Затем мы можем запустить приложение в режиме разработки и начать кодирование:

npm run dev
Войти в полноэкранный режим

Выйти из полноэкранного режима

Давайте быстро создадим базовый шаблон и отрисуем страницы:

// pages/index.js
export default function Home({ landingPages }) {
  return (
    <div className="md:container md:mx-auto px-4">
      <h1 className="text-4xl font-bold text-center pt-5">
        Landing Page Hunt
      </h1>
      <h2 className="pb-5 text-center">
        An inspiration bucket for your next landing page.
      </h2>
      <div className="flex flex-row flex-wrap justify-center gap-8">
        {landingPages.map(landingPage =>
        (<div key={landingPage.url} className="p-2">
          <div className="text-2xl text-center py-3">
            <a href={landingPage.url}>{landingPage.name}</a>
          </div>
          <div className="mt-2 shadow-lg">
            <a href={landingPage.url}><img src={landingPage.screenshotUrl} width="400" /></a>
          </div>
          <div className="text-right text-sm py-5">
            {landingPage.likes} 👍
          </div>
        </div>))}
      </div>
    </div>
  )
}

export async function getStaticProps() {
  const accessKey = process.env.SCREENSHOTONE_ACCESS_KEY;

  const screenshotUrl = (url) => {
    return `https://api.screenshotone.com/take?access_key=${accessKey}&url=${url}&cache=true`;
  }

  const landingPages = [
    {
      name: "KTool",
      url: "https://ktool.io",
      likes: 2,
    },
    // ...
    {
      name: "Ship SaaS",
      url: "https://shipsaas.com/",
      likes: 3,
    }
  ];

  for (let landingPage of landingPages) {
    landingPage.screenshotUrl = screenshotUrl(landingPage.url);
  }

  return {
    props: {
      landingPages
    },
  }
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Зарегистрируйтесь на ScreenshotOne чтобы получить ключ доступа к скриншоту API и заставить его работать, нам нужно создать .env.local файл и установить SCREENSHOTONE_ACCESS_KEY переменная окружения:

SCREENSHOTONE_ACCESS_KEY=<YOUR ACCESS KEY> 
Войти в полноэкранный режим

Выйти из полноэкранного режима

Давайте изучим результат:

Скриншот проекта Landing Page Hunt

Несколько основных моментов:

  1. Next.js основан на React, но решает его самые популярные проблемы, поэтому мы можем быстро создавать компоненты, не думая о маршрутизации, рендеринге на стороне сервера и даже API. Это полноценный фреймворк с полным стеком.

  2. я использовал getStaticProps функция для предоставления данных для индексной страницы во время сборки. Это означает, что наша главная страница не является динамической. Он статичен и уже отрендерен — поэтому он молниеносно быстр.

  3. я не настраивал Кукольник делать скриншоты сайтов, но решил использовать скриншот как сервисный API. Он дает кучу бесплатных скриншотов для рендеринга веб-сайтов, так что этого достаточно, чтобы поиграть с нашим проектом. Потому что создание скриншотов сайта не является основной темой поста, а использование Puppeteer было бы занозой в заднице.


Рефакторинг для использования динамического контента

Мы начали с создания статического сайта с помощью Next.js, но мы хотим лайкать страницы, отправлять страницы и получать обновленные результаты. Итак, нам нужно реорганизовать код, чтобы использовать более сложный подход для наших нужд.

Предлагаю сделать две вещи:

  1. Выделите логику, связанную с лендингами, в отдельный блок — Directory класс, представляющий каталог целевой страницы. Это поможет нам позже перейти на реальную базу данных вместо кэша в памяти.

  2. И использовать getServerSideProps для динамического отображения страниц при каждом запросе.


Извлечение «бизнес» логики

Directory class будет инкапсулировать всю логику, связанную с каталогом целевой страницы:

// lib/directory.js
class Directory {
    constructor() {
        const accessKey = process.env.SCREENSHOT_ONE_ACCESS_KEY;

        const screenshotUrl = (url) => {
            return `https://api.screenshotone.com/take?access_key=${accessKey}&url=${url}&cache=true`;
        }

        const landingPages = [
            {
                name: "KTool",
                url: "https://ktool.io",
                likes: 2,
            },
            // ... 
            {
                name: "Ship SaaS",
                url: "https://shipsaas.com/",
                likes: 3,
            }
        ];

        for (let landingPage of landingPages) {
            landingPage.slug = landingPage.name.toLowerCase().replace(' ', '');
            landingPage.screenshotUrl = screenshotUrl(landingPage.url);
        }

        this.landingPages = landingPages;
    }

    async loadLandingPages() {
        return this.landingPages;
    }
}

let directory = null;
if (process.env.NODE_ENV === 'production') {
    directory = new Directory(prisma);
} else {
    if (!global.directory) {
        global.directory = new Directory(prisma);
    }

    directory = global.directory;
}

export default directory;
Войти в полноэкранный режим

Выйти из полноэкранного режима

Чтобы ускорить разработку, я храню все в памяти, и как только приложение будет готово, мы просто перейдем на использование реальной базы данных.


getServerSideProps

Вместо использования getStaticPropsтеперь мы переходим к использованию getServerSideProps. Это позволит нам динамически отображать страницу при каждом запросе. Посмотрите, как это просто:

// pages/index.js
import directory from "../lib/directory";

export default function Home(props) {
  // ... 
}

// switch to getServerSideProps instead of using getStaticProps 
// to render the content on a per-request basis.
export async function getServerSideProps(context) {
  const landingPages = await directory.loadLandingPages();

  return {
    props: {
      landingPages
    },
  }
}
Войти в полноэкранный режим

Выйти из полноэкранного режима


Отправка запросов API с помощью Next.js

Давайте посмотрим, как легко добавить обработчики API на серверную часть и отправлять запросы API со стороны клиента. Мы собираемся реализовать функцию «нравится страница».

Во-первых, я заложил логику в lib/directory.js:

// lib/directory.js
class Directory {
    // ... 
    async likePage(slug) {
        for (const landingPage of this.landingPages) {
            if (landingPage.slug == slug) {
                landingPage.likes++;

                return landingPage.likes;
            }
        }

        return 0;
    }
    // ...   
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Затем мы создаем обработчик API pages/api/like.js:

// pages/api/like.js
import directory from "../../lib/directory";

export default async function handler(req, res) {
  const { slug } = req.body;
  const likes = await directory.likePage(slug);

  res.status(200).json({ likes: likes });
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Next.js автоматически справится со всеми проблемами маршрутизации. Итак, давайте обновим компонент страницы pages/index.js для отправки HTTP-запроса аналогичному обработчику API:

// pages/index.js
import React, { useState } from "react";
import directory from "../lib/directory";

export default function Home(props) {
  const [landingPages, setLandingPages] = useState(props.landingPages);

  const likePage = async (slug) => {
    try {
      const response = await fetch(
        `/api/like`,
        {
          headers: {
            'Content-Type': 'application/json'
          },
          method: 'POST',
          body: JSON.stringify({ slug })
        }
      );
      const data = await response.json();
      setLandingPages(
        landingPages.map(p => p.slug == slug ? { ...p, ...{ likes: data.likes } } : p)
      );
    } catch (err) {
      console.error(err);
    }
  };

  return (
    <div className="md:container md:mx-auto px-4">
      <h1 className="text-4xl font-bold text-center pt-5 bg-clip-text text-transparent bg-gradient-to-r from-purple-400 to-pink-600">
        Landing Page Hunt
      </h1>
      <h2 className="pb-5 text-center">
        An inspiration bucket for your next landing page.
      </h2>
      <div className="flex flex-row flex-wrap justify-center gap-8">
        {landingPages.map(landingPage =>
        (<div key={landingPage.url} className="p-2">
          <div className="text-2xl text-center py-3">
            <a href={landingPage.url}>{landingPage.name}</a>
          </div>
          <div className="mt-2 shadow-lg">
            <a href={landingPage.url}><img src={landingPage.screenshotUrl} width="400" /></a>
          </div>
          <div className="text-right text-sm py-5">
            <button onClick={async () => await likePage(landingPage.slug)} className="bg-transparent  hover:text-blue-400 text-blue-900 font-semibold py-2 px-4 border hover:border-blue-400 border-blue-900 rounded">
              {landingPage.likes} 👍
            </button>
          </div>
        </div>))}
      </div>
    </div>
  );
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

В результате мы можем нажать на кнопку «Нравится». Затем делается HTTP-запрос к обработчику API, свойство Likes страницы обновляется, и мы получаем результат, чтобы мы могли повторно отобразить все целевые страницы.


Больше страниц и форм

Нам нужна возможность представить целевую страницу. Итак, приступим к разработке страницы. Добавлять новые страницы с помощью Next.js очень просто, и, как я уже упоминал ранее, маршрутизация выполняется за нас.

Когда я создаю pages/submit.jsNext.js будет обрабатывать все запросы к странице по пути /submit.

Давайте создадим простую страницу отправки:

// pages/submit.js
import { useState } from "react";
import Confetti from 'react-confetti';
import { useWindowSize } from 'react-use';

export default function Submit(props) {
  const endpoint = '/api/submit';
  const [landingPage, setLandingPage] = useState(null);

  const handleSubmit = async (event) => {
    event.preventDefault();
    try {
      const response = await fetch(endpoint, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          name: event.target.name.value,
          url: event.target.url.value,
        }),
      })

      setLandingPage(await response.json());
    } catch (error) {
      console.error(error);
    }
  }

  const { width, height } = useWindowSize();

  return (
    <div className="mx-auto w-1/2">
      <h3 className="text-xl text-center">{landingPage ? "The page has been submitted successfully 🥳" : "Submit a landing page"}</h3>
      {
        landingPage ? (          
          <div className="mx-auto py-2 max-w-fit">
            <Confetti opacity={0.5} width={width} height={height} run={landingPage != null} />
            <div className="text-2xl text-center py-3">
              <a href={landingPage.url}>{landingPage.name}</a>
            </div>
            <div className="mt-2 shadow-lg">
              <a href={landingPage.url}><img src={landingPage.screenshotUrl} width="460" /></a>
            </div>
          </div>
        ) : (
          <form onSubmit={handleSubmit} action={endpoint} className="mt-5">
            <div className="mb-6">
              <label htmlFor="name" className="block mb-2 text-sm font-medium text-gray-900">Name</label>
              <input type="text" id="name" className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 " required />
            </div>
            <div className="mb-6">
              <label htmlFor="url" className="block mb-2 text-sm font-medium text-gray-900">URL</label>
              <input type="text" id="url" className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 " required />
            </div>
            <div className="text-right">
              <button type="submit" className="text-white bg-gradient-to-br from-purple-600 to-blue-500 hover:bg-gradient-to-bl font-medium rounded-lg text-sm px-5 py-2.5 text-center mr-2 mb-2">Submit</button>
            </div>
          </form>
        )
      }
    </div>
  );
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Я добавил конфетти после отправки с помощью кнопки реагировать-конфетти библиотека:

Конфетти

При отправке мы читаем данные формы и отправляем запрос обработчику API отправки (pages/api/submit.js):

// pages/api/submit.js
import directory from "../../lib/directory";

export default async function handler(req, res) {
    const body = req.body
    if (!body.name || !body.url) {
        return res.status(400).json({ data: 'Name and URL are required' })
    }

    const landingPage = await directory.submitPage(body.name, body.url);

    res.status(200).json(landingPage);
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

И очевидная логика функции отправки в Directory учебный класс:

// lib/directory.js
class Directory {
    // ... 
    async submitPage(name, url) {
        const landingPage = {
            name: name,
            url: url,
            slug: this.slug(name),
            screenshotUrl: this.screenshotUrl(url),
            likes: 0,
        };

        this.landingPages.push(landingPage);

        return landingPage;
    }

    slug(name) {
        return name.toLowerCase().replace(' ', '');
    }

    screenshotUrl(url) {
        const accessKey = process.env.SCREENSHOTONE_ACCESS_KEY;

        return `https://api.screenshotone.com/take?access_key=${accessKey}&url=${url}&cache=true`;
    }
    // ... 
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Вот как просто разрабатывать с Next.js. Ты чувствуешь это?


Переход на базу данных SQL — кейс для Prisma

Основной функционал нашего приложения готов и протестирован. Поскольку он инкапсулирован, мы можем легко переключиться на хранение данных в постоянной базе данных, а не во временной памяти.

Но перед этим установим и настроим Prisma:

npm i prisma
Войти в полноэкранный режим

Выйти из полноэкранного режима

Давайте создадим каталог, prismaгде мы будем хранить всю конфигурацию, связанную с Prisma, и начнем с основной конфигурации и схемы БД:

// prisma/schema.prisma
generator client {
    provider = "prisma-client-js"
}

datasource db {
    provider = "sqlite"
    url      = "file:./dev.db"
}

model LandingPage {
    id    Int    @id @default(autoincrement())
    slug  String @unique()
    name  String
    url   String
    likes Int

    @@index([slug])
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Файл схемы написан на PSL (язык схемы Prisma).

Затем создадим начальный файл prisma/seed.js с посадочными страницами:

// prisma/seed.js
const { PrismaClient } = require('@prisma/client');

const prisma = new PrismaClient();

const landingPages = [
    {
        slug: "ktool",
        name: "KTool",
        url: "https://ktool.io",
        likes: 2,
    },
    // ... 
    {
        slug: "shipsaas",
        name: "Ship SaaS",
        url: "https://shipsaas.com/",
        likes: 3,
    }
];


async function main() {
    console.log(`Start seeding ...`)
    for (const lp of landingPages) {
        const user = await prisma.landingPage.create({
            data: lp,
        })
        console.log(`Created landing page with id: ${user.id}`)
    }
    console.log(`Seeding finished.`)
}

main()
    .then(async () => {
        await prisma.$disconnect()
    })
    .catch(async (e) => {
        console.error(e)
        await prisma.$disconnect()
        process.exit(1)
    });
Войти в полноэкранный режим

Выйти из полноэкранного режима

Чтобы сделать его частью процесса миграции, нам нужно указать начальное значение в package.json:

{
    // ...
    "prisma": {
        "seed": "node prisma/seed.js"
    }
    // ...
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

И после запуска миграций, включая раздачу:

npx prisma migrate dev
Войти в полноэкранный режим

Выйти из полноэкранного режима

Мы готовы использовать Prisma клиент в нашем приложении. Давайте создадим клиент Prisma в lib/prisma.js:

// lib/prisma.js
import { PrismaClient } from "@prisma/client";

let prisma = null;
if (process.env.NODE_ENV === 'production') {
    prisma = new PrismaClient();
} else {
    if (!global.prisma) {
        global.prisma = new PrismaClient();
    }

    prisma = global.prisma;
}

export default prisma;
Войти в полноэкранный режим

Выйти из полноэкранного режима

PrismaClient прилагается к global объект в разработке, чтобы предотвратить исчерпание лимита подключения к базе данных.

И мы готовы провести рефакторинг Directory класс, на который можно полностью положиться Prisma:

// lib/directory.js
import prisma from "./prisma";
import screenshotUrl from "./screenshotone";

class Directory {
    constructor(prisma) {
        this.prisma = prisma;
    }

    async submitPage(name, url) {
        const landingPage = await this.prisma.landingPage.create({
            data: {
                slug: this.slug(name),
                name,
                url,
                likes: 0,
            },
        })

        return {
            ...landingPage,
            screenshotUrl: screenshotUrl(url)
        };
    }

    async likePage(slug) {
        const landingPage = await this.prisma.landingPage.update({
            where: {
                slug: slug
            },
            data: {
                likes: {
                    increment: 1,
                }
            },
        });

        return landingPage.likes;
    }

    async loadLandingPages() {
        const pages = await this.prisma.landingPage.findMany();

        return pages.map(p => {
            return {
                ...p,
                screenshotUrl: screenshotUrl(p.url)
            };
        });
    }

    slug(name) {
        return name.toLowerCase().replace(' ', '').replace('.', '');
    } 
}

let directory = null;
if (process.env.NODE_ENV === 'production') {
    directory = new Directory(prisma);
} else {
    if (!global.directory) {
        global.directory = new Directory(prisma);
    }

    directory = global.directory;
}

export default directory;
Войти в полноэкранный режим

Выйти из полноэкранного режима

Я выбрал SQLite для простоты. Я также вынес логику генерации ссылок в отдельный модуль.


Защита ключей API для скриншотов API

Визуализированные снимки экрана в приложении предоставляют ключ доступа к API снимков экрана. На этапе разработки это было нормально, но для развертывания в рабочей среде нам необходимо надежно хранить ключ.

ScreenshotOne API позволяет использовать подписанные ссылки. Это означает, что каждый открытый URL-адрес снимка экрана подписан публично, и если вы попытаетесь изменить какой-либо параметр и повторно использовать (украсть) ключ API, вам нужно будет вычислить новую подпись, но вы не сможете этого сделать, не имея секретного ключа.

Скриншот открытого ключа API и подписи

Кроме того, вы можете заставить ScreenshotOne API принимать только подписанные запросы.

Вот как я создал подписанные ссылки с помощью SDK ScreenshotOne API:

// lib/screenshotone.js
import * as screenshotone from 'screenshotone-api-sdk';

let client = null;
if (process.env.NODE_ENV === 'production') {
    client = new screenshotone.Client(process.env.SCREENSHOTONE_ACCESS_KEY, process.env.SCREENSHOTONE_SECRET_KEY);
} else {
    if (!global.client) {
        global.client = new screenshotone.Client(process.env.SCREENSHOTONE_ACCESS_KEY, process.env.SCREENSHOTONE_SECRET_KEY);
    }

    client = global.client;
}

export default function url(url) {
    const options = screenshotone.TakeOptions
        .url(url)
        .cache(true)
        .cacheTtl(2000000)
        .blockChats(true)
        .blockCookieBanners(true)
        .blockAds(true);

    return client.generateTakeURL(options);
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Другим решением может быть рендеринг скриншотов и сохранение скриншотов на бэкэнд-сайте, а также обмен отрендеренными изображениями с интерфейсом. Или используйте «загрузить скриншоты на S3» функцию ScreenshotOne, а затем обслуживать файлы из CDN.


Расширение приложения для завершения решения SaaS

И мы подошли к тому этапу, когда хотим немного заработать на нашем маленьком, простом проекте. Представьте, что вы разрешаете принимать платежи за показ целевых страниц. Но пользователям нужно будет зарегистрироваться. И мы должны убедиться, что голосование будет честным. Для этого требуется несколько вещей:

  • Аутентификация;
  • Биллинг и подписки;
  • Транзакционные письма;
  • Панель для управления приложением;
  • Покройте приложение тестами;
  • Убедитесь, что сайт оптимизирован для SEO;
  • Интернализация, если мы хотим расшириться.

В Next.js есть много решений, которые вы можете комбинировать, чтобы решить все проблемы, упомянутые выше.

Но я хочу предложить кое-что интересное, если ваша цель — поставить SaaS, а не просто играть с кодом, как я люблю делать. Я бы сказал, что начинать с шаблона SaaS лучше, чтобы сэкономить время и энергию.

В настоящее время существует множество решений SaaS, которые вы можете использовать для быстрой загрузки вашего проекта. Взгляни на Ship SaaS — шаблон SaaS на основе Next.js, например, и проблемы, которые он решает для вас. Это буквально может сэкономить вам тысячи часов.


Вместо резюме

Как вы могли заметить, мне очень понравилось строить с помощью Next.js, Tailwind CSS и Prisma. Мне не терпится попробовать их для любой новой идеи, которая появится у меня в будущем. Многие задачи уже решены в этих фреймворках, и ими легко пользоваться.
Кроме того, есть множество хороших шаблонов SaaS, которые я могу использовать, если хочу быстрее загрузить SaaS.