Remix for Next.js Developers

Do you like this guide? If yes you may want to reserve a spot for the Remix for Next Devs Video Course where we will create a full Remix application from scratch using Remix, Tailwind, Supabase, Docker and Fly.

Table of Contents

Routes definition

Instead of using folders and slashes to define routes, you can use dots (.) to define routes, each dot define a path segment.

Next.js

pages/
├── _app.tsx
├── index.tsx
├── about.tsx
├── concerts/
│ ├── index.tsx
│ ├── trending.tsx
│ └── [city].tsx

Remix

app/
├── routes/
│ ├── _index.tsx
│ ├── about.tsx
│ ├── concerts._index.tsx
│ ├── concerts.$city.tsx
│ ├── concerts.trending.tsx
│ └── concerts.tsx
└── root.tsx

Dynamic route [city].tsx

Instead of using square brackets to define dynamic routes, you can use the dollar sign with your param name ($city) to define dynamic routes.

Next.js

pages/
├── _app.tsx
├── concerts/
│ ├── index.tsx
│ └── [city].tsx

Remix

app/
├── routes/
│ ├── concerts._index.tsx
│ ├── concerts.$city.tsx
└── root.tsx

Catch all routes [...slug].tsx

Instead of using three dots to define catch all routes, you can use the dollar sign ($) to define catch all routes.

Next.js

pages/
├── _app.tsx
├── posts/
│ ├── [...slug].tsx
│ └── index.tsx

Remix

app/
├── routes/
│ ├── posts.$.tsx
│ └── posts._index.tsx
└── root.tsx

Route groups (app directory)

Route groups exist in Next.js app directory, Remix has them too, if a route starts with a underscore it will be used as an hidden route, useful to define a layout for a set of routes.

Next.js

app/
├── (group)/
│ ├── folder/
│ │ ├── page.tsx
│ │ └── layout.tsx
│ ├── page.tsx
│ └── layout.tsx
├── other/
│ └── page.tsx
├── layout.tsx

Remix

app/
├── routes/
│ ├── _group.tsx
│ ├── _group._index.tsx
│ ├── _group.folder.tsx
│ └── other.tsx
└── root.tsx

Routes with dots (sitemap.xml)

You can escape dots in Remix with [] syntax. This is useful for characters like . and _ that have special meaning in the route syntax.

Next.js

pages/
├── _app.tsx
├── posts/
│ ├── index.tsx
│ └── about.tsx
├── sitemap.xml.tsx

Remix

app/
├── routes/
│ ├── posts._index.tsx
│ ├── posts.about.tsx
│ └── sitemap[.xml].tsx
└── root.tsx

_document.tsx

In Remix, the equivalent of _document.tsx in Next.js is root.tsx.

Next.js

// /pages/_document.tsx
import { Html, Head, Main, NextScript } from 'next/document'
export default function Document() {
return (
<Html lang='en'>
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
)
}

Remix

// app/root.tsx
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from '@remix-run/react'
export default function Root() {
return (
<html lang='en'>
<head>
<Links />
<Meta />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
</body>
</html>
)
}

Layouts (app directory)

In Remix, you can define layouts in the app directory, the equivalent of _app.tsx in Next.js is root.tsx. Each route folder can have a layout too, simply define a component for that folder and use Outlet to render the child routes.

Next.js

// app/posts/layout.tsx
export default function Layout({ children }) {
return <div>{children}</div>
}
// app/posts/[id]/page.tsx
export default function Page() {
return <div>Hello World</div>
}

Remix

import { Outlet } from '@remix-run/react'
// app/routes/posts.tsx
export default function Layout() {
return (
<div>
<Outlet />
</div>
)
}
// app/routes/posts.$id.tsx
export default function Page() {
return <div>Hello World</div>
}

getServerSideProps

Remix has loader instead of getServerSideProps, the loader function is a top-level export in a route module that is used to fetch data for the route. This function is called on every render, on client side navigation this function will be used to get the json for the next page.

Next.js

// /pages/index.tsx
export async function getServerSideProps() {
const data = await fetchData()
return { props: { data } }
}
const Page = ({ data }) => <div>{data}</div>
export default Page

Remix

// /routes/index.tsx
import { LoaderFunction, json } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
export let loader: LoaderFunction = async (request) => {
const data = await fetchData()
return json(data)
}
export default function Index() {
let data = useLoaderData<typeof loader>()
return <div>{data}</div>
}

getServerSideProps with redirect

Remix has an utility function called redirect you can return in your loaders, notice that this function simply returns a Response.

Next.js

export async function getServerSideProps() {
return {
redirect: {
destination: '/home',
permanent: false,
},
}
}

Remix

import { LoaderFunction, redirect } from '@remix-run/node'
export let loader: LoaderFunction = async () => {
return redirect('/home', { status: 307 })
}

getServerSideProps notFound

Remix supports throwing responses, similar to what Next.js app directory does, when you throw a response you can intercept it in a route ErrorBoundary to show a custom message.

Next.js

export async function getServerSideProps() {
return {
notFound: true,
}
}

