Remix: Dynamic routes and nested routes

So far we’ve seen static routes, routes that are specifically assigned to a route component, for example /about corresponds to app/routes/about.jsx.

But it’s very common to have dynamic routes. The most basic example is a blog. You’ll have a /blog route, and under it, each blog post.

Typically you’ll have a slug, which is a URL-safe version of the title, like /blog/dynamic-routes.

But for this example let’s do a number, each blog posts incremented by 1: /blog/1, /blog/2, and so on.

To handle this, we create a route file with a special symbol, $. In this case app/routes/blog.$id.jsx.

The dot (.) between blog and $id means there’ll be a new segment in the URL path (/blog/<id>).

So here’s an interesting thing. With nested routes like in our case (/blog/<id>), if you have a route component app/routes/blog.jsx, that will be used as the layout.

Let’s create that file first. We start by copying what we had in the other routes like about.jsx and we adapt it. Also add an entry in app/components/Nav.jsx so you have an active route if the URL is /blog.

import Nav from '../components/Nav'

export const meta = () => {
  return [{ title: 'Blog' }]
}

export default function Blog() {
  return (
    <div style={{ fontFamily: 'system-ui, sans-serif', lineHeight: '1.4' }}>
      <Nav />
      <h1>Blog</h1>
    </div>
  )
}

Now I want to list the blog posts. This data now will be hardcoded in the route component, but I want to be able to get it from a database in the future. So I use a special Remix thing, by exporting a loader function:

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

//...

export const loader = async () => {
  const posts = [
    { id: 1, title: 'First' },
    { id: 2, title: 'Second' },
    { id: 3, title: 'Third' },
    { id: 4, title: 'Fourth' },
  ]

  return json({ posts })
}

Now inside the component I can retrieve this data using the useLoaderData() hook provided by Remix:

import Nav from '../components/Nav'
import { json } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'

export const meta = () => {
  return [{ title: 'Blog' }]
}

export const loader = async () => {
  const posts = [
    { id: 1, title: 'First' },
    { id: 2, title: 'Second' },
    { id: 3, title: 'Third' },
    { id: 4, title: 'Fourth' },
  ]

  return json({ posts })
}

export default function Blog() {
  const { posts } = useLoaderData()

  return (
    <div style={{ fontFamily: 'system-ui, sans-serif', lineHeight: '1.4' }}>
      <Nav />
      <h1>Blog</h1>
    </div>
  )
}

Now I can print the blog posts list:

//...
import { useLoaderData, NavLink } from '@remix-run/react'

//...

export default function Blog() {
  const { posts } = useLoaderData()

  return (
    <div style={{ fontFamily: 'system-ui, sans-serif', lineHeight: '1.4' }}>
      <Nav />
      <h1>Blog</h1>
      <ul className='list-disc list-inside'>
        {posts.map((post) => (
          <li key={post.id}>
            <NavLink
              to={`/blog/${post.id}`}
              className={({ isActive }) => (isActive ? 'font-bold' : '')}>
              {post.title}
            </NavLink>
          </li>
        ))}
      </ul>
    </div>
  )
}

Note that I use the NavLink component here because we can take advantage of it to display the current blog post.

Let me style this a little bit nicer with CSS Grid and some padding and typography classes:

export default function Blog() {
  const { posts } = useLoaderData()

  return (
    <div
      style={{ fontFamily: 'system-ui, sans-serif', lineHeight: '1.4' }}
      className='p-8'>
      <Nav />
      <h1 className='pb-2 mt-4 text-2xl font-bold'>Blog</h1>
      <div className='grid grid-cols-4'>
        <ul className='list-disc list-inside row-span-1'>
          {posts.map((post) => (
            <li>
              <NavLink
                to={`/blog/${post.id}`}
                className={({ isActive }) => (isActive ? 'font-bold' : '')}>
                {post.title}
              </NavLink>
            </li>
          ))}
        </ul>
      </div>
    </div>
  )
}

Much better:

If you try clicking a blog post, notice the URL changes but we have an error because we haven’t created the route for that URL yet.

Let’s do it now!

Create app/routes/blog.$id.jsx.

We start simple:

export const meta = () => {
  return [{ title: 'Blog post' }]
}

export default function BlogPost() {
  return (
    <div>
      <h1>Blog post</h1>
      <p>...</p>
    </div>
  )
}

Now if you go back to the browser and click a post URL like /blog/1, we don’t have an error any more, and the post title in the sidebar is highlighted. But we don’t see any content:

This is because we need to add to the blog.jsx file an Outlet:

import { useLoaderData, NavLink, Outlet } from '@remix-run/react'

//...

export default function Blog() {
  const { posts } = useLoaderData()

  return (
    <div
      style={{ fontFamily: 'system-ui, sans-serif', lineHeight: '1.4' }}
      className='p-8'>
      <Nav />
      <h1 className='pb-2 mt-4 text-2xl font-bold'>Blog</h1>
      <div className='grid grid-cols-4'>
        <ul className='list-disc list-inside row-span-1'>
          {posts.map((post) => (
            <li>
              <NavLink
                to={`/blog/${post.id}`}
                className={({ isActive }) => (isActive ? 'font-bold' : '')}>
                {post.title}
              </NavLink>
            </li>
          ))}
        </ul>
        <Outlet />
      </div>
    </div>
  )
}

See, I added <Outlet /> after the <ul> tag.

Now we see the blog post component content:

Back to blog.$id.jsx we need to add the blog posts data. As we did in blog.jsx, we’ll hardcode the blog posts in the loader. But this time the loader needs to return a single blog post, if it corresponds to the id parameter in the URL, which is passed to the loader through the params parameter:

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

export const meta = () => {
  return [{ title: 'Blog post' }]
}

export const loader = async ({ params }) => {
  const posts = [
    { id: 1, title: 'First' },
    { id: 2, title: 'Second' },
    { id: 3, title: 'Third' },
    { id: 4, title: 'Fourth' },
  ]

  const post = posts.filter((post) => post.id === parseInt(params.id))

  if (post.length === 1) {
    return json({ post: post[0] })
  } else {
    return json({})
  }
}

export default function Post() {
  const { post } = useLoaderData()

  if (!post) {
    return (
      <div style={{ fontFamily: 'system-ui, sans-serif', lineHeight: '1.4' }}>
        <h1>No post found</h1>
      </div>
    )
  }
  return (
    <div style={{ fontFamily: 'system-ui, sans-serif', lineHeight: '1.4' }}>
      <h1 className='text-xl font-bold'>{post.title}</h1>
      <p>...post content</p>
    </div>
  )
}

It works:

So, the app/routes/blog.$id.jsx file is a nested route and its layout is not directly app/root.jsx like for normal routes, but its parent route app/routes/blog.jsx.

This is by default.

You can avoid this by appending an underscore _ to the filename of blog.jsx and turning it into app/routes/blog_.jsx. It’s a naming convention.

Lessons in this unit:

0: Introduction
1: Create your first Remix app
2: The root route
3: File based routing
4: Linking your pages
5: Styling with CSS and Tailwind
6: Create a navigation
7: ▶︎ Dynamic routes and nested routes
8: Connecting a database
9: Data mutations using forms and actions
Are you intimidated by Git? Can’t figure out merge vs rebase? Are you afraid of screwing up something any time you have to do something in Git? Do you rely on ChatGPT or random people’s answer on StackOverflow to fix your problems? Your coworkers are tired of explaining Git to you all the time? Git is something we all need to use, but few of us really master it. I created this course to improve your Git (and GitHub) knowledge at a radical level. Launching May 21, 2024. Join the waiting list!