Summary
Remix is a great framework and very easy to learn. If you have any prior knowledge or experience using React + (Gatsby, Next.js, etc.), you will soon realize that Remix is a very similar framework. This three-part series will teach you how to build a React app using Remix Run and Prisma.
We will go over:
- Part 1: How to create a React App using Remix
- Part 2: Adding extra pages into our Remix project (including nested routes)
- Part 3: Wrapping up and setting cache logic for each page of our Remix project
Throughout the series, we will review core Remix concepts, including loaders, actions, routes, meta, styles, cache, forms, and more.
In this first post, we will cover:
- Setting up a Remix project
- Organizing the root file
- Creating the database using Prisma ORM
- Remix routes
- Structure of routes for this project
- Creating the homepage and React components
What is Remix?
Remix is a full-stack web framework that allows users to focus on the user interface and work back through web fundamentals (such as meta tags, status code, HTTP caching, styles, headers, forms, and more) to deliver a fast, slick, and resilient user experience.
As a developer, you can easily set up your entire application using Remix alone, taking into consideration both the front- and back-end. You can also use specific object-relational mapping (ORM) techniques – such as Prisma, a server-side library that helps your application safely and intuitively read and write data to the database – to improve the structure of your project. An ORM like Prisma can easily integrate with Remix and is the perfect database for building robust and scalable web APIs.
The benefits of Remix
- Server-side rendering (SSR)
SSR is the process of rendering web pages which means that only the requested page will load. It also comes with easy-to-manage styles, search engine optimization (SEO), and features such as headers, caching, and more. - Nested routes
Nested routes are a way of defining routes such that you could nest one page as a partial, inside another! This makes it easier to set custom error boundaries for a piece of your code, making the code cleaner and easy to read. - Loader functions
Instead of using React hooks such as useState() and useEffect(), most of your logic will be placed in what Remix calls "loader" functions. This function is called every time you change params from the URL, loading your component with the new data returned from that function. - TypeScript or JavaScript
Remix supports both programming languages. If your file extension is .tsx, Remix will treat it as TypeScript. However, if you're not a fan of using TypeScript, no problem at all! You can use the extension .js to work using the JavaScript format.
Setting up a new Remix project
Now that you have a high-level overview of what Remix is, it’s time to set up our sample project – we’ll be creating an e-commerce site for toys.
Following the Remix documentation, run npx create-remix@latest. You will then be asked the following questions:
- Where would you like to create your app?
- What type of app do you want to create? (Select Just the basics for this project)
- Where do you want to deploy?
- Do you want to code using TypeScript or JavaScript?
- Do you want to run npm install?
After the Remix package is installed, navigate to the project folder and run the command npm i @emotion/styled – we will use this Emotion library to help us with writing React styled components. Finally, run npm run dev.
Organizing the root file
The root file is one of the most important files for our project, and it’s going to be the parent component of the entire application that we’re building. This file is placed inside the app folder.
I’d suggest changing the code so that it’ll be easier to read and manage later. This will become clearer once we start adding more global components to the project.
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
MetaFunction,
} from "remix";
export const meta: MetaFunction = () => {
return { title: "New Remix App" };
};
export default function App() {
return (
<Document>
<Layout>
<Outlet />
</Layout>
</Document >
);
}
function Document({ children }: any) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<Meta />
<Links />
</head>
<body style={{ margin: '0 2.5vw' }}>
{children}
<ScrollRestoration />
<Scripts />
{process.env.NODE_ENV === "development" && <LiveReload />}
</body>
</html>
)
}
export function Layout({ children }: any) {
return (
/*
It is possible to define the Default Layout here.
In that way, all the pages are going to be in the same format.
Examples of components to be added here: Toolbar/Navbar, Footer and etc...
*/
<>
{children}
</>
)
}
When you reload your project, you will notice that nothing has changed. However, your code is more structured now.
Creating the database using Prisma ORM
In this section, we will work with Prisma and SQLite to create a database. You will also be able to select your favourite way to work with Remix — you can use Firebase, Fauna, a custom PostgreSQL, or a backend API.
We won’t go too in-depth into how Prisma works.
To get started with Prisma, make sure to install the Prisma packages by running the following commands:
npm install --save-dev prisma
npm install @prisma/client
You can then initialize the SQLite database using Prisma:
npx prisma init --datasource-provider sqlite
After running the above commands, Prisma will create a folder in the root of your project, titled prisma. In this folder, it is possible to specify the structure of your database inside the schema.prisma.
Our project is going to have three tables:
- Toy
- Image
- Comment
Consequently, the structure of our schema.prisma file will be:
//prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model Toy {
id Int @id @default(autoincrement())
name String
price Int
images Image[]
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Image {
id Int @id @default(autoincrement())
toy Toy @relation(fields: [toyId], references: [id])
toyId Int
imageSrc String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Comment {
id Int @id @default(autoincrement())
toy Toy @relation(fields: [toyId], references: [id])
toyId Int
comment String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Save and run:
npx prisma db push
Prisma will populate our SQLite with the aforementioned tables.
Before we wrap up this section, create a seed file to populate the database. In the Prisma folder, create a new file named seed.tsx, adding the following seed values:
//prisma/seed.tsx
import { PrismaClient } from '@prisma/client';
const db = new PrismaClient();
async function seed() {
await Promise.all(
getToys().map(async toy => {
await db.toy.create({ data: toy })
})
);
await Promise.all(
getImages().map(async image => {
await db.image.create({ data: image })
})
);
}
seed();
function getToys() {
// Right now we are going to fill this seed file with only one Toy and one Image.
// However, you are able to add more data if you want.
const toys: any[] = [
{
id: 1,
name: 'Baby Yoda',
price: Math.floor(Math.random() * 700),
createdAt: new Date(),
updatedAt: new Date()
}
]
return toys
}
function getImages() {
const images: any[] = [
{
imageSrc: "https://images.unsplash.com/photo-1611250535839-94b88cf88bba?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=736&q=80",
toyId: 1,
createdAt: new Date(),
updatedAt: new Date()
}
]
return images
}
Open the package.json and add the following script:
// package.json
"prisma": {
"seed": "ts-node prisma/seed.tsx"
},
Save it and run:
npm install -D typescript ts-node @types/node
npx prisma db seed
Now, your database is populated with the seed file.
Next, create a new file name db.tsx inside your app folder and input the following code:
// app/db.tsx
import { PrismaClient } from "@prisma/client";
let db: PrismaClient;
declare global {
var __db: PrismaClient | undefined;
}
if (process.env.NODE_ENV === 'production') {
db = new PrismaClient();
db.$connect();
} else {
if (!global.__db) {
global.__db = new PrismaClient();
global.__db.$connect();
}
db = global.__db;
}
export { db }
This file will be used to retrieve data from our tables.
About Remix routes
You can easily create a new route inside Remix. All you need to do is create a new file or folder inside the pages folder. The name of the file or folder will define the slug of your page.
Remix has some existing naming conventions for routes — let’s take a look at each case:
- Routes file starting with any special character: This is a regular route, and the name that you define in your file will be identical to your slug. For example, if your route file is named about-us, and you navigate to /about-us, this component will be called...
- Routes that start with $: This is a route where the URL is a parameter. For example, if your route file is $toyId, the Toy ID can be 1, 2, 3… 10, 11… the list goes on. Files starting with $ will accept anything.
- Routes that start with __: This route is a hidden path and it’s useful if you want to create Remix nested routes.
Below is an example of a nested route from the Remix website — each page here is a block of the whole parent page. It’s similar to nested components. However, instead of nesting components, you are nesting a page within another page.
Image credit: https://remix.run/
Nested routes are useful if you want to avoid some network waterfalls, over-fetching, setting specific cache, handling error boundaries (Note: Don't worry if you're unfamiliar with those terms, we will go more in-depth in the next two posts), and etc. In our demo app, we’ll be adding a simple nested route and dig deeper into it.
- Routes inside a folder file: For example, If you want to create two pages inside the slug /products named /products/something and /products/somethingElse, you’ll need to create a folder inside your pages named products, with the following files inside:
• index : This is going to be your /products page
• something : This is going to be your /products/something page
• somethingElse : This is going to be your /products.somethingElse page
Structuring routes
The structure of the routes for our project will follow the schema below:
.
├── ...
├── app
│ ├── routes
│ │ ├── index.tsx
│ │ ├── __toys.tsx
│ │ ├── __toys
│ │ │ ├── $toyId.tsx
│ │ │ └── $toyId.tsx
│ │ │ └── promo.tsx
... ... ...
- index.tsx: Your landing page using /.
- __toys.tsx: This is a hidden URL and we will pass a template for all the routes that live inside of the folder named __toys. In this template, we will add a simple breadcrumb.
- __toys/: Contains all routes that follow __toys.tsx as a template.
- __toys/$toyId.tsx: Product detail page that uses a numeric identifier of the toy the user requests.
- __toys/$toyId/promo: We will create a simple nested route inside of __toys/$toyId.
Here is a demo of how this nested route will work:
In this example, the /promo page is nested inside the /$toyId. If you create a comment, Remix will redirect you to the page /toyId/promo. This page is the small box on the right side.
Creating the home page and React components
Our home page is going to list some Star Wars toys. The layout will be similar to the following format:
To create the home page, we will first create several React components. The structure of our home page is defined below:
<Toolbar /> // Showing the Navigation Menu
<Cover /> // Showing the Cover of the Page
<ProductGrid /> // Showing a Grid with all of our Products
Create a new folder inside our app and name it shared. Inside this folder, create another folder named components.
.
├── ...
├── app
│ ├── shared
│ │ ├── components
│ │ │ └── Our components will live here
... ... ...
Creating the app components
In this section, we will create three React components to use in our app:
- Toolbar
- Cover header
- Product Grid
(Note: we are not going to cover how to create a React component here)
Toolbar
We’ll keep the toolbar simple – it will only show the logo of this demo app. (Path: app/shared/components/Toolbar/index.tsx)
The code:
// app/shared/components/Toolbar/index.tsx
import { Link } from "remix";
import styled from "@emotion/styled";
const StyledToolbar = styled.div`
max-width: 100vw;
width: 100vw;
background-color: white;
position: fixed;
z-index: 999;
height: 48px;
top: 0;
padding: 1rem;
`;
const StyledToolbarWrapper = styled.div`
display: flex;
flex-direction: row;
align-tems: center;
justify-items: stretch;
justify-content: space - between;
color: #000;
height: 100%;
`;
const StyledToolbarListItems = styled.div`
display: flex;
align-items: center;
gap: 24px;
`;
const StyledToolbarLogo = styled.img`
width: auto;
height: 60px;
border-radius: 10px;
transition: 0.5s;
&:hover {
transform: scale(1.1);
}
`;
const StyledToolbarSpacing = styled.div`
height: 48px;
`;
export default function Toolbar() {
return (
<>
<StyledToolbar>
<StyledToolbarWrapper>
<StyledToolbarListItems>
{/* Link is a Helper from Remix to navigate between pages. Check Remix Docs for more Info */}
<Link to={"/"} prefetch="none">
<StyledToolbarLogo
src={
"https://res.cloudinary.com/rangle/image/upload/q_auto,f_auto/rangle.io/TOR-triangle_868x868_kq1k91.png"
}
alt="logo"
/>
</Link>
</StyledToolbarListItems>
</StyledToolbarWrapper>
</StyledToolbar>
<StyledToolbarSpacing></StyledToolbarSpacing>
</>
);
}
Once the component is created, place it in the root file in the Layout section.
// app/root.tsx
import Toolbar from "./shared/components/Toolbar";
.
.
.
.
export function Layout({ children }: any) {
return (
<>
<Toolbar />
{children}
</>
)
}
Cover header
This is the hero of our page – this component will receive an image and a title as props. (Path: app/shared/components/Cover/index.tsx)
The code:
// app/shared/components/Cover/index.tsx
import styled from "@emotion/styled";
interface IProps {
image: string;
title: string;
}
const StyledCover = styled.div`
position: relative;
height: 60vh;
width: 100%;
max-height: 500px;
`;
const StyledCoverTitle = styled.h1`
position: absolute;
top: 30%;
color: white;
padding-left: 1rem;
`;
export default function Cover({ image, title }: IProps) {
const StyledCoverImage = styled.div`
max-height: inherit;
height: inherit;
background-repeat: no-repeat;
background-size: cover;
background-position: center;
background-image: url(${image});
`;
return (
<StyledCover>
<StyledCoverTitle>{title}</StyledCoverTitle>
<StyledCoverImage></StyledCoverImage>
</StyledCover>
);
}
Product Grid
The code:
//app/shared/ProductGrid/index.tsx
import styled from "@emotion/styled";
import { Link } from "remix";
const StyledProductGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 1rem;
`;
const StyledProductGridItem = styled.div`
height: 400px;
border-radius: 10%;
border: 1px solid #eeeeee;
transition: 0.5s;
background-color: #fff;
padding: 1rem;
transition: 0.5s;
&:hover {
border: #11468f solid 1px;
}
`;
const StyledProductGridItemText = styled.p`
text-align: center;
margin: 0.5rem;
color: #11468f;
`;
const StyledProductGridItemImage = styled.img`
height: 65%;
width: 100%;
object-fit: cover;
border-radius: 10px;
`;
export default function ProductGrid({ toys }: any) {
return (
<StyledProductGrid>
{toys.map((toy: any, index: number) => (
<StyledProductGridItem key={index}>
{toy && toy.images && (
<StyledProductGridItemImage src={toy.images[0].imageSrc} />
)}
<Link to={`/${toy.id}`} prefetch="none">
<StyledProductGridItemText>{toy.name}</StyledProductGridItemText>
</Link>
<StyledProductGridItemText>${toy.price}.00</StyledProductGridItemText>
</StyledProductGridItem>
))}
</StyledProductGrid>
);
}
Now that we've created all the components for our project, we can create the home page.
Creating the home page
Place the following code in the home page (app/routes/index.tsx)
//app/routes/index.tsx
import Cover from "~/shared/components/Cover";
import ProductGrid from "~/shared/components/ProductGrid";
import styled from "@emotion/styled";
const StyledHomeProductContainer = styled.div`
margin: 1rem 2rem;
`;
export default function Index() {
return (
<>
<Cover
title={"Star Wars Toys"}
image={
"https://images.unsplash.com/photo-1608983765214-3fb32be57d29?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2069&q=80"
}
/>
<StyledHomeProductContainer>
<ProductGrid toys={[]} />
</StyledHomeProductContainer>
</>
);
}
For now, the ProductGrid component doesn’t show any products.
Fetching toys from Prisma
The home page is now ready, however, it is not currently loading any toys.
Remix has a function called loader, and this function will be used to load data from the server.
This function will be called every time you navigate to this page or when the params of your route have changed.
(Note: Only files inside the routes folder can have a loader function. If you place this function in another folder, it will not be triggered.)
The loader function:
import { json, LoaderFunction } from "remix";
export const loader: LoaderFunction = async () => {
/* Add any backend logic here. In our case, we will fetch the data
* from Prisma. However, you are free to add any logic that you want
* here
*/
const data = await db.toy.findMany({
include: {
images: true,
},
});
return json(data);
}
Set this function inside of your home page folder, outside of your component function.
//app/routes/index.tsx
import { db } from "~/db";
import { json, LoaderFunction } from "remix";
import Cover from "~/shared/components/cover";
import ProductGrid from "~/shared/components/ProductGrid";
import styled from "@emotion/styled";
export const loader: LoaderFunction = async () => {
const data = await db.toy.findMany({
include: {
images: true,
},
});
return json(data);
};
const StyledHomeProductContainer = styled.div`
margin: 1rem 2rem;
`;
export default function Index() {
return (
<>
<Cover
title={"Star Wars Toys"}
image={
"https://images.unsplash.com/photo-1608983765214-3fb32be57d29?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2069&q=80"
}
/>
<StyledHomeProductContainer>
<ProductGrid toys={[]} />
</StyledHomeProductContainer>
</>
);
}
As soon as you hit the page with route /, the loader function will be triggered. Consequently, we are calling the Prisma database to retrieve all the toys.
You may be wondering, “Where is the loader function returning the data? We just have a loader function outside the component, how are we going to return the data into our component?”
The solution is simple. The hook, useLoaderData(), constantly checks whether the database returned data.
Let’s set this hook inside our component and see what happens.
The code:
//app/routes/index.tsx
import { db } from "~/db";
import { json, LoaderFunction, useLoaderData } from "remix";
import Cover from "~/shared/components/Cover";
import ProductGrid from "~/shared/components/ProductGrid";
import styled from "@emotion/styled";
export const loader: LoaderFunction = async () => {
const data = await db.toy.findMany({
include: {
images: true,
},
});
return json(data);
};
const StyledHomeProductContainer = styled.div`
margin: 1rem 2rem;
`;
export default function Index() {
const data = useLoaderData();
return (
<>
<Cover
title={"Star Wars Toys"}
image={
"https://images.unsplash.com/photo-1608983765214-3fb32be57d29?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2069&q=80"
}
/>
<StyledHomeProductContainer>
<ProductGrid toys={data} />
</StyledHomeProductContainer>
</>
);
}
The result:
Conclusion
In this blog post, we covered the basics of Remix concepts. We learned how to organize the root file, set up a database using Prisma, fetch data from the server using the Remix loader function, and reviewed some React concepts.
Remix is easy and intuitive to learn. Compared to other frameworks, Remix brings us an excellent and elegant way to set up your own logic for meta tags, styles, caching and errors for each page, making your application even faster.
In the next two posts, we will dive even deeper into Remix concepts, covering:
- Caching data using http (stale-while-revalidate
- Caching using REDIS
- Remix forms
- Catch Boundaries and Error Boundaries
- Nested Routes
You can find the GitHub link to the code and tutorial for this project here.
You can read Part 2 of our tutorial series here.