Home >Web Front-end >JS Tutorial >From Zero to Launch: Key Takeaways from Our Remix-Powered Development Journey
Around six months ago, I made what some would say is a bold decision by choosing Remix as the foundation for our company's web application. Fast forward to today, and I think it's time to take a step back and reflect on the choices we made. I'll go over the main infrastructure decisions made and sprinkle a bit of practical usage examples on the way.
So, without further ado, let’s jump straight into the highlights and lowlights of this journey — a mix of satisfaction and lessons learned.
This is probably the "riskiest" infrastructure decision I made at that time, as Remix was not remotely as popular as NextJS and there weren't many examples of big enterprises using Remix to my knowledge.
Fast forward to today - ChatGPT migrated from Next to Remix just a few days ago!
As I detail in my previous article, I chose Remix for many reasons, some being its simplicity, the "full-stack" aspect (namely, utilizing the remix server as a "backend for frontend") and its great abstractions for routing, data fetching and mutations.
Fortunately, Remix delivered ? The framework is intuitive, easy to learn and teach others and ensures best practices are being used, making both writing code and testing it straightforward.
A few months into working with Remix, they announced the official merge with React Router, which I hope will persuade even more people to use it, just like their move to vite did.
It became clear to me in many occasions that Remix was the right call. I'll give one practical example I tackled lately - using a single logger instance in the remix server to be able to log and trace actions and errors across the entire app to enhance our monitoring abilities. The implementation was very straight-forward:
Step 1 - create your logger (in my case I used winston, which works great with Datadog that we use for monitoring)
Step 2 - add your logger to the server's load context (in my case it was express):
app.all( '*', createRequestHandler({ getLoadContext: () => ({ logger, // add any other context variables here }), mode: MODE, // ... }), );
Step 3 (for typescript users) - update Remix's default type definitions to include the logger in the app load context
import '@remix-run/node'; import { type Logger } from 'winston'; declare module '@remix-run/node' { interface AppLoadContext { logger: Logger; } }
Step 4 - use the logger as you wish in any route's loader or action!
export async function action({ request, context }: ActionFunctionArgs) { try { await someAction(); } catch (e) { context.logger.error(e); } }
Before we conclude this section, I do wish to say that there are also things I wish Remix had but they don't yet, like an implementation of RSC for streaming data/components, and Route Middlewares which would be great for authentication/authorization. Fortunately, it looks like these things (and other cool features) are prioritized in their roadmap, so hopefully we could get them soon!
Choosing @tanstack/react-query was an easy decision for me, based on my past positive experiences, and it didn’t disappoint this time either. The API is versatile, extendable, and unopinionated in the best way — making it easy to integrate with other tools.
I like it so much that I chose it knowing our internal API is GraphQL-based, instead of the more obvious choice that is Apollo Client. There are many reasons as to why: Tanstack Query has an excellent API, it’s significantly more lightweight than Apollo, and because I didn’t want to depend on a tool that’s heavily tailored to a specific technology like GraphQL, in case we ever need to switch or incorporate other technologies.
Plus, since we're using Remix, I could fully utilize Tanstack Query’s SSR capabilities — prefetching queries on the server-side while still maintaining the ability to mutate, invalidate, or refetch these queries on the client side. Here's a simplified example:
import { dehydrate, QueryClient, HydrationBoundary, useQuery } from '@tanstack/react-query'; import { json, useLoaderData } from '@remix-run/react'; const someDataQuery = { queryKey: ['some-data'], queryFn: () => fetchSomeData() } export async function loader() { const queryClient = new QueryClient(); try { await queryClient.fetchQuery(someDataQuery); return json({ dehydrate: dehydrate(queryClient) }); } catch (e) { // decide whether to handle the error or continue to // render the page and retry the query in the client } } export default function MyRouteComponent() { const { dehydratedState } = useLoaderData<typeof loader>(); const { data } = useQuery(someDataQuery); return ( <HydrationBoundary state={dehydratedState}> <SomeComponent data={data} /> </HydrationBoundary /> ); }
I was initially skeptical about Tailwind, having never used it before, and because I didn’t quite understand the hype (it seemed to me at first just like syntactic sugar over CSS). However, I decided to give it a try because of its strong recommendations and popularity within the community, and I’m really glad I did. Tailwind’s utility-first approach made it incredibly easy to build a consistent and robust design system right from the start, which, looking back, was a total game changer.
It also pairs perfectly with shadcn, which we used, and together they allowed me to deliver quickly while keeping everything modular and easy to modify later on - a crucial advantage in a startup environment.
I also really like how easy it is to customize tailwind's theme to your needs - for example, overriding tailwind's default scheme:
First, define your colors as variable's under tailwind's main .css file:
@tailwind base; @tailwind components; @tailwind utilities; @layer base { :root { /* define the primitive design system tokens */ --colors-blue-100: hsl(188 76% 90%); --colors-blue-200: hsl(187 63% 82%); --colors-blue-25: hsl(185 100% 98%); --colors-blue-300: hsl(190 52% 74%); --colors-blue-400: hsl(190 52% 61%); --colors-blue-50: hsl(188 92% 95%); --colors-blue-500: hsl(190 74% 39%); --colors-blue-600: hsl(191 77% 34%); --colors-blue-700: hsl(190 51% 35%); --colors-blue-800: hsl(191 52% 29%); --colors-blue-900: hsl(190 51% 23%); --colors-blue-950: hsl(190 52% 17%); --colors-gray-100: hsl(0 0 90%); --colors-gray-200: hsl(0 0 85%); --colors-gray-25: hsl(0 0 98%); --colors-gray-300: hsl(0 0 73%); --colors-gray-400: hsl(0 1% 62%); --colors-gray-50: hsl(0 0 94%); --colors-gray-500: hsl(0 0% 53%); --colors-gray-600: hsl(0 0 44%); --colors-gray-700: hsl(0 0 36%); --colors-gray-800: hsl(0 2% 28%); --colors-gray-900: hsl(0 0 20%); --colors-gray-950: hsl(0 0 5%); --colors-red-100: hsl(4 93% 94%); --colors-red-200: hsl(3 96% 89%); --colors-red-25: hsl(12 100% 99%); --colors-red-300: hsl(4 96% 80%); --colors-red-400: hsl(4 92% 69%); --colors-red-50: hsl(5 86% 97%); --colors-red-500: hsl(4 88% 61%); --colors-red-600: hsl(4 74% 49%); --colors-red-700: hsl(4 76% 40%); --colors-red-800: hsl(4 72% 33%); --colors-red-900: hsl(8 65% 29%); --colors-red-950: hsl(8 75% 19%); /* ... */ /* define the semantic design system tokens */ --primary-light: var(--colors-blue-200); --primary: var(--colors-blue-600); --primary-dark: var(--colors-blue-800); --primary-hover: var(--colors-blue-50); --text-default-primary: var(--colors-gray-700); --text-default-secondary: var(--colors-gray-800); --text-default-tertiary: var(--colors-gray-900); --text-default-disabled: var(--colors-gray-300); --text-default-read-only: var(--colors-gray-400); --disabled: var(--colors-gray-300); --tertiary: var(--colors-gray-50); /* ... */ } }
Then, extend Tailwind's default theme via the tailwind config file:
import { type Config } from 'tailwindcss'; const ColorTokens = { BLUE: 'blue', GRAY: 'gray', RED: 'red', } as const; const generateColorScale = (colorName: string) => { const scales = [25, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]; return scales.reduce( (acc, scale) => { acc[scale] = `var(--colors-${colorName}-${scale})`; return acc; }, {} as Record<string, string>, ); }; export const customColors = Object.values(ColorTokens).reduce((acc, color) => { return { ...acc, [color]: generateColorScale(color), }; }, {}); const config = { // ... additional config theme: { extend: { colors: customColors }, }, } satisfies Config; export default config;
This is just the tip of the iceberg - you can go on to define custom spacing, text sizing and much more!
Previously using Cypress, I was inclined to choose it, but I kept hearing hype around Playwright and figured I'll research it extensively before making a decision. After comparing Playwright with Cypress, it was clear Playwright is the right choice to make - the fact it comes with parallel execution out of the box, the broader browser support, running times and debugging capabilities - all made Playwright the obvious choice.
And, while this is very subjective, I like Playwright's syntax much better. I find it similar to React Testing Library's syntax, which I like, and I tend to think the tests are a lot more readable, with the asynchronous aspect of the tests being very straight forward, unlike the syntax of Cypress that can cause tests to feel bloated by .then() statements and subsequent indentations.
I think my favorite feature of Playwright is their implementation of Test Fixtures. They provide a clean way to initialize and reuse resources like page objects, making tests more modular and maintainable. Make sure to check out the above link to learn more about it!
First off, let me clarify — @tanstack/react-table is a fantastic tool, which is why I was inclined to choose it in the first place, but it wasn’t the best fit for my particular use case. The very features that make it great, like its small bundle size and customizable API, ended up being less relevant to our needs than I originally thought. Despite having full control of the rendering of the Table, I was having some issues aligning its scrolling behavior to our desired outcome (why is it still not possible in 2024 to have a