Blog Tutorial: Routes
In this chapter, we'll create the posts listing page and individual post pages using EreoJS's file-based routing.
Posts Listing Page
Create app/routes/posts/index.tsx:
tsx
// app/routes/posts/index.tsx
import { createLoader } from '@ereo/data'
import { Link } from '@ereo/client'
import { getPosts } from '../../lib/db'
export const loader = createLoader(async () => {
const posts = getPosts()
return { posts }
})
export default function Posts({ loaderData }) {
const { posts } = loaderData
return (
<div>
<h1>All Posts</h1>
{posts.length === 0 ? (
<p>No posts yet. <Link href="/posts/new">Write one!</Link></p>
) : (
posts.map((post: any) => (
<article key={post.id} className="post-card">
<h2>
<Link href={`/posts/${post.slug}`}>{post.title}</Link>
</h2>
<p className="post-meta">
{new Date(post.created_at).toLocaleDateString()}
</p>
<p>{post.excerpt}</p>
<Link href={`/posts/${post.slug}`}>Read more →</Link>
</article>
))
)}
</div>
)
}Post Detail Page
Create app/routes/posts/[slug].tsx for dynamic routing:
tsx
// app/routes/posts/[slug].tsx
import { createLoader } from '@ereo/data'
import { Link } from '@ereo/client'
import { getPost, getPostComments } from '../../lib/db'
export const loader = createLoader(async ({ params }) => {
const post = getPost(params.slug)
if (!post) {
throw new Response('Post not found', { status: 404 })
}
const comments = getPostComments(post.id)
return { post, comments }
})
export function meta({ data }) {
return [
{ title: `${data.post.title} | My Blog` },
{ name: 'description', content: data.post.excerpt }
]
}
export default function PostPage({ loaderData }) {
const { post, comments } = loaderData
return (
<article>
<header>
<h1>{post.title}</h1>
<p className="post-meta">
Published on {new Date(post.created_at).toLocaleDateString()}
</p>
</header>
<div className="post-content">
{post.content.split('\n').map((paragraph: string, i: number) => (
<p key={i}>{paragraph}</p>
))}
</div>
<section className="comments">
<h2>Comments ({comments.length})</h2>
{comments.length === 0 ? (
<p>No comments yet. Be the first!</p>
) : (
comments.map((comment: any) => (
<div key={comment.id} className="comment">
<strong>{comment.author}</strong>
<span className="comment-date">
{new Date(comment.created_at).toLocaleDateString()}
</span>
<p>{comment.content}</p>
</div>
))
)}
</section>
<Link href="/posts">← Back to all posts</Link>
</article>
)
}Add Comment Styles
Add to public/styles.css:
css
/* Add to public/styles.css */
.post-content {
margin: 2rem 0;
line-height: 1.8;
}
.post-content p {
margin-bottom: 1.5rem;
}
.comments {
margin-top: 3rem;
padding-top: 2rem;
border-top: 1px solid #e5e7eb;
}
.comment {
background: white;
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
border: 1px solid #e5e7eb;
}
.comment strong {
margin-right: 0.5rem;
}
.comment-date {
color: #6b7280;
font-size: 0.875rem;
}
.hero {
text-align: center;
padding: 3rem 0;
margin-bottom: 2rem;
}
.hero p {
color: #6b7280;
font-size: 1.25rem;
}Error Page
Create app/routes/posts/_error.tsx to handle errors:
tsx
// app/routes/posts/_error.tsx
import { useRouteError, isRouteErrorResponse } from '@ereo/client'
import { Link } from '@ereo/client'
export default function PostsError() {
const error = useRouteError()
if (isRouteErrorResponse(error) && error.status === 404) {
return (
<div className="error-page">
<h1>Post Not Found</h1>
<p>The post you're looking for doesn't exist.</p>
<Link href="/posts" className="btn">View All Posts</Link>
</div>
)
}
return (
<div className="error-page">
<h1>Oops!</h1>
<p>Something went wrong loading this post.</p>
<Link href="/posts" className="btn">View All Posts</Link>
</div>
)
}Add error styles:
css
/* Add to public/styles.css */
.error-page {
text-align: center;
padding: 3rem;
}
.error-page h1 {
color: #dc2626;
}Understanding the Routing
Let's review what we've created:
app/routes/
├── _layout.tsx # Root layout (wraps everything)
├── index.tsx # /
└── posts/
├── _error.tsx # Error boundary for /posts/*
├── index.tsx # /posts
└── [slug].tsx # /posts/:slugKey concepts:
- Directory structure = URL structure -
posts/index.tsxbecomes/posts - Dynamic segments -
[slug].tsxcaptures any value at that position - Layouts cascade -
_layout.tsxwraps all child routes - Error boundaries -
_error.tsxcatches errors in that route segment
Testing the Routes
Visit these URLs to test:
http://localhost:3000/- Home pagehttp://localhost:3000/posts- Posts listinghttp://localhost:3000/posts/welcome- Post detailhttp://localhost:3000/posts/nonexistent- Error page (404)
Navigation Flow
Notice how navigation works:
- Server renders the initial page
- Client takes over for subsequent navigation
- Prefetching loads data before you click
- No full page reloads - smooth SPA-like experience
Try hovering over links - watch the network tab to see prefetching in action!
What We've Done
- Created a posts listing page
- Created a dynamic post detail page
- Used the
paramsobject to access URL parameters - Added error handling for missing posts
- Learned about file-based routing conventions
Current File Structure
src/
├── lib/
│ └── db.ts
├── routes/
│ ├── _layout.tsx
│ ├── index.tsx
│ └── posts/
│ ├── _error.tsx
│ ├── index.tsx
│ └── [slug].tsx
└── index.tsNext Step
In the next chapter, we'll add the ability to create new posts using loaders and actions.