From Next.js to Ruby on Rails

15 min read

After working with React daily for the past five years, I realized I wanted to switch to something simpler. That’s when I decided to give Rails a try by rebuilding my website.

How I arrive at my decision

My dissatisfaction with React began after dealing with constant issues caused by package deprecations and breaking changes being introduced almost every year. With each new project, I had to select the best available libs and abandon the outdated ones, leading to a constantly evolving stack. My initial tech stack was as follows:
  • Create React App
  • React Router Dom
  • Sass
  • Express
  • TypeORM
  • Passport
Then, styled components replaced Sass. TypeORM became obsolete as Sequelize was better. React Router introduced a breaking change.

I needed a message broker in the backend, Bull was the way to go. Also, Mongo was the *new* right db to choose. It took 1 to 2 years to ditch Create React App in favor of Next.js because it was better. Then, styled components were *bad*, and Tailwind was the way to go about styling. It didn’t take long before Passport was also ditched in favor of NextAuth. MongoDB was no longer cool; let’s go back to Postgres.

Bull turned into BullMQ, with more breaking changes. Prisma was the new cool ORM. The turning point for me was when Remix was launched… “SPAs are bad, let’s go back to server-side rendering…” Vercel launched the app router and “recommends”  using the app router, although they didn’t deprecate the page router.

What’s more? Well, Prisma, while being cool, messed with the node_modules folder, and this wasn’t acceptable. Therefore, we need a new lib, Drizzle!

To add insult to injury, every npm package seemed to become a PaaS, at least this was how I felt. After consideration, I decided to give Rails a try again.

The nostalgia also helped me take this decision because I remember how Rails was awesome in 2014 when I found out about it. Previously, I was working with PHP, and it was a pain in the ass. I remember clearly how great it felt writing the first site in Rails and how amazing it was.

I checked how Rails was doing, and it was going just great! It was like a small hidden club that people didn’t talk to the others. Plenty of cool stuff was being launched every year. The promise of having a single solution to all my problems felt amusing.

From idea to practice

Well, as is stated in the title, my site was made with Next.js and I really liked the design I had, so I wanted to keep it visually as it is. What made the transition easy for me was the fact that I was using Tailwind and the Tailwind support for Rails is great. My icons were made with React Icons, luckily finding a replacement using Lucide was easy and it also improved my design since all icons have the same style. 

In less than an hour, I copy-pasted everything and simply changed what needed to be changed. For example, this is a piece of React code:
<section className="w-[95%] lg:w-full border-x-[1px] border-b-[1px] border-[#FFFFFF]/[0.16] max-w-screen-lg m-auto relative flex flex-wrap">
        <div className="absolute top-0 left-0 right-0 mx-auto w-1/3 h-full pointer-events-none border-[#FFFFFF]/[0.16] border-r-[1px] border-l-[1px] hidden md:block"></div>
        <IconPlus className="absolute -bottom-3 -left-3 w-6 h-6" />
        <IconPlus className="absolute -bottom-3 -right-3 w-6 h-6" />

        <div className="w-full flex flex-col md:flex-row">
          <div className="w-full md:w-1/3 flex flex-col p-6">
            <Image src={ImgEstacio} width={150} alt="Estácio Logo" />
            <h3 className="text-2xl font-bold w-full mt-4">
              Software Engineer
            </h3>
            <p className="text-base mt-2">Bachelor{"'"}s Degree</p>
            <small className="text-gray-400">
              2022 - 2025 (Currently attending)
            </small>
          </div>
          <div className="w-full md:w-1/3 flex flex-col p-6 border-[#FFFFFF]/[0.16] border-t-[1px] md:border-t-0">
            <Image src={ImgEstacio} width={150} alt="Estácio Logo" />
            <h3 className="text-2xl font-bold w-full mt-4">
              Information Systems
            </h3>
            <p className="text-base mt-2">Bachelor{"'"}s Degree</p>
            <small className="text-gray-400">2014 - 2015 (60 credits)</small>
          </div>
          <div className="w-full md:w-1/3 flex flex-col p-6 border-[#FFFFFF]/[0.16] border-t-[1px] md:border-t-0">
            <Image src={ImgEasyComp} width={80} alt="EasyComp Logo" />
            <h3 className="text-2xl font-bold w-full mt-4">Web Design</h3>
            <p className="text-base mt-2">Certificate Program</p>
            <small className="text-gray-400">2011 - 2012</small>
          </div>
        </div>
      </section>
