Read this if you’re trying to figure out which JavaScript framework to choose in 2024 or 2025.
Disclaimer: I’ve been working for 2.5 years using Remix, 2 weeks using Next.js, and 1 week with SvelteKit, so I will by biased towards remix. The results of this comparison come from my latest SaaS boilerplate RockStack.
TLDR: Watch the video instead.
I will be comparing the following:
Server vs Client
Form Actions
State Management
Middleware
UI Libraries
Deployment
Syntax
Error Boundaries
Developer Experience
Learning Curve
As of October 2nd 2024, I’m using the following versions:
Remix: @remix-run/*@2.9.2
Next.js: next@15.0.0-canary.73
SvelteKit: @sveltejs/kit@2.5.28
So yeah, this is before Remix v3 (or React Router v7), and before the official release of React 19 and Next.js 15.
I judge server vs client separation by 4 metrics:
Where and how is the data loaded
How are the metatags populated for SEO purposes
Where do the actions go
Where can I put my client component
Server component? Put the “use server” directive at the top ofx the file.
Client component? Use “use client” to use useEffect, useState…
// .../marketing/page.tsx
"use server";
import Component from "./component";
export async function generateMetadata() {
return { title: "Newsletter" };
}
export const myAction = async (prev: any, form: FormData) => {
return { success: "Hello" };
};
export default async function() {
const someData = await db.someData.findMany();
return <Component data={someData}/>;
}
// .../marketing/component.tsx
"use client";
import { myAction} from "./page";
export default function ({ data }: { data: any }) {
const [actionData, action, pending] = useActionState(myAction, null);
useEffect(() => {
if (actionData?.success) {
toast.success(actionData.success);
}
}, [actionData]);
...
With Remix you can have the loader, action, and the client component in one file.
// .../newsletter.tsx
export const meta: MetaFunction<typeof loader> = ({ data }) => data?.metatags || [];
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { t } = await getTranslations(request);
const data: NewsletterPage.LoaderData = {
metatags: [{title: "Hey"}],
};
return json(data);
};
export const action: ActionFunction = async ({ request }) => {
// ...
};
export default function () {
const { t } = useTranslation();
const data = useLoaderData<typeof loader>();
return <div>...</div>;
}
SvelteKit collocates +page.server.ts and +page.svelte in the route directory. Whatever you export as the “load” function, is automatically typed on the client.
// .../newsletter/+page.server.ts
export const load = async () => {
return {
metatags: { title: "alex" }
}
};
// .../newsletter/+page.svelte
<script lang="ts">
export let data; // <- this is typed!
</script>
<svelte:head>
<title>{data.title}</title>
</svelte:head>
<div>...</div>
With Next.js, you wrap your action function in a useActionState hook:
"use client";
const [actionData, action, pending] = useActionState(actionLogin, null);
<form {action}>
<input type="email" name="email" />
<input type="password" name="password" />
<button type="submit" disabled={pending}>
{pending ? "Loading..." : "Login"}
</button>
{actionData?.error && <div class="text-red-500">{actionData.error}</div>
</form>
The great thing about Next.js actions is that you can just call the action by passing the formData:
const [actionData, action, pending] = useActionState(myAction, null);
function onCustomClick () {
const form = new FormData()
form.set("no-input-required", "hello");
action(form)
}
To progressively enhance your sveltekit actions, use use:enhace
<script lang="ts">
type SuccessData = { success: string };
type FailureData = { error: string };
type ISubmitFunction = SubmitFunction<SuccessData, FailureData>;
type IActionData = { success?: string; error?: string } & { [key: string]: unknown };
let pending = false;
let actionData: IActionData | undefined;
const onSubmit: ISubmitFunction = (e) => {
pending = true;
return ({ result, update }) => {
pending = false;
if (result.type === "success") {
actionData = result.data;
update({ reset: true });
} else if (result.type === "failure") {
actionData = result.data;
}
applyAction(result);
};
};
</script>
<form method="POST" action="/newsletter" use:enhance={onSubmit}>
<input type="email" name="email" />
<input type="password" name="password" />
<button type="submit" disabled={pending}>
{pending ? "Loading..." : "Login"}
</button>
{actionData?.error && <div class="text-red-500">{actionData.error}</div>
</form>
But if you don’t want to use <form />, use fetch, but apply the action:
function onCustomClick () {
const form = new FormData()
form.set("no-input-required", "hello");
const response = await fetch("/?/setLocale", {
method: "POST",
body: form,
});
const result = deserialize(await response.text());
if (result.type === "success") {
await invalidateAll();
}
applyAction(result);
}
// src/lib/state/useAdminData.ts
"use client";
import { createContext, useContext } from "react";
export type AdminDataDto = {
user: UserDto;
};
export const AdminDataContext = createContext<AdminDataDto | null>(null);
export default function useAdminData(): AdminDataDto {
return useContext(AdminDataContext);
}
// src/context/AdminDataLayout.tsx
"use client";
import { AdminDataContext, AdminDataDto } from "@/lib/state/useAdminData";
export default function AdminDataLayout({ children, data }: { children: React.ReactNode; data: AdminDataDto }) {
return <AdminDataContext.Provider value={data}>
{children}
</AdminDataContext.Provider>;
}
// src/app/admin/layout.tsx
export default async function () {
const adminData = await loadAdminData();
return (
<AdminDataLayout data={adminData}>
<SidebarLayout layout="admin">{props.children}</SidebarLayout>
</AdminDataLayout>
);
}
// src/app/admin/dashboard/page.tsx
export default async function ) {
const adminData = useAdminData()
return <div>Hi {adminData.user.firstName}!</div>;
}
Wrap writable object within a context to have a reactive context:
// src/lib/state/useAdminData.ts
import { getContext } from "svelte";
import type { Writable } from "svelte/store";
export type AdminDataDto = {
user: UserDto;
};
export function useAdminData(): Writable<AdminDataDto> {
return getContext<Writable<AdminDataDto>>("adminData");
}
// src/lib/context/AdminDataContext.svelte
<script lang="ts">
import type { AdminDataDto } from "$lib/state/useAdminData";
import { setContext } from "svelte";
import { writable } from "svelte/store";
export let data: AdminDataDto;
const adminDataStore = writable(data);
setContext("adminData", adminDataStore);
$: adminDataStore.set(data);
</script>
<slot />
// src/routes/admin/+layout.svelte
<script lang="ts">
export let data;
</script>
<AdminDataContext {data}>
<SidebarLayout layout="admin">
<slot />
</SidebarLayout>
</AdminDataContext>
// src/routes/admin/dashboard/+page.svelte
<script lang="ts">
import { useAdminData } from "$lib/state/useAdminData";
const adminData = useAdminData();
</script>
<div>Hello {$adminData.user.firstName}!</div>
In Remix you don’t need a custom context provider, just reference a parent’s data with useMatches.
// app/lib/state/useAdminData.ts
import { useMatches } from "@remix-run/react";
export type AdminDataDto = {
user: UserDto;
};
export function useAdminData(){
const paths: string[] = ["routes/admin"];
return (useMatches().find((f) =>
paths.includes(f.id.toLowerCase())
)?.data ?? {}) as AdminDataDto;
}
// app/routes/admin.tsx
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
return json({ user: await loadUser() })
}
export default function () {
return (
<SidebarLayout layout="admin">
<Outlet />
</SidebarLayout>
)
}
// src/app/admin/dashboard/page.tsx
export default async function ) {
const adminData = useAdminData()
return <div>Hi {adminData.user.firstName}!</div>;
}
In Next.js actions, you don’t have access to the current URL, unless you send the current pathname into the formData. So one use case I found is to set a custom header:
export function middleware(request: Request) {
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-url", request.url);
return NextResponse.next({ request: { headers: requestHeaders } });
}
Remix does not offer an out-of-the-box middleware, this is because all routes run in parallel (they’re working on it). But for my specific use case I’ve found that I don’t really need a middleware, I just repeat code on every loader and every action:
// app/routes/admin/accounts/index.tsx
export const loader = async ({ request }: LoaderFunctionArgs) => {
await verifyUserHasPermission(request, "admin.accounts.view");
// ...
}
export const action = async ({ request }: ActionFunctionArgs) => {
await verifyUserHasPermission(request, "admin.accounts.view");
// ...
}
As you can imagine, I use my helper functions to redirect or throw errors.
Using the src/hooks.server.ts file, I do something similar to what I
// src/hooks.server.ts
export const handle: Handle = async ({ event, resolve }) => {
try {
const userInfo = getUserInfo(event.request);
// do something
return response;
} catch (e: any) {
console.error("[Error] hooks.server.ts", e.message);
throw e;
}
};
But SvelteKit loaders and actions do have the request object, so I don’t need a custom middleware for my custom authorization logic.
Tailwind CSS is basically everywhere, so I can maintain the exact same UI for the 3 apps. But for complex components, such as a library for Buttons, Modals, Transitions, and more, both React and Svelte have great UI libraries:
For @headlessui/react use @rgossiaux/svelte-headlessui
For ui.shadcn.com use shadcn-svelte.com
I’ve already deployed some demos on the 3 frameworks:
So I’ve got no complaints on any of them, all of them work out-of-the-box in serverless environments and all of them support a Dockerfile to use in the VPS hosting provider of choice. Although I’ve seen some complains on how Next.js is not easily deployed outside of Vercel, I haven’t had that experience but I haven’t build big apps with Next so don’t quote me!
Remix and Next.js share the same language, React:
// if
{isTruthy && <div />}
// else
{isTruthy ? <div /> : <div />}
// else if
{isTruthy ? <div /> : anotherValue > 1 ? <div /> : <div /> }
// forEach
{items.map((item, idx) => (
<div key={idx}>Item: {item.name}</div>
))}
Svelte’s syntax is similar to Vue and Angular, like an HTML templating language:
<!-- if -->
{#if isTruthy}
<div />
{/if}
<!-- else -->
{#if isTruthy}
<div />
{:else}
<div />
{/if}
<!-- else if -->
{#if isTruthy}
<div />
{:else if anotherValue > 1}
<div />
{/if}
<!-- forEach -->
{#each items as item, idx (idx)}
<div>Item: {item.name}</div>
{/each}
As far as I know, you cannot create multiple components in one Svelte file (unless you use snippets), whereas in React you can have multiple Components in one file.
React requires you to use useState
for reactivity:
// React
const [reactiveVariable, setReactiveVariable] = useState("");
function getFlatPrice(): SubscriptionPriceDto | undefined {
return prices.find((f) => f.currency === currency);
}
return (
<div>
<div> My variable: {reactiveVariable} </div>
<div> My function: {getFlatPrice()} </div>
</div>
)
Although I have to mention that my use of useEffect has drastically lowered when working with server loaders and actions.
…vs in SvelteKit you prefix with $:
<!-- SvelteKit -->
<script lang="ts">
$: reactiveVariable = "";
$: getFlatPrice = (): SubscriptionPriceDto | undefined => {
return prices.find((f) => f.currency === currency);
};
</script>
<div>
<div> My reactive variable: {reactiveVariable} </div>
<div> My reactive function: {getFlatPrice()} </div>
</div>
React has custom HTML attributes, like for CSS classes you’d use "className”: <div className='text-black' />
.
With SvelteKit you must use "class": <div class='text-orange-500' />
, but for custom components, you need to do a workaround to export className as class (or just use className for custom components ¯_(ツ)_/¯):
<!-- MyComponent.svelte -->
<script lang="ts">
let className = "";
export { className as class };
</script>
<div class={className} />
<!-- +page.svelte -->
<script lang="ts">
import MyComponent from "./MyComponent.svelte";
</script>
<MyComponent class="p-12"/>
The three frameworks have their own way of rendering server errors on the client:
Inside any route directory, you must create a reserved “error” page:
// src/app/error.tsx
export default function GlobalError({
error,
}: {
error: Error & { status?: number; message?: string; digest?: string };
}) {
return <div>{error.message}</div>
}
In any route, you can export an ErrorBoundary reserved function and access the page error with useRouteError:
// app/routes/admin.tsx
export function ErrorBoundary() {
const error= useRouteError();
return <div>{error.message}</div>
}
With SvelteKit it’s a bit easier as you have the page error in, yes, $page.error.
<script>
import { page } from "$app/stores";
import Page401 from "$lib/components/pages/Page401.svelte";
import Page404 from "$lib/components/pages/Page404.svelte";
import { t } from "$lib/i18n";
import Metatags from "$lib/modules/pageBlocks/seo/Metatags.svelte";
let errorTitle = $t("shared.error");
let errorDescription = $page.error?.message;
</script>
<svelte:head>
<Metatags metatags={{ title: $t("shared.error") }} />
</svelte:head>
{#if $page.status === 404}
<Page404 title={errorDescription || errorTitle}/>
{:else if $page.status === 401 || errorTitle === "Unauthorized"}
<Page401/>
{:else}
{errorTitle}
{/if}
I spent 30 minutes on building the simplest CRUD implementation I could think of on the 3 frameworks, go watch it here:
https://www.loom.com/share/0d5389dd1d644378981fcc5583147408?sid=ba1a638b-06ae-490a-a103-f11ea419fdf2
TLDW:
Spent 6 minutes in Remix (I have 2.5 years of experience)
Spent 10 minutes in Next.js (2 weeks of experience in next)
Spent 11 minutes in SvelteKit (1 week of experience in next)
I like the 3 of them but experience matters in my case.
If I had to put a number on the complexity, where 10 is the easiest:
Next.js@15-rc: 9 — -1 point for having to separate server and client files.
Remix@2.9: 10 — it just clicks for me.
SvelteKit@5: 8 — reason being I prefer JSX over templating syntax so I need to adapt my brain to use slots instead of dynamic components wrapped in functions like value: () => {}.
Take in consideration that this is not my first time using SvelteKit or trying out Next, so this is a big win for those 2 frameworks. I did not like the first experience with them 2 years ago, but they’ve both grown to a point that I would definetly use any of the three frameworks to build my next app.
In Next.js and SvelteKit, if you call the redirect function inside a try catch block, you need to make sure to throw it on the error clause:
try {
redirect("/login"); // you need to rethrow it in the catch block ↓
}
catch (e: any) {
if (isRedirectError(e)) {
throw e;
}
return { error: e.message }
}
Otherwise you would get a NEXT_REDIRECT error or nothing at all in the case of SvelteKit. On Remix, it’s smart enough to rethrow with no additional error type check.
I spent at least 1 full week working on each stack so you don’t have to. Not to mention all the UI, logic, and know-how, comes from a background of 5 years working on previous frameworks and boilerplates (netcoresaas, saasfrontends, and saasrock).
It's 50% off for the first 500 users!