Routes, Controllers and Views
In the previous chapter, we created the Post and Comment models with their database tables and relationships. Now we'll bring those models to life by building pages where users can actually see posts.
This tutorial covers basic routing, controllers, and views. For advanced topics like route groups, middleware, route parameters validation, and custom Inertia components, see the Routing guide, Controllers guide, and Inertia documentation.
Overview
Right now, your posts and comments exist only in the database. Let's build two pages: one that lists all posts, and another that shows a single post with its comments.
This is where you'll see the complete flow in action — models handle data, transformers serialize it for the frontend, controllers coordinate logic, and React components display everything to users.
Before we begin, make sure your development server is running.
node ace serve --hmr
Displaying the posts list
Let's build the complete feature for displaying a list of posts. We'll create a transformer to serialize post data, add a controller method to fetch posts, register a route, and create the React component.
-
Creating the transformer
Transformers convert your Lucid models into plain JSON objects that can be safely sent to your React frontend. They explicitly control what data gets serialized and generate TypeScript types for your components.
Create a transformer for posts:
node ace make:transformer postThis creates
app/transformers/post_transformer.ts. Open it and define what data to serialize:app/transformers/post_transformer.tsimport { BaseTransformer } from '@adonisjs/core/transformers' import type Post from '#models/post' import UserTransformer from '#transformers/user_transformer' export default class PostTransformer extends BaseTransformer<Post> { toObject() { return { ...this.pick(this.resource, ['id', 'title', 'url', 'summary', 'createdAt']), author: UserTransformer.transform(this.resource.user), } } }We're using
this.pick()to select specific fields from the Post model, and transforming the relateduserwithUserTransformer. The starter kit already includesUserTransformer, which serializes user data (id, fullName, email). -
Creating the controller
Now create a controller to handle post-related requests.
node ace make:controller postsThis creates
app/controllers/posts_controller.ts. Add a method to list all posts:app/controllers/posts_controller.tsimport Post from '#models/post' import type { HttpContext } from '@adonisjs/core/http' import PostTransformer from '#transformers/post_transformer' export default class PostsController { async index({ inertia }: HttpContext) { const posts = await Post.query() .preload('user') .orderBy('createdAt', 'desc') return inertia.render('posts/index', { posts: PostTransformer.transform(posts), }) } }A few things to note here:
- We're preloading the
userrelationship so we can display the author's name without extra queries - We're ordering posts by creation date with newest first
- We're using
PostTransformer.transform()to serialize the posts
- We're preloading the
-
Defining the route
Open your routes file and register a route.
start/routes.tsimport router from '@adonisjs/core/services/router' import { controllers } from '#generated/controllers' router.on('/').renderInertia('home').as('home') router.get('/posts', [controllers.Posts, 'index'])The route connects the
/postsURL to your controller'sindexmethod. When someone visits/posts, AdonisJS callsPostsController.index()and Inertia renders the React component with the posts data. -
Creating the React component
Time to create the React component that will display the posts.
node ace make:inertia posts/indexThis creates
inertia/pages/posts/index.tsx. Open it and add the following code:inertia/pages/posts/index.tsximport { InertiaProps } from '~/types' import { Data } from '~/generated/data' type PageProps = InertiaProps<{ posts: Data.Post[] }> export default function PostsIndex(props: PageProps) { const { posts } = props return ( <div className="container"> <div className="posts-list-title"> <h1>Posts</h1> </div> {posts.map((post) => ( <div key={post.id} className="post-item"> <h2>{post.title}</h2> <div className="post-meta"> <div>By {post.author.fullName}</div> <span>.</span> <div> <a href={post.url} target="_blank" rel="noreferrer"> {post.url} </a> </div> <span>.</span> <div> <a href={`/posts/${post.id}`}>View comments</a> </div> </div> </div> ))} </div> ) }Let's break down what's happening:
- TypeScript props: We're using
InertiaPropscombined with the generatedData.Posttype to get full type safety. TheData.Posttype is automatically generated from ourPostTransformer. - Mapping posts: We loop through the posts array and display each post's title, author, and URL.
- Type safety: Your editor will autocomplete
post.title,post.author.fullName, etc., and TypeScript will catch any typos.
- TypeScript props: We're using
Visit
/posts and you should see a list of all your posts!
Displaying a single post
Now let's add the ability to view an individual post with its details. We'll implement the controller method, register the route with a dynamic parameter, and create the React component.
-
Implementing the controller method
Add the
showmethod to your controller:app/controllers/posts_controller.tsimport Post from '#models/post' import type { HttpContext } from '@adonisjs/core/http' import PostTransformer from '#transformers/post_transformer' export default class PostsController { async index({ inertia }: HttpContext) { const posts = await Post.query() .preload('user') .orderBy('createdAt', 'desc') return inertia.render('posts/index', { posts: PostTransformer.transform(posts), }) } async show({ inertia, params }: HttpContext) { const post = await Post.query() .where('id', params.id) .preload('user') .firstOrFail() return inertia.render('posts/show', { post: PostTransformer.transform(post), }) } }We're using
firstOrFail()which will automatically throw a 404 error if no post exists with that ID. The pattern is the same asindex: fetch data, transform it, and pass it as props. -
Registering the route
Register the route for this controller method:
start/routes.tsimport router from '@adonisjs/core/services/router' import { controllers } from '#generated/controllers' router.get('/posts', [controllers.Posts, 'index']) router.get('/posts/:id', [controllers.Posts, 'show'])The
:idpart is a route parameter. When someone visits/posts/5, AdonisJS captures that5and makes it available in your controller asparams.id. -
Creating the React component
Create the component for displaying a single post:
node ace make:inertia posts/showThis creates
inertia/pages/posts/show.tsx. Open it and add the following code:inertia/pages/posts/show.tsximport { InertiaProps } from '~/types' import { Data } from '~/generated/data' type PageProps = InertiaProps<{ post: Data.Post }> export default function PostsShow(props: PageProps) { const { post } = props return ( <div className="container"> <div> <h1>{post.title}</h1> </div> <div className="post"> <div className="post-meta"> <div>By {post.author.fullName}</div> <span>.</span> <div> <a href={post.url} target="_blank" rel="noreferrer"> {post.url} </a> </div> </div> <div className="post-summary">{post.summary}</div> </div> </div> ) }Again, we're using
InertiaPropswith the generatedData.Posttype to get automatic type safety for thepostprop.Try clicking on a post from your list page. You should now see the full post with its title, author, and content.
Using client-side navigation
Right now, clicking between pages causes full page reloads. Inertia provides a Link component that enables client-side navigation — clicking a link fetches only the new page data via AJAX and swaps the component, making your app feel instant.
Let's update the posts list to use the Link component:
import { InertiaProps } from '~/types'
import { Data } from '~/generated/data'
import { Link } from '@adonisjs/inertia/react'
type PageProps = InertiaProps<{
posts: Data.Post[]
}>
export default function PostsIndex(props: PageProps) {
const { posts } = props
return (
<div className="container">
<div className="posts-list-title">
<h1>Posts</h1>
</div>
{posts.map((post) => (
<div key={post.id} className="post-item">
<h2>{post.title}</h2>
<div className="post-meta">
<div>By {post.author.fullName}</div>
<span>.</span>
<div>
<a href={post.url} target="_blank" rel="noreferrer">
{post.url}
</a>
</div>
<span>.</span>
<div>
<a href={`/posts/${post.id}`}>View comments</a>
<Link route="posts.show" routeParams={{ id: post.id }}>
View comments
</Link>
</div>
</div>
</div>
))}
</div>
)
}
The Link component accepts two important props:
route: The name of the route to navigate to. In this case,posts.showrefers to ourPostsController.showmethod.routeParams: An object containing parameters for the route. Here, we're passing the post's ID to fill in the:idparameter.
About named routes
You might be wondering where posts.show comes from. When you define a route with a controller, AdonisJS automatically generates a route name based on the controller and method names. For example, [controllers.Posts, 'show'] automatically gets the name posts.show.
This is much better than hardcoding URLs like href={/posts/${post.id}} because:
- Maintainability: If you change the URL pattern from
/posts/:idto/showcase/:idin your routes file, all your links automatically work with the new pattern. No find-and-replace needed. - Type safety: The
routeprop expects valid route names, so TypeScript will catch typos. - Centralized routing: All URL patterns are defined in one place (your routes file), making your application easier to understand and modify.
Now when you click "View comments", Inertia handles the navigation without a full page reload. The browser's back button still works, and the URL updates properly — but it feels much faster.
Adding comments to the post view
Finally, let's display the comments for each post. First, we need to create a transformer for comments, update the Post transformer to include them, preload them in the controller, and finally display them in the React component.
-
Create the Comment transformer
Create a transformer for comments:
node ace make:transformer commentOpen it and define what data to serialize:
app/transformers/comment_transformer.tsimport { BaseTransformer } from '@adonisjs/core/transformers' import type Comment from '#models/comment' import UserTransformer from '#transformers/user_transformer' export default class CommentTransformer extends BaseTransformer<Comment> { toObject() { return { ...this.pick(this.resource, ['id', 'content', 'createdAt']), author: UserTransformer.transform(this.resource.user), } } } -
Update the Post transformer
Now update
PostTransformerto include comments:app/transformers/post_transformer.tsimport { BaseTransformer } from '@adonisjs/core/transformers' import type Post from '#models/post' import UserTransformer from '#transformers/user_transformer' import CommentTransformer from '#transformers/comment_transformer' export default class PostTransformer extends BaseTransformer<Post> { toObject() { return { ...this.pick(this.resource, ['id', 'title', 'url', 'summary', 'createdAt']), author: UserTransformer.transform(this.resource.user), comments: CommentTransformer.transform(this.whenLoaded(this.resource.comments)), } } }We're using
this.whenLoaded()to guard against undefined relationships. This means thecommentsfield will only be included if we preloaded the relationship in the controller. -
Preload comments in the controller
Update the
showmethod to preload comments and their authors:app/controllers/posts_controller.tsimport Post from '#models/post' import type { HttpContext } from '@adonisjs/core/http' import PostTransformer from '#transformers/post_transformer' export default class PostsController { async index({ inertia }: HttpContext) { const posts = await Post.query() .preload('user') .orderBy('createdAt', 'desc') return inertia.render('posts/index', { posts: PostTransformer.transform(posts), }) } async show({ inertia, params }: HttpContext) { const post = await Post.query() .where('id', params.id) .preload('user') .preload('comments', (query) => { query.preload('user').orderBy('createdAt', 'asc') }) .firstOrFail() return inertia.render('posts/show', { post: PostTransformer.transform(post), }) } }We're preloading comments along with each comment's user (the author), and ordering them by creation date with oldest first.
-
Display comments in the React component
Update the component to display the comments:
inertia/pages/posts/show.tsximport { InertiaProps } from '~/types' import { Data } from '~/generated/data' type PageProps = InertiaProps<{ post: Data.Post }> export default function PostsShow(props: PageProps) { const { post } = props return ( <div className="container"> <div> <h1>{post.title}</h1> </div> <div className="post"> <div className="post-meta"> <div>By {post.author.fullName}</div> <span>.</span> <div> <a href={post.url} target="_blank" rel="noreferrer"> {post.url} </a> </div> </div> <div className="post-summary">{post.summary}</div> <div className="post-comments"> <h2>Comments</h2> {post.comments && post.comments.length > 0 ? ( post.comments.map((comment) => ( <div key={comment.id} className="comment-item"> <p>{comment.content}</p> <div className="comment-meta"> By {comment.author.fullName} on{' '} {new Date(comment.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', })} </div> </div> )) ) : ( <p>No comments yet.</p> )} </div> </div> </div> ) }Notice how TypeScript knows that
post.commentsexists and what shape each comment has. This is all thanks to the generatedData.Posttype from our transformers.
Refresh your post detail page and you'll now see all the comments listed below the post content!
What you've built
You've just completed the full data flow in an AdonisJS + Inertia app:
- Routes that map URLs to controller actions
- Controllers that fetch data from your models
- Transformers that serialize models into type-safe JSON for your frontend
- React components that receive and display data with full TypeScript support
- Relationships that let you eager load related data efficiently
- Client-side navigation with Inertia's
Linkcomponent for a SPA-like experience