And this is how it looks like in Rails
<section class="w-[95%] lg:w-full border-x-[1px] border-b-[1px] border-[#FFFFFF]/[0.16] max-w-screen-lg m-auto relative flex flex-wrap">
    <div class="absolute top-0 left-0 right-0 mx-auto w-1/3 h-full pointer-events-none border-[#FFFFFF]/[0.16] border-r-[1px] border-l-[1px] hidden md:block"></div>
    <%= lucide_icon('plus', class: 'absolute -bottom-3 -left-3 w-6 h-6') %>
    <%= lucide_icon('plus', class: 'absolute -bottom-3 -right-3 w-6 h-6') %>

    <div class="w-full flex flex-col md:flex-row">
      <div class="w-full md:w-1/3 flex flex-col p-6">
        <img src="<%= asset_path('estacio-logo-1024x260.png') %>" alt="Estácio Logo" class="w-[150px]" />
        <h3 class="text-2xl font-bold w-full mt-4">
          Software Engineer
        </h3>
        <p class="text-base mt-2">Bachelor's Degree</p>
        <small class="text-gray-400">
          2022 - 2026 (Currently attending)
        </small>
      </div>
      <div class="w-full md:w-1/3 flex flex-col p-6 border-[#FFFFFF]/[0.16] border-t-[1px] md:border-t-0">
        <img src="<%= asset_path('estacio-logo-1024x260.png') %>" alt="Estácio Logo" class="w-[150px]" />
        <h3 class="text-2xl font-bold w-full mt-4">
          Information Systems
        </h3>
        <p class="text-base mt-2">Bachelor's Degree</p>
        <small class="text-gray-400">2014 - 2015 (60 credits)</small>
      </div>
      <div class="w-full md:w-1/3 flex flex-col p-6 border-[#FFFFFF]/[0.16] border-t-[1px] md:border-t-0">
        <img src="<%= asset_path('logo-easy-comp.png') %>" alt="EasyComp Logo" class="w-[80px]" />
        <h3 class="text-2xl font-bold w-full mt-4">Web Design</h3>
        <p class="text-base mt-2">Certificate Program</p>
        <small class="text-gray-400">2011 - 2012</small>
      </div>
    </div>
  </section>
Every component became a partial. TBH, partials aren’t as good as components, but they got the job done.

First challenges

My site is tiny but has some animations. All of them were made using Frame Motion, which is an amazing library. I thought to myself, “Why not do it vanilla using simple stimulus controllers?” I did the first one, a simple hand-waving animation. This is how it was in React.
'use client';

import { motion } from "framer-motion";

const HandWave = () => {
    return (
        <motion.span
            transition={{
                duration: 5,
                delay: 1,
                ease: "easeInOut",
            }}
            animate={{
                rotate: [0, 35, -25, 25, -35, 0],
            }}
            initial={{
                rotate: 0,
            }}
            className="inline-block"
        >
            👋
        </motion.span>
    );
};

export default HandWave;
A single component, quite simple. In Rails, I had to separate the animation into 3 parts. So the HTML became a single line.
<span data-controller="hand-wave" class="hand-wave inline-block">👋</span>
And the animation is done in CSS
@keyframes wave {
  0% {
    transform: rotate(0deg);
  }
  20% {
    transform: rotate(35deg);
  }
  40% {
    transform: rotate(-25deg);
  }
  60% {
    transform: rotate(25deg);
  }
  80% {
    transform: rotate(-35deg);
  }
  100% {
    transform: rotate(0deg);
  }
}

 .wave-animation {
  animation: wave 5s ease-in-out 1;
}
A Stimulus controller triggers it by adding the animation class (yeah, I didn’t need this, I could have just added the class to the HTML element, then the animation would trigger after loading the page…)
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  connect() {
    setTimeout(() => {
      this.element.classList.add("wave-animation");
    }, 1000); // 1s delay
  }
}
The ending result was the same.

This one was simple, huh? But the problems started when the animation complexity started to grow. Eventually, it became an enormous task for me to deal with my work page. Then, I decided to check frame motion code to know how to implement springs and the like. For my surprise, they made a separate library called motion.dev that is purely vanilla. Then, it was easy peasy to keep switching. Thanks, team behind motion!

In my work page, the React animation looked like this
"use client";

import { useMemo, useRef, useState } from "react";
import {
    motion,
    useScroll,
    useTransform,
    useSpring,
} from "framer-motion";
import { ProductCard } from "./ProductCard";
import ProductModal from "./ProductModal";