Remix

import { LoaderFunction } from '@remix-run/node'
export let loader: LoaderFunction = async () => {
throw new Response('', { status: 404 })
}

API Routes

Remix has no concept of API routes, just use normal loaders like any other route and return a Response object.

Next.js

// /pages/api/hello.ts
import { NextApiRequest, NextApiResponse } from 'next'
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
res.status(200).json({ name: 'John Doe' })
}

Remix

// /routes/api/hello.ts
import { LoaderFunctionArgs, LoaderFunction } from '@remix-run/node'
export let loader = async ({ request }: LoaderFunctionArgs) => {
const res = new Response(JSON.stringify({ name: 'John Doe' }))
return res
}

useRouter().push

Remix instead of useRouter has many little hooks unfortunately. One of these is useNavigate which is used to navigate to a new route.

Next.js

import { useRouter } from 'next/router'
export default function Index() {
const router = useRouter()
return (
<button
onClick={() => {
router.push('/home')
}}
>
Home
</button>
)
}

Remix

import { useNavigate } from '@remix-run/react'
export default function Index() {
const navigate = useNavigate()
return (
<button
onClick={() => {
navigate('/home')
}}
>
Home
</button>
)
}

useRouter().replace

Remix uses navigate with a second options argument.

Next.js

import { useRouter } from 'next/router'
export default function Index() {
const router = useRouter()
return (
<button
onClick={() => {
router.replace('/home')
}}
>
Home
</button>
)
}

Remix

import { useNavigate } from '@remix-run/react'
export default function Index() {
const navigate = useNavigate()
return (
<button
onClick={() => {
navigate('/home', { replace: true })
}}
>
Home
</button>
)
}

useRouter().reload()

In Next.js you can reload with router.reload() or router.replace(router.asPath). In Remix you can use revalidate from useRevalidator.

In Remix revalidate loading state is not the same as useNavigation.state, this means if you want to create a progress bar at the top of the page you will also need to use this revalidator state too to show the loading bar during reloads or form submits.

Next.js

import { useRouter } from 'next/router'
export default function Index() {
const router = useRouter()
return (
<button
onClick={() => {
router.reload()
}}
>
Reload
</button>
)
}

Remix

import { useRevalidator } from '@remix-run/react'
export default function Index() {
const { revalidate } = useRevalidator()
return (
<button
onClick={() => {
revalidate()
}}
>
Reload
</button>
)
}

useRouter().query

To access query parameters in Remix, you can use the useSearchParams hook.

Remix will not pass params in this object, unlike Next.js.

Next.js

import { useRouter } from 'next/router'
export default function Index() {
const router = useRouter()
return (
<button
onClick={() => {
router.replace({ query: { ...router.query, name: 'John Doe' } })
}}
>
{router.query.name}
</button>
)
}

Remix

import { useSearchParams } from '@remix-run/react'
export default function Index() {
const [searchParams, setSearchParams] = useSearchParams()
return (
<button
onClick={() =>
setSearchParams((prev) => {
prev.set('name', 'John Doe')
return prev
})
}
>
{searchParams.get('name')}
</button>
)
}

useRouter().asPath

Next.js has asPath to get the current path as shown in the browser. Remix has useLocation, which returns an object similar to the window.location object.

Next.js

import { useRouter } from 'next/router'
export default function Index() {
const router = useRouter()
return <div>{router.asPath}</div>
}

Remix

import { useLocation } from '@remix-run/react'
export default function Index() {
const location = useLocation()
return <div>{location.pathname}</div>
}

useRouter().back()

Remix uses the navigate function to go back in the history stack.

Next.js

import { useRouter } from 'next/router'
export default function Index() {
const router = useRouter()
return (
<button
onClick={() => {
router.back()
}}
>
Back
</button>
)
}

Remix

import { useNavigate } from '@remix-run/react'
export default function Index() {
const navigate = useNavigate()
return (
<button
onClick={() => {
navigate(-1)
}}
>
Back
</button>
)
}

useRouter().forward()

Remix uses the navigate function to go forward in the history stack.

Next.js

import { useRouter } from 'next/router'
export default function Index() {
const router = useRouter()
return (
<button
onClick={() => {
router.forward()
}}
>
Forward
</button>
)
}

Remix

import { useNavigate } from '@remix-run/react'
export default function Index() {
const navigate = useNavigate()
return (
<button
onClick={() => {
navigate(1)
}}
>
Forward
</button>
)
}

dynamic params

Dynamic params in Remix can be accessed both in the loaders and with an hook useParams.

Next.js

import { useRouter } from 'next/router'
export function getServerSideProps({ params }) {
return { props: { params } }
}
export default function Index({ params }) {
const router = useRouter()
return (
<div>
{params.name} is same as {router.query.name}
</div>
)
}

Remix

import { LoaderFunctionArgs, json } from '@remix-run/node'
import { useParams } from '@remix-run/react'
export function loader({ params }: LoaderFunctionArgs) {
return json({ params })
}
export default function Index() {
const params = useParams()
return <div>{params.name}</div>
}

