Remix for Next.js Developers

11 min read Original article ↗

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>

)

}