Skip to content

EreoJS vs Remix

EreoJS and Remix share many philosophical similarities. Both emphasize web standards, progressive enhancement, and explicit data patterns. This comparison highlights the differences.

Overview

AspectEreoJSRemix
RuntimeBun (native)Node.js (adapters)
BundlerBunesbuild
HydrationIslands (selective)Full page
PhilosophyVery similarWeb standards focused
Data PatternsLoaders/ActionsLoaders/Actions
CachingTag-based, explicitManual, headers

Core Similarities

EreoJS and Remix share these core concepts:

  • Loaders for data fetching
  • Actions for mutations
  • Progressive enhancement for forms
  • Nested layouts with file-based routing
  • Web standard APIs (Request/Response)

Data Loading

Both frameworks use loaders, with nearly identical APIs:

Remix:

tsx
import { json } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'

export async function loader({ params }) {
  const post = await db.posts.find(params.id)
  return json({ post })
}

export default function Post() {
  const { post } = useLoaderData()
  return <h1>{post.title}</h1>
}

EreoJS:

tsx
import { createLoader } from '@ereo/data'
import { useLoaderData } from '@ereo/client'

export const loader = createLoader(async ({ params }) => {
  const post = await db.posts.find(params.id)
  return { post }
})

export default function Post() {
  const { post } = useLoaderData()
  return <h1>{post.title}</h1>
}

Key differences:

  • EreoJS uses createLoader wrapper for type safety
  • Remix uses json() helper (EreoJS returns objects directly)
  • APIs are otherwise nearly identical

Actions

Remix:

tsx
import { redirect } from '@remix-run/node'
import { Form } from '@remix-run/react'

export async function action({ request }) {
  const formData = await request.formData()
  await db.posts.create(Object.fromEntries(formData))
  return redirect('/posts')
}

export default function NewPost() {
  return (
    <Form method="post">
      <input name="title" />
      <button type="submit">Create</button>
    </Form>
  )
}

EreoJS:

tsx
import { createAction, redirect } from '@ereo/data'
import { Form } from '@ereo/client'

export const action = createAction(async ({ request }) => {
  const formData = await request.formData()
  await db.posts.create(Object.fromEntries(formData))
  return redirect('/posts')
})

export default function NewPost() {
  return (
    <Form method="post">
      <input name="title" />
      <button type="submit">Create</button>
    </Form>
  )
}

Nearly identical. The main difference is EreoJS's createAction wrapper.

Client-Side Interactivity

This is where EreoJS and Remix differ significantly:

Remix (full hydration):

tsx
// Every component hydrates
import { useState } from 'react'

export default function Page() {
  return (
    <div>
      <StaticContent />
      <InteractiveCounter />
      <MoreStaticContent />
    </div>
  )
}

function InteractiveCounter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(c + 1)}>{count}</button>
}

EreoJS (islands):

Mark interactive components with 'use client' — only they get hydrated:

tsx
import { Counter } from '~/components/Counter';

export default function Page() {
  return (
    <div>
      <StaticContent />
      {/* Only this ships JavaScript */}
      <Counter />
      <MoreStaticContent />
    </div>
  )
}

For deferred hydration, use data-island attributes:

tsx
<Counter data-island="Counter" data-hydrate="idle" />

Key differences:

  • Remix hydrates the entire page
  • EreoJS only hydrates 'use client' components (islands)
  • EreoJS ships less JavaScript
  • EreoJS offers hydration strategies (idle, visible, etc.) for fine-grained control

Routing

Remix:

app/routes/
├── _index.tsx           # /
├── about.tsx            # /about
├── posts._index.tsx     # /posts
├── posts.$id.tsx        # /posts/:id
└── posts.$id_.edit.tsx  # /posts/:id/edit

EreoJS:

routes/
├── index.tsx            # /
├── about.tsx            # /about
├── posts/
│   ├── index.tsx        # /posts
│   ├── [id].tsx         # /posts/:id
│   └── [id]/
│       └── edit.tsx     # /posts/:id/edit

Key differences:

  • Remix uses dot notation for nesting
  • EreoJS uses directory structure
  • Both support dynamic segments
  • EreoJS uses [param], Remix uses $param

Error Handling

Remix:

tsx
import { useRouteError, isRouteErrorResponse } from '@remix-run/react'

export function ErrorBoundary() {
  const error = useRouteError()

  if (isRouteErrorResponse(error)) {
    return <h1>{error.status}: {error.statusText}</h1>
  }

  return <h1>Error</h1>
}

EreoJS:

tsx
import { useRouteError, isRouteErrorResponse } from '@ereo/client'

