Skip to content

1. Project Setup

In this first part of the tutorial, you will create a brand-new project, install Remix and its supporting tools, configure TypeScript, and start a development server that responds to requests in your browser.

By the end of this section, you will have a running web server that displays "Welcome to the Bookstore" when you visit http://localhost:3000.

Create the Project Directory

Open your terminal and create a new directory for the bookstore project:

bash
mkdir bookstore
cd bookstore

Initialize it as a Node.js project. The npm init -y command creates a package.json file, which is the manifest that tracks your project's name, scripts, and dependencies:

bash
npm init -y

Open the generated package.json and update it to use ES modules. ES modules are the modern JavaScript module system that uses import and export (as opposed to the older require() syntax). Add the "type": "module" field:

json
{
  "name": "bookstore",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "NODE_ENV=development tsx watch server.ts"
  }
}

Let's break down what each field does:

  • "name" --- the name of your project. It can be anything.
  • "private": true --- prevents you from accidentally publishing this project to npm (the public package registry).
  • "type": "module" --- tells Node.js to treat .js files as ES modules, so import/export syntax works.
  • "scripts" --- defines shortcut commands. The "dev" script runs your server in development mode. We will use it shortly.

What is package.json?

Every Node.js project has a package.json file at its root. It lists the packages (libraries) your project depends on, defines scripts you can run, and holds metadata like the project name and version. Think of it as a table of contents for your project.

Install Dependencies

Now install the packages your project needs. There are two kinds of dependencies:

  • Dependencies --- packages your application needs to run (like Remix itself).
  • Dev dependencies --- packages you only need during development (like TypeScript tools).

Install Remix:

bash
npm install remix@next

Install the development tools:

bash
npm install -D tsx @types/node esbuild

Here is what each package does:

PackagePurpose
remix@nextThe Remix framework. The @next tag installs the V3 alpha.
tsxRuns TypeScript files directly, without a separate compile step.
@types/nodeTypeScript type definitions for Node.js built-in modules like http and fs.
esbuildA fast JavaScript bundler. You will use it later to bundle browser-side code.

What does -D mean?

The -D flag (short for --save-dev) installs packages as dev dependencies. These are tools you need while building your app, but not when running it in production. In package.json, they appear under "devDependencies" instead of "dependencies".

Configure TypeScript

Create a tsconfig.json file in the root of your project. This file tells TypeScript how to check and compile your code:

json
{
  "compilerOptions": {
    "strict": true,
    "lib": ["ES2024", "DOM", "DOM.Iterable"],
    "module": "ES2022",
    "moduleResolution": "Bundler",
    "target": "ESNext",
    "allowImportingTsExtensions": true,
    "rewriteRelativeImportExtensions": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true,
    "jsx": "react-jsx",
    "jsxImportSource": "remix/component",
    "preserveSymlinks": true
  }
}

Here is what the important settings do:

  • strict --- enables all of TypeScript's strictness checks. This catches more bugs but requires you to be more precise with types.
  • lib --- tells TypeScript which built-in APIs are available. ES2024 gives modern JavaScript features. DOM and DOM.Iterable give web APIs like Request, Response, and Headers.
  • module and moduleResolution --- configure how import statements are resolved. The Bundler strategy works with modern build tools.
  • allowImportingTsExtensions --- lets you write import './foo.ts' with the .ts extension.
  • jsx and jsxImportSource --- configure JSX to use Remix's built-in component system. JSX is a syntax that lets you write HTML-like code inside JavaScript --- you will learn more about it in Part 3.
  • preserveSymlinks --- ensures that symlinked packages (common in monorepos) are resolved correctly.

What is TypeScript?

TypeScript is JavaScript with type annotations. Types describe the shape of your data --- for example, "this variable is a string" or "this function takes a number and returns a boolean." Your editor uses these types to give you autocomplete and catch mistakes before you run your code. You do not need to be a TypeScript expert; the types work quietly in the background.

Create the Server

Your Remix application is a web server --- a program that listens for HTTP requests from browsers and sends back responses. Let's create one.

Create a file called server.ts in the root of your project:

ts
import * as http from 'node:http'
import { createRequestListener } from 'remix/node-fetch-server'

import { router } from './app/router.ts'

let server = http.createServer(
  createRequestListener(async (request) => {
    try {
      return await router.fetch(request)
    } catch (error) {
      console.error(error)
      return new Response('Internal Server Error', { status: 500 })
    }
  }),
)

let port = 3000

server.listen(port, () => {
  console.log(`Bookstore is running on http://localhost:${port}`)
})

Let's walk through every part of this file:

Line by Line

ts
import * as http from 'node:http'

This imports Node.js's built-in http module. The node: prefix is the standard way to import Node.js built-in modules. The http module gives you the ability to create a web server that listens for incoming requests.

ts
import { createRequestListener } from 'remix/node-fetch-server'

This imports a Remix helper that bridges two worlds. Node.js has its own way of handling HTTP requests (using req and res objects), but Remix uses the Fetch API --- the same Request and Response objects that browsers use. createRequestListener translates between the two, so you can write standard web code that runs on Node.js.

ts
import { router } from './app/router.ts'

This imports the router you will create next. The router is the core of your application --- it decides which code to run based on the URL of each incoming request.