getStaticProps

Remix does not have a direct equivalent to getStaticProps, but you can use loader with a stale-while-revalidate cache control header to achieve the same behavior. You will also need a CDN on top of your host to support this feature the same way Next.js on Vercel does.

One drawback is that you can't create the pages ahead of time to have them fast on the first load.

Next.js

export function getStaticProps({ params }) {
return { props: { params } }
}
export const revalidate = 60
export default function Index({ params }) {
return <div>{params.name}</div>
}

Remix

import { LoaderFunctionArgs, json } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
export function loader({ params }: LoaderFunctionArgs) {
return json(
{ params },
{
headers: {
// you will need a CDN on top
'Cache-Control': 'public, stale-while-revalidate=60',
},
},
)
}
export default function Index() {
const data = useLoaderData<typeof loader>()
return <div>{data.params.name}</div>
}

_error.jsx

Remix can have an error boundary for each route, this error boundary will be rendered when you throw an error in a loader or during rendering

Next.js

function Error({ statusCode }) {
return (
<p>
{statusCode
? `An error ${statusCode} occurred on server`
: 'An error occurred on client'}
</p>
)
}
Error.getInitialProps = ({ res, err }) => {
const statusCode = res ? res.statusCode : err ? err.statusCode : 404
return { statusCode }
}
export default Error

Remix

import { useRouteError, Scripts, isRouteErrorResponse } from '@remix-run/react'
// root.tsx
export function ErrorBoundary() {
const error = useRouteError()
return (
<html>
<head>
<title>Oops!</title>
</head>
<body>
<h1>
{isRouteErrorResponse(error)
? `${error.status} ${error.statusText}`
: error instanceof Error
? error.message
: 'Unknown Error'}
</h1>
<Scripts />
</body>
</html>
)
}

400.jsx

Remix does not have a special file for 400 errors, you can use the error boundary to show a custom message for 400 errors.

Notice that the same Remix ErrorBoundary used for runtime errors is also called for 404 errors, you can check if the error is a response error to show a not found message.

Next.js

// pages/400.jsx
export default function Custom404() {
return <h1>404 - Page Not Found</h1>
}

Remix

// root.tsx
import { useRouteError, Scripts, isRouteErrorResponse } from '@remix-run/react'
// a 404 page is the same thing as an error page, where the error is a 404 response
export function ErrorBoundary() {
const error = useRouteError()
return (
<html>
<head>
<title>Oops!</title>
</head>
<body>
<h1>
{isRouteErrorResponse(error)
? `${error.status} ${error.statusText}`
: error instanceof Error
? error.message
: 'Unknown Error'}
</h1>
<Scripts />
</body>
</html>
)
}

useRouter().events

Next.js pages directory has router events, perfect to show progress bar at the top of the screen. Remix can do the same thing with the useNavigation hook.

Next.js

import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
export default function Index() {
const router = useRouter()
const [isNavigating, setIsNavigating] = useState(false)
useEffect(() => {
router.events.on('routeChangeStart', () => setIsNavigating(true))
router.events.on('routeChangeComplete', () => setIsNavigating(false))
router.events.on('routeChangeError', () => setIsNavigating(false))
}, [router.events])
return <div>{isNavigating ? 'Navigating...' : 'Not navigating'}</div>
}

Remix

import { useNavigation } from '@remix-run/react'
export default function Index() {
const { state } = useNavigation()
return <div>{state === 'loading' ? 'Navigating...' : 'Not navigating'}</div>
}

Showing skeleton while loading (app directory)

Next.js support streaming when using the app directory and server components, when you fetch a page you get the suspense fallback first while the browser streams the rest of the page and React injects script tags at the end to replace the fallbacks with the real components.

Remix can do the same, using the defer utility function. You pass unresolved promises and Remix can start render the page and replace the fallbacks with the rendered components later on time.

Next.js

// app/page.tsx using server components
import { Suspense } from 'react'
async function ServerComponent() {
const data = await fetchData()
return <div>{data}</div>
}
export default function Page() {
return (
<Suspense fallback={<div>Loading...</div>}>
<ServerComponent />
</Suspense>
)
}

Remix

import { defer } from '@remix-run/node'
import { useLoaderData, Await } from '@remix-run/react'
import { Suspense } from 'react'
export function loader() {
return defer({
data: fetchData(),
})
}
export default function Page() {
const { data } = useLoaderData<typeof loader>()
return (
<Suspense fallback={<div>Loading...</div>}>
<Await resolve={data}>{(data) => <div>{data}</div>}</Await>
</Suspense>
)
}

Dynamic imports

Remix supports the React.lazy function to load components dynamically.

Next.js

import dynamic from 'next/dynamic'
const Page = dynamic(() => import('./page'), {
loading: () => <div>Loading...</div>,
})
export default function App() {
return <Page />
}

Remix

import { lazy, Suspense } from 'react'
const Page = lazy(() => import('./page'))
export default function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Page />
</Suspense>
)
}

Written by @__morse
Edit on GitHub