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.