ts
let server = http.createServer(
  createRequestListener(async (request) => {
    try {
      return await router.fetch(request)
    } catch (error) {
      console.error(error)
      return new Response('Internal Server Error', { status: 500 })
    }
  }),
)

This creates the HTTP server. For every incoming request:

  1. createRequestListener converts the Node.js request into a standard Request object.
  2. That Request is passed to router.fetch(), which finds the right handler and runs it.
  3. The handler returns a Response, which gets sent back to the browser.
  4. If anything goes wrong, the catch block logs the error and returns a generic "Internal Server Error" response.
ts
let port = 3000

server.listen(port, () => {
  console.log(`Bookstore is running on http://localhost:${port}`)
})

This starts the server on port 3000. A port is a number that identifies a specific program on your computer. When you visit http://localhost:3000 in your browser, it connects to port 3000, where your bookstore server is listening.

Define the Routes

Routes tell your application which URLs it should respond to. Create a directory called app and a file called routes.ts inside it:

bash
mkdir app
ts
import { route } from 'remix/fetch-router/routes'

export const routes = route({
  home: '/',
})

This defines a single route: when someone visits / (the home page), the router will look for a handler mapped to routes.home. You will add many more routes in Part 2.

What is a route?

A route is a mapping between a URL pattern and the code that should run when someone visits that URL. For example, the route '/' matches the home page, /about would match the about page, and /books/:slug would match any URL like /books/my-book or /books/great-expectations. Routes are the backbone of any web application.

Create the Router

The router takes your route definitions and connects them to handlers --- the functions that actually process requests and return responses. Create app/router.ts:

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'

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, {
  handler() {
    return new Response(
      `<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Bookstore</title>
  </head>
  <body>
    <h1>Welcome to the Bookstore</h1>
    <p>Your bookstore is up and running.</p>
  </body>
</html>`,
      {
        headers: { 'Content-Type': 'text/html; charset=UTF-8' },
      },
    )
  },
})

Let's break this down:

createRouter

ts
import { createRouter } from 'remix/fetch-router'

createRouter creates a new router instance. The router is the central piece that receives every incoming request, checks it against your defined routes, runs any middleware, and calls the appropriate handler.

Middleware

ts
let middleware = []

if (process.env.NODE_ENV === 'development') {
  middleware.push(logger())
}

middleware.push(compression())
middleware.push(staticFiles('./public'))

Middleware are small functions that run on every request, before your route handler. They can inspect or modify the request, add extra data to the response, or even respond directly. Think of them as a pipeline that each request flows through.

Here we use three middleware:

MiddlewareWhat it does
logger()Prints every request to the terminal (method, URL, status code, timing). Only enabled in development so your production logs stay clean.
compression()Compresses response bodies with gzip or brotli, making pages load faster.
staticFiles('./public')Serves files from the public directory (images, CSS, JavaScript) without needing a route for each one.

Mapping Routes to Handlers

ts
router.map(routes.home, {
  handler() {
    return new Response(/* ... */)
  },
})

router.map() connects a route to a handler. The handler is a function that receives the request and returns a Response. Here, we return a simple HTML page.

The Response constructor takes two arguments:

  1. The body --- the content to send back. Here it is an HTML string.
  2. An options object --- we set the Content-Type header to text/html so the browser knows to render it as a web page (rather than displaying the raw text).

Create the Public Directory

The staticFiles middleware looks for files in a public directory. Create it now:

bash
mkdir public

You will put CSS files, images, and other static assets here later. For now, it just needs to exist so the middleware does not throw an error.

Run the Development Server

Everything is in place. Start the server:

bash
npm run dev

You should see output like this:

Bookstore is running on http://localhost:3000

Open your browser and visit http://localhost:3000. You should see a page that says "Welcome to the Bookstore".

What does tsx watch do?

The dev script runs tsx watch server.ts. The watch flag tells tsx to monitor your files for changes. When you edit and save a file, tsx automatically restarts the server so you can see your changes immediately without manually stopping and restarting.

Your Project So Far

Here is the complete file structure at this point:

bookstore/
  app/
    router.ts        # Creates the router, maps routes to handlers
    routes.ts        # Defines URL patterns
  public/            # Static files (empty for now)
  package.json       # Project manifest and dependencies
  server.ts          # HTTP server entry point
  tsconfig.json      # TypeScript configuration

And the flow of a request through your application:

  1. A browser sends an HTTP request to http://localhost:3000/.
  2. Node.js's http server receives it and passes it to createRequestListener.
  3. createRequestListener converts it to a standard Request object.
  4. router.fetch(request) takes over:
    • The logger middleware logs the request to the terminal.
    • The compression middleware prepares to compress the response.
    • The staticFiles middleware checks if the URL matches a file in public/. It does not, so it passes the request along.
    • The router matches / to routes.home and calls the handler.
  5. The handler returns a Response with HTML.
  6. The compression middleware compresses the HTML.
  7. The response flows back to the browser, which renders the page.

What is Next

Your server works, but it only has one page that returns raw HTML. In the next part, you will learn Remix's routing system in depth --- defining multiple routes, using dynamic URL segments, and organizing your routes for a full bookstore application.

Continue to Part 2: Routing -->

Released under the MIT License.