export interface Product {
    title: string;
    name: string;
    thumbnail: string;
    slides: string[];
    description: string;
};
interface HeroParallaxProps {
    products: Product[];
};
const HeroParallax: React.FC<HeroParallaxProps> = ({
    products,
}) => {
    const [currentProduct, setCurrentProduct] = useState<Product | null>(null);

    const firstRow = useMemo(() => products.slice(0, 5), [products]);
    const secondRow = useMemo(() => products.slice(5, 10), [products]);
    const thirdRow = useMemo(() => products.slice(10, 15), [products]);

    const ref = useRef(null);

    const { scrollYProgress } = useScroll({
        target: ref,
        offset: ["start start", "end start"],
    });

    const springConfig = { stiffness: 300, damping: 30, bounce: 100 };

    const translateX = useSpring(
        useTransform(scrollYProgress, [0, 1], [0, 1000]),
        springConfig
    );
    const translateXReverse = useSpring(
        useTransform(scrollYProgress, [0, 1], [0, -1000]),
        springConfig
    );
    const rotateX = useSpring(
        useTransform(scrollYProgress, [0, 0.2], [15, 0]),
        springConfig
    );
    const opacity = useSpring(
        useTransform(scrollYProgress, [0, 0.2], [0.2, 1]),
        springConfig
    );
    const rotateZ = useSpring(
        useTransform(scrollYProgress, [0, 0.2], [20, 0]),
        springConfig
    );
    const translateY = useSpring(
        useTransform(scrollYProgress, [0, 0.2], [-700, 500]),
        springConfig
    );

    return (
        <>
            <div
                ref={ref}
                className="h-[300vh] py-40 relative flex flex-col self-auto [perspective:1000px] [transform-style:preserve-3d]"
            >
                <div className="max-w-screen-lg relative mx-auto py-20 md:py-40 px-4 w-full left-0 top-0">
                    <h1 className="text-2xl md:text-7xl font-bold text-neutral-200">
                        My Work
                    </h1>
                    <p className="max-w-2xl text-base md:text-xl mt-8 text-neutral-300">
                        I bring websites and apps to life, from idea to launch. See what I&apos;ve built.
                    </p>
                </div>
                <motion.div
                    style={{
                        rotateX,
                        rotateZ,
                        translateY,
                        opacity,
                    }}
                >
                    <motion.div className="flex flex-row-reverse space-x-reverse space-x-20 mb-20">
                        {firstRow.map((product) => (
                            <ProductCard
                                product={product}
                                translate={translateX}
                                key={product.title}
                                onClick={() => setCurrentProduct(product)}
                            />
                        ))}
                    </motion.div>
                    <motion.div className="flex flex-row mb-20 space-x-20 ">
                        {secondRow.map((product) => (
                            <ProductCard
                                product={product}
                                translate={translateXReverse}
                                key={product.title}
                                onClick={() => setCurrentProduct(product)}
                            />
                        ))}
                    </motion.div>
                    <motion.div className="flex flex-row-reverse space-x-reverse space-x-20">
                        {thirdRow.map((product) => (
                            <ProductCard
                                product={product}
                                translate={translateX}
                                key={product.title}
                                onClick={() => setCurrentProduct(product)}
                            />
                        ))}
                    </motion.div>
                </motion.div>
            </div>
            <ProductModal
                product={currentProduct}
                onRequestClose={() => setCurrentProduct(null)}
            />
        </>
    );
};

export default HeroParallax;

"use client";

import React from "react";
import {
    motion,
    MotionValue,
} from "framer-motion";
import Image from "next/image";

