3. Building Pages
In Part 2 you mapped routes to handlers that return HTML strings. That works, but it is clumsy --- you end up writing HTML inside template literals with no syntax highlighting, no autocomplete, and lots of repetition.
In this part, you will learn to build pages using JSX and Remix's built-in component system. You will create reusable components, build a shared layout, add some styling, and display a list of books.
By the end, your bookstore will have properly structured pages with a consistent header, footer, and navigation.
What is JSX?
JSX is a syntax extension for JavaScript that lets you write HTML-like code directly in your .tsx files. Instead of building HTML strings by hand, you write:
let page = (
<html>
<head><title>My Page</title></head>
<body><h1>Hello</h1></body>
</html>
)This looks like HTML, but it is actually JavaScript. The <html>, <head>, and <body> tags get transformed into function calls that produce a data structure representing the page. A renderer then turns that data structure into the final HTML string that gets sent to the browser.
JSX was popularized by React, but Remix V3 has its own lightweight JSX runtime --- you do not need React or any other UI library. The jsxImportSource setting in your tsconfig.json (which you set up in Part 1) tells TypeScript to use Remix's runtime.
JSX files use the .tsx extension
Files that contain JSX must have the .tsx extension (instead of .ts). This tells TypeScript that the file contains JSX syntax and needs to be processed accordingly.
The html Template Tag
Before diving into components, let's look at a simpler way to generate HTML. Remix provides an html template tag that lets you write HTML in template literals with automatic escaping:
import { html } from 'remix/html-template'
function homePage() {
let title = 'Welcome to the Bookstore'
return new Response(
html`<!DOCTYPE html>
<html lang="en">
<head><title>Bookstore</title></head>
<body>
<h1>${title}</h1>
</body>
</html>`.toString(),
{ headers: { 'Content-Type': 'text/html; charset=UTF-8' } },
)
}The html tag looks like a regular template literal, but it does something important: it escapes any interpolated values (${title}) so they are safe to embed in HTML. If title contained something dangerous like <script>alert('hack')</script>, the html tag would convert the angle brackets to < and >, preventing the browser from executing the script.
Why escaping matters
When you insert user-provided data into an HTML page (like a search query, a user's name, or a form value), you must escape special HTML characters. Without escaping, an attacker could inject malicious <script> tags into your page. This type of attack is called Cross-Site Scripting (XSS). Both the html tag and JSX components handle escaping automatically.
The html tag is useful for simple pages, but for anything complex, Remix's component system is a better choice.
Your First Component
A component in Remix is a function that returns a render function. The render function receives props (input data) and returns JSX:
function Greeting() {
return ({ name }: { name: string }) => (
<p>Hello, {name}!</p>
)
}Let's break this down:
function Greeting()--- the component function. Its name must start with a capital letter (this is how JSX distinguishes components from HTML tags).return ({ name }) => ...--- the component returns a render function. This inner function receives props (short for "properties") --- the data passed to the component.<p>Hello, {name}!</p>--- the JSX that produces HTML. Curly braces{}let you embed JavaScript expressions inside JSX.
You use the component like an HTML tag:
<Greeting name="World" />This renders as:
<p>Hello, World!</p>Why the double function?
Remix components use a "function that returns a function" pattern. The outer function creates the component, and the inner function renders it with specific data. This pattern supports Remix's streaming and rendering optimizations. You do not need to understand the internals --- just remember the structure: define a function, return an arrow function that takes props and returns JSX.
Building the Document Component
Every page in the bookstore shares the same HTML skeleton: <!DOCTYPE html>, <html>, <head>, and <body>. Let's extract that into a reusable component.
Create a new directory and file:
mkdir -p app/uiimport type { RemixNode } from 'remix/component'
export interface DocumentProps {
title?: string
children?: RemixNode
}
export function Document() {
return ({ title = 'Bookstore', children }: DocumentProps) => (
<html lang="en">
<head>
<meta charSet="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<link rel="stylesheet" href="/app.css" />
</head>
<body>{children}</body>
</html>
)
}Let's examine the new concepts:
The children Prop
The children prop is special --- it represents whatever content is placed between the opening and closing tags of a component:
<Document title="Home">
<h1>Welcome!</h1> {/* This is the children */}
<p>Hello, world.</p>
</Document>Everything between <Document> and </Document> gets passed as children. The Document component renders it inside the <body> tag.
The RemixNode Type
The type RemixNode represents anything that can be rendered as JSX: a string, a number, a JSX element, an array of these, or null/undefined. It is the type you use for children and any other prop that accepts renderable content.
Props Interface
The DocumentProps interface defines the shape of the props:
export interface DocumentProps {
title?: string // Optional - defaults to 'Bookstore'
children?: RemixNode // Optional - the content inside the component
}The ? after the property name means it is optional. If you use <Document /> without specifying a title, it defaults to 'Bookstore' (set by the = 'Bookstore' default value in the render function).
Building the Layout Component
The layout wraps every page with a header, navigation, main content area, and footer. Create app/ui/layout.tsx:
import type { RemixNode } from 'remix/component'
import { routes } from '../routes.ts'
import { Document } from './document.tsx'
export interface LayoutProps {
title?: string
children?: RemixNode
}
export function Layout() {
return ({ title, children }: LayoutProps) => (
<Document title={title}>
<header>
<div class="container">
<h1>
<a href={routes.home.href()}>Bookstore</a>
</h1>
<nav>
<a href={routes.home.href()}>Home</a>
<a href={routes.books.index.href()}>Books</a>
<a href={routes.about.href()}>About</a>
<a href={routes.contact.index.href()}>Contact</a>
</nav>
</div>
</header>
<main>
<div class="container">{children}</div>
</main>
<footer>
<div class="container">
<p>© {new Date().getFullYear()} Bookstore. Built with Remix.</p>
</div>
</footer>
</Document>
)
}Notice how Layout uses Document internally. Components can use other components, just like HTML tags can be nested. The Layout adds the header, navigation, and footer, and delegates the <html>/<head>/<body> structure to Document.
Also notice how we use routes.home.href(), routes.books.index.href(), etc. for the navigation links. These are the type-safe URL generators from Part 2 --- no hardcoded URL strings.
Rendering Components to Responses
To use components in your route handlers, you need to render them to HTML and wrap the result in a Response. Create a helper for this:
import type { RemixNode } from 'remix/component'
import { renderToString } from 'remix/component/server'
export function render(node: RemixNode, init?: ResponseInit) {
let html = renderToString(node)
let headers = new Headers(init?.headers)
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'text/html; charset=UTF-8')
}
return new Response(html, { ...init, headers })
}mkdir -p app/utilsThe renderToString function takes a JSX node and turns it into an HTML string. The render helper wraps that string in a Response with the correct Content-Type header.
renderToString vs. renderToStream
Remix offers two ways to render components:
renderToStringrenders everything to a single string. Simple and easy to understand.renderToStreamrenders progressively, sending chunks of HTML as they become ready. This is faster for large pages because the browser can start rendering before the entire page is generated.
We use renderToString here for simplicity. The bookstore demo uses renderToStream for better performance. You can switch later without changing your components.
Rewriting the Home Page
Now let's use components to build real pages. Update app/router.ts to use the Layout component. But first, let's create a dedicated controller file for the home page.
Create app/controllers/home.tsx:
import type { BuildAction } from 'remix/fetch-router'
import { css } from 'remix/component'
import type { routes } from '../routes.ts'
import { render } from '../utils/render.tsx'
import { Layout } from '../ui/layout.tsx'
export const home: BuildAction<'GET', typeof routes.home> = {
handler() {
return render(<HomePage />)
},
}
function HomePage() {
return () => (
<Layout>
<div class="card">
<h1>Welcome to the Bookstore</h1>
<p mix={css({ margin: '1rem 0' })}>
Discover your next favorite book from our curated collection.
</p>
<p>
<a href="/books" class="btn">Browse Books</a>
</p>
</div>
</Layout>
)
}Let's look at the new concepts here:
The BuildAction Type
export const home: BuildAction<'GET', typeof routes.home> = {
handler() {
return render(<HomePage />)
},
}BuildAction is a TypeScript type that ensures your handler is compatible with the route it is mapped to. The first argument ('GET') specifies the HTTP method, and the second (typeof routes.home) specifies the route. TypeScript will catch errors if you try to access parameters that do not exist on this route, or if you forget to handle a required parameter.
The css() Mixin
<p mix={css({ margin: '1rem 0' })}>The css() function applies inline styles to an element. You pass it a JavaScript object where keys are CSS property names (in camelCase) and values are CSS values. The mix attribute is Remix's way of attaching mixins --- reusable bits of styling or behavior --- to elements.
Using css() with mix is similar to the style attribute in HTML, but it is more composable --- you can combine multiple mixins on the same element:
<div mix={[css({ padding: '1rem' }), css({ color: 'blue' })]}>
Multiple styles
</div>Controllers
The home export is a controller --- an object that contains handler functions for a route or group of routes. For a single route like routes.home, the controller has a handler property. For grouped routes like routes.books, it has an actions property with a handler for each route in the group.
Building More Pages
Create controller files for the about and contact pages:
import type { BuildAction } from 'remix/fetch-router'
import type { routes } from '../routes.ts'
import { render } from '../utils/render.tsx'
import { Layout } from '../ui/layout.tsx'
export const about: BuildAction<'GET', typeof routes.about> = {
handler() {
return render(<AboutPage />)
},
}
function AboutPage() {
return () => (
<Layout title="About">
<h1>About the Bookstore</h1>
<div class="card">
<p>
We are a small, independent bookstore dedicated to bringing you the best
books across all genres. Whether you are looking for a gripping novel, a
helpful self-help guide, or a beautiful cookbook, we have something for you.
</p>
</div>
</Layout>
)
}mkdir -p app/controllersimport type { Controller } from 'remix/fetch-router'
import type { routes } from '../routes.ts'
import { render } from '../utils/render.tsx'
import { Layout } from '../ui/layout.tsx'
export default {
actions: {
index() {
return render(<ContactPage />)
},
action() {
return render(<ContactSuccessPage />)
},
},
} satisfies Controller<typeof routes.contact>
function ContactPage() {
return () => (
<Layout title="Contact">
<h1>Contact Us</h1>
<div class="card">
<form method="POST">
<div class="form-group">
<label for="name">Name</label>
<input type="text" id="name" name="name" required />
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" required />
</div>
<div class="form-group">
<label for="message">Message</label>
<textarea id="message" name="message" rows={5} required />
</div>
<button type="submit" class="btn">Send Message</button>
</form>
</div>
</Layout>
)
}
function ContactSuccessPage() {
return () => (
<Layout title="Thanks">
<h1>Thank You!</h1>
<div class="card">
<p>We received your message and will get back to you soon.</p>
</div>
</Layout>
)
}Notice the difference between the two controller styles:
aboutusesBuildActionfor a single route. It exports a named handler with ahandlerproperty.contactusesControllerfor a route group (theform()helper created bothindexandaction). It exports a default object with anactionsproperty containing a handler for each route name.
The satisfies Controller<typeof routes.contact> at the end is a TypeScript check that verifies the controller handles all the routes in the group.
Building the Book Listing Page
Create a simple book listing page using hardcoded data. You will replace this with database data in Part 4.
import type { Controller } from 'remix/fetch-router'
import { css } from 'remix/component'
import type { routes } from '../routes.ts'
import { render } from '../utils/render.tsx'
import { Layout } from '../ui/layout.tsx'
// Temporary hardcoded data (replaced with database in Part 4)
let sampleBooks = [
{
slug: 'bbq',
title: 'Ash & Smoke',
author: 'Rusty Char-Broil',
price: 16.99,
genre: 'cookbook',
cover_url: '/images/placeholder.jpg',
},
{
slug: 'heavy-metal',
title: 'Heavy Metal Guitar Riffs',
author: 'Axe Master Krush',
price: 27.00,
genre: 'music',
cover_url: '/images/placeholder.jpg',
},
{
slug: 'three-ways',
title: 'Three Ways to Change Your Life',
author: 'Wisdom Sage',
price: 28.99,
genre: 'self-help',
cover_url: '/images/placeholder.jpg',
},
]
export default {
actions: {
index() {
return render(<BooksIndexPage books={sampleBooks} />)
},
genre({ params }) {
let matchingBooks = sampleBooks.filter(
(book) => book.genre.toLowerCase() === params.genre.toLowerCase(),
)
return render(<BooksGenrePage genre={params.genre} books={matchingBooks} />)
},
show({ params }) {
let book = sampleBooks.find((b) => b.slug === params.slug)
if (!book) {
return render(<BookNotFoundPage />, { status: 404 })
}
return render(<BookShowPage book={book} />)
},
},
} satisfies Controller<typeof routes.books>
// --- Components ---
interface BookListProps {
books: typeof sampleBooks
}
function BookCard() {
return ({ book }: { book: (typeof sampleBooks)[number] }) => (
<div class="book-card">
<div class="book-card-body">
<h3>{book.title}</h3>
<p class="author">by {book.author}</p>
<p class="price">${book.price.toFixed(2)}</p>
<a href={`/books/${book.slug}`} class="btn">View Details</a>
</div>
</div>
)
}
function BooksIndexPage() {
return ({ books }: BookListProps) => (
<Layout title="Books">
<h1>Browse Books</h1>
<div class="grid">
{books.map((book) => (
<BookCard book={book} />
))}
</div>
</Layout>
)
}
function BooksGenrePage() {
return ({ genre, books }: { genre: string; books: typeof sampleBooks }) => (
<Layout title={`${genre} Books`}>
<h1>{genre} Books</h1>
{books.length === 0 ? (
<p>No books found in this genre.</p>
) : (
<div class="grid">
{books.map((book) => (
<BookCard book={book} />
))}
</div>
)}
<p mix={css({ marginTop: '2rem' })}>
<a href="/books">View all books</a>
</p>
</Layout>
)
}
function BookShowPage() {
return ({ book }: { book: (typeof sampleBooks)[number] }) => (
<Layout title={book.title}>
<h1>{book.title}</h1>
<div class="card">
<p><strong>Author:</strong> {book.author}</p>
<p><strong>Genre:</strong> {book.genre}</p>
<p><strong>Price:</strong> ${book.price.toFixed(2)}</p>
</div>
<p mix={css({ marginTop: '1rem' })}>
<a href="/books">Back to all books</a>
</p>
</Layout>
)
}
function BookNotFoundPage() {
return () => (
<Layout title="Not Found">
<h1>Book Not Found</h1>
<p>Sorry, we could not find that book.</p>
<a href="/books">Browse all books</a>
</Layout>
)
}Rendering Lists with .map()
A common pattern in JSX is rendering a list of items using JavaScript's .map() method:
<div class="grid">
{books.map((book) => (
<BookCard book={book} />
))}
</div>This takes the books array, calls the function once for each book, and returns an array of <BookCard> elements. JSX knows how to render arrays, so each card is rendered in order.
Conditional Rendering
You can show different content based on conditions using ternary expressions or logical AND:
{/* Ternary: show one thing or another */}
{books.length === 0 ? (
<p>No books found.</p>
) : (
<div class="grid">...</div>
)}
{/* Logical AND: show something only if condition is true */}
{user ? <a href="/account">Account</a> : null}Updating the Router
Now update app/router.ts to use the new controllers instead of inline handlers:
import { createRouter } from 'remix/fetch-router'
import { compression } from 'remix/compression-middleware'
import { logger } from 'remix/logger-middleware'
import { staticFiles } from 'remix/static-middleware'
import { routes } from './routes.ts'
import { home } from './controllers/home.tsx'
import { about } from './controllers/about.tsx'
import contactController from './controllers/contact.tsx'
import booksController from './controllers/books.tsx'
let middleware = []
if (process.env.NODE_ENV === 'development') {
middleware.push(logger())
}
middleware.push(compression())
middleware.push(staticFiles('./public'))
export let router = createRouter({ middleware })
router.map(routes.home, home)
router.map(routes.about, about)
router.map(routes.contact, contactController)
router.map(routes.books, booksController)The router is much cleaner now. Each route is mapped to a controller, and the handler logic lives in its own file.
Adding Styles
Create a CSS file to make the pages look presentable. Create public/app.css:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
background: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
/* Header */
header {
background: #1a1a2e;
color: white;
padding: 1rem 0;
}
header .container {
display: flex;
justify-content: space-between;
align-items: center;
}
header h1 { font-size: 1.5rem; }
header h1 a { color: white; text-decoration: none; }
header nav { display: flex; gap: 1rem; align-items: center; }
header nav a { color: #ccc; text-decoration: none; }
header nav a:hover { color: white; }
/* Main content */
main { padding: 2rem 0; min-height: 60vh; }
/* Footer */
footer {
background: #1a1a2e;
color: #ccc;
padding: 1rem 0;
text-align: center;
}
/* Cards */
.card {
background: white;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 1rem;
}
/* Grid */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
/* Book cards */
.book-card {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.book-card img {
width: 100%;
height: 200px;
object-fit: cover;
}
.book-card-body { padding: 1rem; }
.book-card-body h3 { margin-bottom: 0.5rem; }
.book-card .author { color: #666; margin-bottom: 0.25rem; }
.book-card .price { font-weight: bold; color: #2d7d46; margin-bottom: 0.5rem; }
/* Buttons */
.btn {
display: inline-block;
padding: 0.5rem 1rem;
background: #3b82f6;
color: white;
text-decoration: none;
border-radius: 4px;
border: none;
cursor: pointer;
font-size: 0.9rem;
}
.btn:hover { background: #2563eb; }
.btn-secondary {
background: #6b7280;
}
.btn-secondary:hover { background: #4b5563; }
/* Forms */
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 1rem;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}The <link rel="stylesheet" href="/app.css" /> tag in the Document component references this file. The staticFiles middleware serves it automatically from the public directory.
Try It Out
Start the dev server and visit the pages:
npm run dev- http://localhost:3000/ --- the styled home page
- http://localhost:3000/books --- the book listing with cards
- http://localhost:3000/books/bbq --- a book detail page
- http://localhost:3000/about --- the about page
- http://localhost:3000/contact --- the styled contact form
You should see a consistent layout across all pages with a dark header, navigation, and footer.
Your Project So Far
bookstore/
app/
controllers/
about.tsx # About page controller
books.tsx # Books controller with index, genre, show
contact.tsx # Contact form controller (GET + POST)
home.tsx # Home page controller
ui/
document.tsx # HTML document shell component
layout.tsx # Page layout with header/footer
utils/
render.tsx # Helper to render JSX to a Response
router.ts # Router setup and route mapping
routes.ts # Route definitions
public/
app.css # Application styles
package.json
server.ts
tsconfig.jsonRecap
In this part you learned:
- JSX is HTML-like syntax for JavaScript, used in
.tsxfiles. - Components are functions that return render functions.
- Props are the data you pass to components, like HTML attributes.
childrenis a special prop for content placed between component tags.RemixNodeis the type for any renderable value.renderToStringconverts JSX to an HTML string.- The
htmltemplate tag provides auto-escaping for template literals. - The
css()mixin applies inline styles via themixattribute. - Controllers organize handler functions for routes.
BuildActiontypes single-route handlers;Controllertypes route-group handlers..map()renders lists; ternary expressions handle conditional rendering.
The bookstore is looking good, but the book data is hardcoded. In the next part, you will set up a database, define tables, and query real data.