Skip to content

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:

tsx
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:

ts
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 &lt; and &gt;, 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:

tsx
function Greeting() {
  return ({ name }: { name: string }) => (
    <p>Hello, {name}!</p>
  )
}

Let's break this down:

  1. function Greeting() --- the component function. Its name must start with a capital letter (this is how JSX distinguishes components from HTML tags).
  2. return ({ name }) => ... --- the component returns a render function. This inner function receives props (short for "properties") --- the data passed to the component.
  3. <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:

tsx
<Greeting name="World" />

This renders as:

html
<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:

bash
mkdir -p app/ui
tsx
import 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:

tsx
<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:

ts
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:

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>&copy; {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:

tsx
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 })
}
bash
mkdir -p app/utils

The 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:

  • renderToString renders everything to a single string. Simple and easy to understand.
  • renderToStream renders 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:

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

ts
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

tsx
<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:

tsx
<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:

tsx
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>
  )
}
bash
mkdir -p app/controllers
tsx
import 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:

  • about uses BuildAction for a single route. It exports a named handler with a handler property.
  • contact uses Controller for a route group (the form() helper created both index and action). It exports a default object with an actions property 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.

tsx
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:

tsx
<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:

tsx
{/* 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:

ts
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:

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:

bash
npm run dev

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.json

Recap

In this part you learned:

  • JSX is HTML-like syntax for JavaScript, used in .tsx files.
  • Components are functions that return render functions.
  • Props are the data you pass to components, like HTML attributes.
  • children is a special prop for content placed between component tags.
  • RemixNode is the type for any renderable value.
  • renderToString converts JSX to an HTML string.
  • The html template tag provides auto-escaping for template literals.
  • The css() mixin applies inline styles via the mix attribute.
  • Controllers organize handler functions for routes.
  • BuildAction types single-route handlers; Controller types 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.

Continue to Part 4: Database -->

Released under the MIT License.