interface ProductCardProps {
    product: {
        title: string;
        name: string;
        thumbnail: string;
    };
    translate: MotionValue<number>;
    onClick?: () => void;
};
export const ProductCard: React.FC<ProductCardProps> = ({
    product,
    translate,
    onClick,
}) => {
    return (
        <motion.div
            style={{
                x: translate,
            }}
            whileHover={{
                y: -20,
            }}
            key={product.title}
            className="group/produc h-80 w-[35rem] relative flex-shrink-0"
            onClick={onClick}
        >
            <div className="block group-hover/product:shadow-2xl hover:cursor-pointer">
                <Image
                    src={product.thumbnail}
                    height="1280"
                    width="832"
                    className="object-cover object-left-top absolute h-full w-full inset-0 rounded-md"
                    alt={product.title}
                />
            </div>
            <div className="absolute inset-0 h-full w-full opacity-0 group-hover/product:opacity-80 bg-black pointer-events-none"></div>
            <h2 className="absolute bottom-4 left-4 opacity-0 group-hover/product:opacity-100 text-white">
                {product.title}
            </h2>
        </motion.div>
    );
};
The Rails implementation follows the same idea.
<div class="antialiased overflow-hidden relative w-full" data-controller="hero-parallax">
  <div class="flex flex-col items-center justify-between pt-4 sm:pt-24 pb-4 sm:pb-12">
    <%= render 'shared/header-menu' %>
  </div>
  <div class="pb-10">
    <div data-hero-parallax-target="scrollContainer" class="h-[300vh] py-40 relative flex flex-col self-auto perspective-[1000px] transform-preserve-3d">
      <div class="max-w-screen-lg relative mx-auto py-20 md:py-40 px-4 w-full left-0 top-0">
        <h1 class="text-2xl md:text-7xl font-bold text-neutral-200">
          My Work
        </h1>
        <p class="max-w-2xl text-base md:text-xl mt-8 text-neutral-300">
          I bring websites and apps to life, from idea to launch. See what I&apos;ve built.
        </p>
      </div>
      <div data-hero-parallax-target="productGrid" class="transition-all duration-300 ease-out will-change-transform" style="transform: perspective(1000px) rotateX(15deg) rotateZ(20deg) translateY(-700px); opacity: 0.2;">
        <% @products.each_slice((@products.size / 3.0).ceil).with_index do |slice, index| %>
          <div class="<%= index.odd? ? 'flex flex-row' : 'flex flex-row-reverse space-x-reverse' %> mb-20 space-x-20">
            <% slice.each do |product| %>
              <%= render 'product_card', product: product, reverse: index.odd? %>
            <% end %>
          </div>
        <% end %>
      </div>
    </div>
  </div>
  <div class="fixed top-0 left-0 w-full h-full bg-black bg-opacity-80 flex items-center justify-center" style="display:none;" data-hero-parallax-target="productModal" data-action="click->hero-parallax#closeModal">
    <turbo-frame id="product-modal-content" data-action="turbo:frame-load->hero-parallax#openModal"></turbo-frame>
  </div>
  <%= render 'shared/footer' %>
</div>

<%= link_to "/work/#{product[:name]}/modal",
  class: "group/produc h-80 w-[35rem] relative flex-shrink-0 transform transition-all",
  data: {
    "hero-parallax-target": "translateX#{reverse ? 'reverse' : ''}",
    "action": "mouseenter->hero-parallax#onMouseEnter mouseleave->hero-parallax#onMouseLeave",
    "turbo-frame": "product-modal-content"
  } do %>
  <div class="block group-hover/product:shadow-2xl hover:cursor-pointer">
    <img src="<%= asset_path(product[:thumbnail]) %>" height="1280" width="832" class="object-cover object-left-top absolute h-full w-full inset-0 rounded-md" alt="Product image" />
  </div>
  <div class="absolute inset-0 h-full w-full opacity-0 group-hover/product:opacity-80 bg-black pointer-events-none"></div>
  <h2 class="absolute bottom-4 left-4 opacity-0 group-hover/product:opacity-100 text-white">
    <%= product[:title] %>
  </h2>
<% end %>
And all the animations were done in the Stimulus controller using motion.dev, including the modal when you click on the item.
import { Controller } from "@hotwired/stimulus";
import {
  animate,
  scroll,
  transform,
} from "https://cdn.jsdelivr.net/npm/[email protected]/+esm";

export default class extends Controller {
  static targets = [
    "scrollContainer",
    "productGrid",
    "translateX",
    "translateXreverse",
    "productModal",
  ];

  animConfig = {
    type: "spring",
    stiffness: 300,
    damping: 30,
    bounce: 100,
    delay: 0,
    duration: 0,
  };

  connect() {
    this.setupScroll();
  }

  disconnect() {
    this.cancelScroll?.();
  }

  setupScroll() {
    this.cancelScroll = scroll(
      (progress) => this.animateScrollProgress(progress),
      {
        axis: "y",
        target: this.scrollContainerTarget,
        offset: ["start start", "end start"],
      },
    );
  }

  animateScrollProgress(progress) {
    const animations = [
      { target: this.productGridTarget, props: this.getGridProps(progress) },
      {
        target: this.translateXTargets,
        props: { x: transform([0, 1], [0, 1000])(progress) },
      },
      {
        target: this.translateXreverseTargets,
        props: { x: transform([0, 1], [0, -1000])(progress) },
      },
    ];

    animations.forEach(({ target, props }) =>
      animate(target, props, this.animConfig),
    );
  }

  getGridProps(progress) {
    return {
      transformPerspective: 1000,
      y: transform([0, 0.2], [-700, 500])(progress),
      rotateX: transform([0, 0.2], [15, 0])(progress),
      rotateZ: transform([0, 0.2], [20, 0])(progress),
      opacity: transform([0, 0.2], [0.2, 1])(progress),
    };
  }