export function ErrorBoundary() {
  const error = useRouteError()

  if (isRouteErrorResponse(error)) {
    return <h1>{error.status}: {error.statusText}</h1>
  }

  return <h1>Error</h1>
}

Identical APIs.

Caching

Remix:

tsx
// Manual cache headers
export async function loader() {
  return json(data, {
    headers: {
      'Cache-Control': 'public, max-age=3600'
    }
  })
}

EreoJS:

tsx
// Route-level configuration
export const config = {
  cache: {
    maxAge: 3600,
    tags: ['posts']
  }
}

// Tag-based invalidation
await revalidateTag('posts')

Key differences:

  • Remix uses manual Cache-Control headers
  • EreoJS has built-in tag-based invalidation
  • EreoJS caching is more declarative

Streaming

Remix:

tsx
import { defer } from '@remix-run/node'
import { Await } from '@remix-run/react'

export async function loader() {
  const post = await getPost()
  const comments = getComments() // Not awaited
  return defer({ post, comments })
}

export default function Post() {
  const { post, comments } = useLoaderData()
  return (
    <article>
      <h1>{post.title}</h1>
      <Suspense fallback={<Loading />}>
        <Await resolve={comments}>
          {(data) => <Comments data={data} />}
        </Await>
      </Suspense>
    </article>
  )
}

EreoJS:

tsx
import { createLoader, defer } from '@ereo/data'
import { Await } from '@ereo/client'

export const loader = createLoader(async () => {
  const post = await getPost()
  const comments = defer(getComments())
  return { post, comments }
})

export default function Post({ loaderData }) {
  return (
    <article>
      <h1>{loaderData.post.title}</h1>
      <Suspense fallback={<Loading />}>
        <Await resolve={loaderData.comments}>
          {(data) => <Comments data={data} />}
        </Await>
      </Suspense>
    </article>
  )
}

Nearly identical patterns.

useFetcher

Both have similar fetcher APIs:

Remix:

tsx
import { useFetcher } from '@remix-run/react'

function LikeButton({ postId }) {
  const fetcher = useFetcher()
  return (
    <fetcher.Form method="post" action="/api/like">
      <input type="hidden" name="postId" value={postId} />
      <button disabled={fetcher.state === 'submitting'}>
        {fetcher.state === 'submitting' ? 'Liking...' : 'Like'}
      </button>
    </fetcher.Form>
  )
}

EreoJS:

tsx
import { useFetcher } from '@ereo/client'

function LikeButton({ postId }) {
  const fetcher = useFetcher()
  return (
    <fetcher.Form method="post" action="/api/like">
      <input type="hidden" name="postId" value={postId} />
      <button disabled={fetcher.state === 'submitting'}>
        {fetcher.state === 'submitting' ? 'Liking...' : 'Like'}
      </button>
    </fetcher.Form>
  )
}

Identical.

Deployment

Remix:

  • Requires adapters for different platforms
  • Adapters: Node, Cloudflare, Deno, Vercel, etc.
  • Each adapter has different APIs

EreoJS:

  • Native Bun deployment
  • Adapters for other runtimes
  • Simpler deployment story for Bun

Build Performance

MetricEreoJSRemix
Cold Start~0.5s~2s
Hot Reload<50ms~200ms
Production Build~2s~8s

EreoJS is faster due to Bun's native bundler.

Bundle Size

MetricEreoJSRemix
Framework Runtime~15KB~45KB
Per-Route JSVaries (islands)Full component
HydrationSelectiveFull

EreoJS ships less JavaScript when using islands.

State Management

Remix:

  • Uses React state and context
  • No built-in state management
  • Community solutions (Zustand, Jotai, etc.)

EreoJS:

  • Built-in signals (@ereo/state)
  • Works across islands
  • Also supports React state
tsx
// EreoJS signals
import { signal } from '@ereo/state'

export const cartItems = signal([])

// In any island
function CartButton() {
  const items = cartItems.get()
  return <span>{items.length}</span>
}

When to Choose EreoJS

  • You want islands architecture for smaller bundles
  • You're using Bun or want faster builds
  • You want built-in tag-based caching
  • You prefer explicit hydration control
  • You want built-in reactive state

When to Choose Remix

  • You need the adapter ecosystem
  • You're deploying to Cloudflare Workers
  • You prefer full-page hydration model
  • You want the larger community
  • You need Shopify Hydrogen support

Migration Path

Migrating from Remix to EreoJS is straightforward:

  1. Convert $param to [param] in routes
  2. Wrap loaders with createLoader
  3. Wrap actions with createAction
  4. Convert interactive components to islands
  5. Update imports from @remix-run/* to @ereo/*

See the migration guide for details.

Released under the MIT License.