Remix is a full stack JavaScript framework for building modern web apps. It is classified as a meta-framework alongside Next.js, Nuxt, SvelteKit, etc. which means they rely on a single-page application (SPA) framework for rendering HTML markup on the server and for rehydrating the app on the client. At the moment Remix only supports React officially, but with adapters being developed, we should be able to use Remix with other SPA frameworks such as Vue or Svelte in a near future. This article discusses what makes Remix different from other React meta-frameworks, the benefits to using Remix, and the drawbacks of doing so.
Remix is markedly different from other React meta-frameworks such as Next.js and Gatsby. This section is not going to elaborate on all the detailed dissimilarities such as route definition, data fetching, error handling and so on. Instead, we are going to cover three major characteristics that set Remix apart:
SSR only
In a Remix application, all pages are rendered dynamically on request (server side rendering or SSR). Remix doesn’t supports static site generation (SSG), which means generating pages at build time, nor does it support incremental static regeneration (ISR), which is similar to SSG but deferred until the page is first requested.
Data fetching happens only on the server by running a
loader()
function and the result is made available to the route’s component through the useLoaderData
hook:export const loader: LoaderFunction = async () => {
const data: LoaderData = {
users: await db.user.findMany(),
};
return json(data);
};
export default function Users() {
const data = useLoaderData<LoaderData>();
return (
<ul>
{data.users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
Being able to serve dynamic contents is a good thing but does it make Remix apps slower than Gatsby or Next.js SSG apps? Usually not. If you deploy your Remix app at the edge (on a platform such as Cloudflare Workers or Deno Deploy) and also cache data there, you can achieve similar speed as serving static assets from a CDN. In case of cache miss, however, the request could take longer than a statically generated page (especially if you need to fetch a lot of data from a backend server far away from the edge).
Nested routes
Another great idea of Remix is nested routes, which allow the framework to fetch data for multiple routes in parallel. For example, let’s say our application has a page with the URL
/jokes/:id
to display a joke as follows:This page needs to fetch three pieces of data: the currently logged in user (for the top bar), a list of jokes (for the right-hand side menu), and the selected joke’s content. We can define three routes that nest one another inside an
Outlet
component like so:// root.tsx
export const loader: LoaderFunction = async ({ request }) => {
const data: LoaderData = {
user: await getUser(request),
};
return json(data);
};
export default function App() {
const data = useLoaderData<LoaderData>();
return (
{/* ...more stuff... */}
<div className="jokes-layout">
<header className="jokes-header">
<span>{`Hi ${data.user.username}`}</span>
</header>
<main className="jokes-main">
<Outlet />
</main>
</div>
{/* ...more stuff... */}
);
// routes/jokes.tsx
export const loader: LoaderFunction = async () => {
const data: LoaderData = {
jokeListItems: await db.joke.findMany(),
};
return json(data);
};
export default function JokesRoute() {
return (
<div className="container">
<div className="jokes-list">
<ul>
{data.jokeListItems.map((joke) => (
<li key={joke.id}>
<Link to={joke.id}>{joke.name}</Link>
</li>
))}
</ul>
</div>
<div className="jokes-outlet">
<Outlet />
</div>
</div>
);
}
// routes/jokes/$id.tsx
export const loader: LoaderFunction = async ({ params }) => {
const data: LoaderData = {
joke: await db.joke.findUnique({
where: { id: params.jokeId },
})
};
return json(data);
};
export default function JokeRoute() {
const data = useLoaderData<LoaderData>();
return (
<div>
<p>{data.joke.content}</p>
<Link to=".">{data.joke.name} Permalink</Link>
</div>
);
}
In this example, Remix can run all the three loaders at the same time to fetch data in parallel. Doing so greatly mitigates the waterfall problem where you can only start fetching data for an inner component once the outer component has finished fetching data and rendering the UI. Nested routes is a powerful idea and has been adopted by other frameworks (such as Next.js with their recent Layout RFC).
No client-side state
This is Remix's most radical difference in my opinion. Unlike normal single-page apps, a Remix app doesn’t usually have client-side state. Every time you navigate to a page, Remix will request for data from server - somewhat similar to the old days when we developed web apps with Java Servlets, ASP.NET, or PHP. With Remix, however, application state now lives at the edge - very close to end users - so such requests are very fast.
So how would we mutate data? Again, just like in the old days, we submit forms. More specifically, you would use Remix’s
Form
component to render the UI, and write an action()
function to handle submitted data:export const action: ActionFunction = async ({ request }) => {
const form = await request.formData();
const name = form.get("name");
const content = form.get("content");
const joke = await db.joke.create({ data: { name, content} });
return redirect(`/jokes/${joke.id}`);
};
export default function NewJokeRoute() {
return (
<Form method="post">
<div>
<label>
Name: <input type="text" name="name" />
</label>
</div>
<div>
<label>
Content: <textarea name="content" />
</label>
</div>
<div>
<button type="submit" className="button">
Add
</button>
</div>
</Form>
);
}
Actions have exactly the same API as loaders and, like loaders, they also run only on the server. Note that, should JavaScript become unavailable, mutations still work but form submission results in a full page reload (as opposed to a fetch request when JavaScript is used).
Now that we've seen Remix’s major differences, let’s discuss the main benefits to using this meta-framework.
Dynamic contents:
With Remix, you no longer have to make the tradeoff between performance and dynamic contents. By taking advantage of edge computing, your apps can be dynamic and fast at the same time.
Faster data fetching:
Thanks to nested routes, Remix can fetch data in parallel which alleviates waterfall problems and greatly improves performance.
Simpler code:
There is no decision to make between SSG, SSR, or ISR. Only one single way to fetch data (that is by calling a loader function). More importantly, by getting rid of client-side state management altogether (which is normally a large part of any non-trivial app), Remix significantly reduces the complexity of your apps.
More resilient apps:
With Remix, links and mutations still work without JavaScript. That’s great because sometimes users may have a spotty connection and JavaScript may fail to load. Furthermore, with Remix’s built-in support for mutations, error handling is simpler and better with error boundaries and catch boundaries. Race conditions are handled automatically by the framework, for example when a user clicks on a button multiple times in quick succession.
Smaller bundle size:
As mutations happen only on the server, you can reduce a large amount of code that needs to be downloaded and parsed by the browser.
With the numerous benefits mentioned above, Remix is clearly an awesome framework. But, of course, it’s not perfect. Below are a few potential drawbacks that I could think of.
Responsiveness:
Remix apps are fast when deployed to the edge and with data cached. In case of cache miss, however, it may take a while to fetch data and render the UI, that means users might experience some level of unresponsiveness. You can mitigate this issue by enabling prefetching, meaning that Remix will instruct the browser to eagerly fetch a link when the mouse is over it.
Nested routes’ nuisances:
While nested routes are great for data fetching, there are times they are not convenient to use. For example, you may want to have a breadcrumb that requires data from multiple descendant routes. To implement it, each route needs to expose a
handle
which then becomes available through the useMatches
hook at the top level. Another example is when you have a protected page. In this case, you need to perform user authentication in every loader, not only the top-level one.Problems with not having client-side state:
The most notable drawbacks of Remix arise from the fact that you no longer have client-side state to tap into.
First, real-time applications (web whiteboard, chat and so on). Remix can gracefully handle mutations initiated inside the browser, but in a realtime application, changes also come from outside. In a normal single-page app, you can simply update the application state and the changes will automatically reflect in the UI. But in a Remix app you don’t have client-side state, so what would you do?
Second, sharing data across routes. In a single-page app with client-side state, it's very straightforward for UI components to share data. But in a Remix app, if it takes a while for the server to process a mutation and you you want to implement optimistic UI involving two or more routes, how would you share data? Remix does provide a
hook for this purpose but using it is rather cumbersome and apparently not declarative.useFetchers
Third, number of requests to database/cache. Because there is no client-side state, almost every time you go to a link or perform a mutation, Remix needs to fetch data again for all the visible routes (except when you visit a child route). This results in a much higher number of requests to the server and a larger number of reads to your database and/or cache which could probably increase your project’s cost. Remix has a
API to help avoid unnecessary reloads but it complicates your code and won’t totally eradicate this problem.shouldReload
Finally, automated tests are harder because without client-side state you must write more end-to-end tests which are slower and more difficult to set up and tear down.
In summary, Remix is markedly different from other React meta-frameworks in that pages are always rendered dynamically, it uses nested routes for fetching data in parallel, and that it moves application state to the edge. With these characteristics, Remix makes it simpler to build web apps that are dynamic, fast, small, and resilient. However, Remix may not be best suited for building real-time applications or for applications in which you often need to share data across routes. In any case, Remix is a truly great framework and I would highly recommend that you give Remix a try if you haven’t already done so.