  onMouseEnter(event) {
    animate(event.target, { y: -20 }, this.animConfig);
  }

  onMouseLeave(event) {
    animate(event.target, { y: 0 }, this.animConfig);
  }

  openModal() {
    const modal = document.querySelector(".productModalContent");
    const modalContainer = this.productModalTarget;

    modalContainer.style.opacity = 0;
    modalContainer.style.display = "flex";

    animate(modalContainer, { opacity: 1 });

    animate(modal, { y: "-100vh", opacity: 0 }, { duration: 0, delay: 0 }).then(
      () => {
        document.body.style.overflow = "hidden";
        animate(
          modal,
          { y: 0, opacity: 1 },
          { type: "spring", damping: 10, stiffness: 100 },
        );
      },
    );
  }

  closeModal() {
    const modalContainer = this.productModalTarget;
    const modal = document.querySelector(".productModalContent");

    document.body.style.overflow = "auto";

    const backDropAnim = animate(modalContainer, { opacity: 0 });
    const modalAnim = animate(
      modal,
      { y: "-100vh", opacity: 0 },
      { type: "spring", damping: 10, stiffness: 100 },
    );

    Promise.all([backDropAnim, modalAnim]).then(() => {
      modalContainer.style.display = "none";
    });
  }

  modalClick(event) {
    event.stopPropagation();
  }
}

To load the modal content, I used Turbo Frames. When Turbo loads the modal content through a click, it triggers the opening animation.

Adding a blog

Since Rails makes it super easier to build stuff, I decided to add a blog. It was something I wanted to do before, but I didn’t have any good alternative while using Next.js and Vercel. I thought about using Neon and NextAuth, and honestly, it was a good option. But using SQLite was far simpler, and I didn’t have this option with Vercel. I just had to use a volume for my machine on fly.io to store the database.

So, I started by using the auth generator from Rails
rails generate authentication
I tweaked the files a little bit to meet my requirements and adjusted the layout. Then, I installed Action Text, generated the post model, the controller, and the views. Everything was created using the Rails CLI. I just had to adjust the layout and some logic here and there, and it was done.

I got a little bit lazy and then used Pagination to paginate my posts. I use the same blog post as my listing for editing. I just added an Edit button when I am logged in that redirects to the post edition. It took me 3 hours to add the blog with authentication and everything else. 

The Trix editor for content writing that I am using right now while writing this is awesome. It lacks support for a bunch of stuff like Tables, but there is beauty in the simplicity, and it has enough to meet my requirements.

In development, I am using local storage for my files, but in Production, I am using Tigris from fly.io since it was convenient.

Deploying

Vercel is amazing. I love the serverless idea and paying only for what I use, so I can’t complain about anything, but it only works with supported JS frameworks.

Rails 8 is pushing Kamal as the deployment tool, so using a VPS would be dead simple. But well, I paid $0 for my site, and I wanted to keep the bill as low as possible. After a bit of searching, I found fly.io that stops your machine if it is no longer being used. It couldn’t be easier to use flyctl. I just had to run 2 commands:
flyctl launch
This sets up all configuration needed for your app, then
flyctl deploy
That was it! Amazing, the site was deployed and it is blazing fast! It feels even faster than the Next.js version (probably bias). Then, a few minutes passed, I decided to check my site again and boom: 502 bad gateway. I reloaded the page and it was working. What was going on? 

I use Cloudflare for my DNS management, so I thought that it was the problem, but turns out it wasn’t. My fly machine was configured to scale down to 0 and it took 3s to start up and this should be a problem, except it wasn’t. Rails 8 comes with Thruster a proxy that deals with problems bare Puma server has such as
  • HTTP/2 support
  • Automatic TLS certificate management with Let’s Encrypt
  • X-Sendfile support and compression, to efficiently serve static files
  • Basic HTTP caching of public assets
The problem is, Thruster forwards the request to Puma even if it isn’t ready to serve requests… this isn’t a problem in an always on machine but one that needs to scale to 0 then start again it was indeed a problem since I receive so few visits, users would probably always face a 502. The solution was simple but I didn’t realize at the time, so I asked in the fly.io forum and in the same day someone figured out what the problem was, I fixed it by removing Thruster, Kamal and changing the port. Voilà, working as I wanted.

The 3s cold start bothers me a little bit though.

What now?

It’s hard to find a job that pays as well as my current one, where I use C# for backend development and React for the frontend. So, I’ve decided to use Rails for side projects until I either create something profitable or a great opportunity appears. It’s a bit sad, though, I wish more companies recognized how good Ruby on Rails really is.

You might also like

Made with by David Martins