Back to blog
October 2, 2024

Next.js, Remix, or SvelteKit  - The Ultimate Guide: Top 10 differences

  • Name
    Alexandro Martinez
    #sveltekit
    #nextjs
    #remix
Next.js, Remix, or SvelteKit  - The Ultimate Guide: Top 10 differences

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:

  1. Server vs Client

  2. Form Actions

  3. State Management

  4. Middleware

  5. UI Libraries

  6. Deployment

  7. Syntax

  8. Error Boundaries

  9. Developer Experience

  10. 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.


1. Server vs Client

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

Next.js “use server” and “use client”

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]); ...

Remix full-stack files

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 — +page.server.ts and +page.svelte

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>

2. Form Actions

Next.js Form Actions — Form Submit:

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>

Next.js Form Actions — Custom Submit:

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)
}

SvelteKit Form Actions — Form Submit:

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>

SvelteKit Form Actions — Custom Submit:

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); }

3. State Management

Next.js —  React context

// 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>; }

SvelteKit — writable + context

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>

Remix —  useMatches

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>; }

4. Middleware

Next.js Middleware

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 } });
}

No Remix Middleware

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.

No need for SvelteKit Middleware

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.

5. UI Libraries

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:

6. Deployment

I’ve already deployed some demos on the 3 frameworks:

Fly.io

Vercel

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!

7. Syntax

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}

Components

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.

Reactivity

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>

CSS classes

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"/>

8. Error Boundaries

The three frameworks have their own way of rendering server errors on the client:

Next.js — error.tsx

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>
}

Remix — export ErrorBoundary

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>
}

SvelteKit — +error.svelte

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}

9. Developer Experience

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.

10. Learning Curve

If I had to put a number on the complexity, where 10 is the easiest:

  1. Next.js@15-rc: 9 — -1 point for having to separate server and client files.

  2. Remix@2.9: 10 —  it just clicks for me.

  3. 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.


Other things

Redirects

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.


Save literally +200 hours with RockStack

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!