You want to learn Remix? You're in the right place. Let's build Remix Jokes!
This tutorial is the comprehensive way to getting an overview of the primary APIs available in Remix. By the end, you'll have a full application you can show your mom, significant other, or dog, and I'm sure they'll be just as excited about Remix as you are (though I make no guarantees).
We're going to be laser focused on Remix. This means that we're going to skip over a few things that are a distraction from the core ideas we want you to learn about Remix. For example, we'll show you how to get a CSS stylesheet on the page, but we're not going to make you write the styles by yourself. So we'll just give you stuff you can copy/paste for that kind of thing. However, if you'd prefer to write it all out yourself, you totally can (it'll just take you much longer). So we'll put it in little <details>
elements you have to click to expand to not spoil anything if you'd prefer to code it out yourself.
There are several areas in the tutorial where we stick code behind one of these <details>
elements. This is so you can choose how much copy/paste you want to do without us spoiling it for you. We don't recommend struggling with concepts unrelated to Remix though, like guessing what class names to use. Feel free to reference these sections to check your work once you get the main point of the tutorial. Or if you want to run through things quickly then you can just copy/paste stuff as you go as well. We won't judge you!
We'll be linking to various docs (Remix docs as well as web docs on MDN) throughout the tutorial (if you don't already use MDN, you'll find yourself using it a lot with Remix, and getting better at the web while you're at it). If you're ever stuck, make sure you check into any docs links you may have skipped. Part of the goal of this tutorial is to get you acclimated to the Remix and web API documentation, so if something's explained in the docs, then you'll be linked to those instead of rehashing it all out in here.
This tutorial will be using TypeScript. Feel free to follow along and skip/remove the TypeScript bits. We find that Remix is made even better when you're using TypeScript, especially since we'll also be using Prisma to access our data models from the SQLite database.
useState
until we get to that step.
Here are the topics we'll be covering in this tutorial:
sqlite
and prisma
)You'll find links to the sections of the tutorial in the navbar (top of the page for mobile and to the right for desktop).
You can follow along with this tutorial on CodeSandbox (a fantastic in-browser editor) or locally on your own computer. If you use the CodeSandbox approach then all you need is a good internet connection and a modern browser. If you run things locally then you're going to need some things installed:
If you'd like to follow along with the deployment step at the end, you'll also want an account on Fly.io.
We'll also be executing commands in your system command line/terminal interface. So you'll want to be familiar with that.
Some experience with React and TypeScript/JavaScript is assumed. If you'd like to review your knowledge, check out these resources:
And having a good understanding of the HTTP API is also helpful, but not totally required.
With that, I think we're ready to get started!
If you're planning on using CodeSandbox, you can use the Basic example to get started.
πΏ Open your terminal and run this command:
npx create-remix@latest
This may ask you whether you want to install create-remix@latest
. Enter y
. It will only be installed the first time to run the setup script.
Once the setup script has run, it'll ask you a few questions. We'll call our app "remix-jokes", select to initialize a Git repository and have it run the installation for us:
Where should we create your new project?
remix-jokes
Initialize a new git repository?
Yes
Install dependencies with npm?
Yes
Remix can be deployed in a large and growing list of JavaScript environments. The "Remix App Server" is a full-featured Node.js server based on Express. It's the simplest option, and it satisfies most people's needs, so that's what we're going with for this tutorial. Feel free to experiment in the future!
Once the npm install
has completed, we'll change into the remix-jokes
directory:
πΏ Run this command
cd remix-jokes
Now you're in the remix-jokes
directory. All other commands you run from here on out will be in that directory.
πΏ Great, now open that up in your favorite editor and let's explore the project structure a bit.
Here's the tree structure. Hopefully what you've got looks a bit like this:
remix-jokes
βββ README.md
βββ app
β βββ entry.client.tsx
β βββ entry.server.tsx
β βββ root.tsx
β βββ routes
β βββ _index.tsx
βββ package-lock.json
βββ package.json
βββ public
β βββ favicon.ico
βββ remix.config.js
βββ remix.env.d.ts
βββ tsconfig.json
Let's talk briefly about a few of these files:
app/
- This is where all your Remix app code goesapp/entry.client.tsx
- This is the first bit of your JavaScript that will run when the app loads in the browser. We use this file to hydrate our React components.app/entry.server.tsx
- This is the first bit of your JavaScript that will run when a request hits your server. Remix handles loading all the necessary data, and you're responsible for sending back the response. We'll use this file to render our React app to a string/stream and send that as our response to the client.app/root.tsx
- This is where we put the root component for our application. You render the <html>
element here.app/routes/
- This is where all your "route modules" will go. Remix uses the files in this directory to create the URL routes for your app based on the name of the files.public/
- This is where your static assets go (images/fonts/etc.)remix.config.js
- Remix has a handful of configuration options you can set in this file.πΏ Let's go ahead and run the build:
npm run build
That should output something like this:
Building Remix app in production mode...
Built in 132ms
Now you should also have a .cache/
directory (something used internally by Remix), a build/
directory, and a public/build
directory. The build/
directory is our server-side code. The public/build/
holds all our client-side code. These three directories are listed in your .gitignore
file, so you don't commit the generated files to source control.
πΏ Let's run the built app now:
npm start
This will start the server and output this:
Remix App Server started at http://localhost:3000
Open up that URL and you should be presented with a minimal page pointing to some docs.
πΏ Now stop the server and delete this directory:
app/routes
We're going to trim this down the bare bones and introduce things incrementally.
πΏ Replace the contents of app/root.tsx
with this:
import { LiveReload } from "@remix-run/react";
export default function App() {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
<title>Remix: So great, it's funny!</title>
</head>
<body>
Hello world
<LiveReload />
</body>
</html>
);
}
The <LiveReload />
component is useful during development to auto-refresh our browser whenever we make a change. Because our build server is so fast, the reload will often happen before you even notice β‘
Your app/
directory should now look like this:
app
βββ entry.client.tsx
βββ entry.server.tsx
βββ root.tsx
πΏ With that set up, go ahead and start the dev server up with this command:
npm run dev
Open http://localhost:3000 and the app should greet the world:
Great, now we're ready to start adding stuff back.
The first thing we want to do is get our routing structure set up. Here are all the routes our app is going to have:
/
/jokes
/jokes/:jokeId
/jokes/new
/login
You can programmatically create routes via the remix.config.js
, but the more common way to create the routes is through the file system. This is called "file-based routing."
Each file we put in the app/routes
directory is called a Route Module and by following the route filename convention, we can create the routing URL structure we're looking for. Remix uses React Router under the hood to handle this routing.
πΏ Let's start with the index route (/
). To do that, create a file at app/routes/_index.tsx
and export default
a component from that route module. For now, you can have it just say "Hello Index Route" or something.
export default function IndexRoute() {
return <div>Hello Index Route</div>;
}
React Router supports "nested routing" which means we have parent-child relationships in our routes. The app/routes/_index.tsx
is a child of the app/root.tsx
route. In nested routing, parents are responsible for laying out their children.
πΏ Update the app/root.tsx
to position children. You'll do this with the <Outlet />
component from @remix-run/react
:
import { LiveReload, Outlet } from "@remix-run/react";
export default function App() {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
<title>Remix: So great, it's funny!</title>
</head>
<body>
<Outlet />
<LiveReload />
</body>
</html>
);
}
npm run dev
That will watch your filesystem for changes, rebuild the site, and thanks to the <LiveReload />
component your browser will refresh.
πΏ Go ahead and open up the site again, and you should be presented with the greeting from the index route.
Great! Next let's handle the /jokes
route.
πΏ Create a new route at app/routes/jokes.tsx
(keep in mind that this will be a parent route, so you'll want to use <Outlet />
again).
import { Outlet } from "@remix-run/react";
export default function JokesRoute() {
return (
<div>
<h1>Jπ€ͺKES</h1>
<main>
<Outlet />
</main>
</div>
);
}
You should be presented with that component when you go to /jokes
. Now, in that <Outlet />
we want to render out some random jokes in the "index route".
πΏ Create a route at app/routes/jokes._index.tsx
export default function JokesIndexRoute() {
return (
<div>
<p>Here's a random joke:</p>
<p>
I was wondering why the frisbee was getting bigger,
then it hit me.
</p>
</div>
);
}
Now if you refresh /jokes
, you'll get the content in the app/routes/jokes.tsx
as well as the app/routes/jokes._index.tsx
. Here's what mine looks like:
And notice that each of those route modules is only concerned with their part of the URL. Neat right!? Nested routing is pretty nice, and we're only just getting started. Let's keep going.
πΏ Next, let's handle the /jokes/new
route. I'll bet you can figure out how to do that π. Remember we're going to allow people to create jokes on this page, so you'll want to render a form
with name
and content
fields.
export default function NewJokeRoute() {
return (
<div>
<p>Add your own hilarious joke</p>
<form method="post">
<div>
<label>
Name: <input type="text" name="name" />
</label>
</div>
<div>
<label>
Content: <textarea name="content" />
</label>
</div>
<div>
<button type="submit" className="button">
Add
</button>
</div>
</form>
</div>
);
}
Great, so now going to /jokes/new
should display your form:
Soon we'll add a database that stores our jokes by an ID, so let's add one more route that's a little more unique, a parameterized route:
/jokes/$jokeId
Here the parameter $jokeId
can be anything, and we can look up that part of the URL in the database to display the right joke. To make a parameterized route, we use the $
character in the filename. (Read more about the convention here).
πΏ Create a new route at app/routes/jokes.$jokeId.tsx
. Don't worry too much about what it displays for now (we don't have a database set up yet!):
export default function JokeRoute() {
return (
<div>
<p>Here's your hilarious joke:</p>
<p>
Why don't you find hippopotamuses hiding in trees?
They're really good at it.
</p>
</div>
);
}
Great, so now going to /jokes/anything-you-want
should display what you just created (in addition to the parent routes):
Great! We've got our primary routes all set up!
From the beginning of styling on the web, to get CSS on the page, we've used <link rel="stylesheet" href="/path-to-file.css" />
. This is how you style your Remix applications as well, but Remix makes it much easier than just throwing link
tags all over the place. Remix brings the power of its Nested Routing support to CSS and allows you to associate link
tags to routes. When the route is active, the link
tag is on the page and the CSS applies. When the route is not active (the user navigates away), the link
tag is removed and the CSS no longer applies.
You do this by exporting a links
function in your route module. Let's get the homepage styled. You can put your CSS files anywhere you like within the app
directory. We'll put ours in app/styles/
.
We'll start off by just styling the home page (the index route /
).
πΏ Create app/styles/index.css
and stick this CSS in it:
body {
color: hsl(0, 0%, 100%);
background-image: radial-gradient(
circle,
rgba(152, 11, 238, 1) 0%,
rgba(118, 15, 181, 1) 35%,
rgba(58, 13, 85, 1) 100%
);
}
πΏ Now update app/routes/_index.tsx
to import that css file. Then add a links
export (as described in the documentation) to add that link to the page.
import type { LinksFunction } from "@remix-run/node";
import stylesUrl from "~/styles/index.css";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: stylesUrl },
];
export default function IndexRoute() {
return <div>Hello Index Route</div>;
}
Now if you go to /
you may be a bit disappointed. Our beautiful styles aren't applied! Well, you may recall that in the app/root.tsx
we're the ones rendering everything about our app. From the <html>
to the </html>
. That means if something doesn't show up in there, it's not going to show up at all!
So we need some way to get the link
exports from all active routes and add <link />
tags for all of them. Luckily, Remix makes this easy for us by providing a convenience <Links />
component.
πΏ Go ahead and add the Remix <Links />
component to app/root.tsx
within the <head>
.
import {
Links,
LiveReload,
Outlet,
} from "@remix-run/react";
export default function App() {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
<title>Remix: So great, it's funny!</title>
<Links />
</head>
<body>
<Outlet />
<LiveReload />
</body>
</html>
);
}
Great, now check /
again, and it should be nice and styled for you:
Hooray! But I want to call out something important and exciting. You know how the CSS we wrote styles the body
element? What would you expect to happen on the /jokes
route? Go ahead and check it out.
π€― What is this? Why aren't the CSS rules applied? Did the body
get removed or something?! Nope. If you open the Elements tab of the dev tools you'll notice that the link tag isn't there at all!
This means that you don't have to worry about unexpected CSS clashes when you're writing your CSS. You can write whatever you like and so long as you check each route your file is linked on you'll know that you haven't impacted other pages! π₯
This also means your CSS files can be cached long-term and your CSS is naturally code-split. Performance FTW β‘
That's pretty much all there is to it for styling with the tutorial. The rest is just writing the CSS which you're welcome to do if you want, or simply copy the styles from below.
@font-face {
font-family: "baloo";
src: url("/fonts/baloo/baloo.woff") format("woff");
font-weight: normal;
font-style: normal;
}
:root {
--hs-links: 48 100%;
--color-foreground: hsl(0, 0%, 100%);
--color-background: hsl(278, 73%, 19%);
--color-links: hsl(var(--hs-links) 50%);
--color-links-hover: hsl(var(--hs-links) 45%);
--color-border: hsl(277, 85%, 38%);
--color-invalid: hsl(356, 100%, 71%);
--gradient-background: radial-gradient(
circle,
rgba(152, 11, 238, 1) 0%,
rgba(118, 15, 181, 1) 35%,
rgba(58, 13, 85, 1) 100%
);
--font-body: -apple-system, "Segoe UI", Helvetica Neue, Helvetica,
Roboto, Arial, sans-serif, system-ui, "Apple Color Emoji",
"Segoe UI Emoji";
--font-display: baloo, var(--font-body);
}
html {
box-sizing: border-box;
}
*,
*::before,
*::after {
box-sizing: inherit;
}
:-moz-focusring {
outline: auto;
}
:focus {
outline: var(--color-links) solid 2px;
outline-offset: 2px;
}
html,
body {
padding: 0;
margin: 0;
color: var(--color-foreground);
background-color: var(--color-background);
}
[data-light] {
--color-invalid: hsl(356, 70%, 39%);
color: var(--color-background);
background-color: var(--color-foreground);
}
body {
font-family: var(--font-body);
line-height: 1.5;
background-repeat: no-repeat;
min-height: 100vh;
min-height: calc(100vh - env(safe-area-inset-bottom));
}
a {
color: var(--color-links);
text-decoration: none;
}
a:hover {
color: var(--color-links-hover);
text-decoration: underline;
}
hr {
display: block;
height: 1px;
border: 0;
background-color: var(--color-border);
margin-top: 2rem;
margin-bottom: 2rem;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-display);
margin: 0;
}
h1 {
font-size: 2.25rem;
line-height: 2.5rem;
}
h2 {
font-size: 1.5rem;
line-height: 2rem;
}
h3 {
font-size: 1.25rem;
line-height: 1.75rem;
}
h4 {
font-size: 1.125rem;
line-height: 1.75rem;
}
h5,
h6 {
font-size: 0.875rem;
line-height: 1.25rem;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.container {
--gutter: 16px;
width: 1024px;
max-width: calc(100% - var(--gutter) * 2);
margin-right: auto;
margin-left: auto;
}
/* buttons */
.button {
--shadow-color: hsl(var(--hs-links) 30%);
--shadow-size: 3px;
-webkit-appearance: none;
-moz-appearance: none;
cursor: pointer;
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
background-color: var(--color-links);
color: var(--color-background);
font-family: var(--font-display);
font-weight: bold;
line-height: 1;
font-size: 1.125rem;
margin: 0;
padding: 0.625em 1em;
border: 0;
border-radius: 4px;
box-shadow: 0 var(--shadow-size) 0 0 var(--shadow-color);
outline-offset: 2px;
transform: translateY(0);
transition: background-color 50ms ease-out, box-shadow
50ms ease-out,
transform 100ms cubic-bezier(0.3, 0.6, 0.8, 1.25);
}
.button:hover {
--raise: 1px;
color: var(--color-background);
text-decoration: none;
box-shadow: 0 calc(var(--shadow-size) + var(--raise)) 0 0 var(
--shadow-color
);
transform: translateY(calc(var(--raise) * -1));
}
.button:active {
--press: 1px;
box-shadow: 0 calc(var(--shadow-size) - var(--press)) 0 0 var(
--shadow-color
);
transform: translateY(var(--press));
background-color: var(--color-links-hover);
}
.button[disabled],
.button[aria-disabled="true"] {
transform: translateY(0);
pointer-events: none;
opacity: 0.7;
}
.button:focus:not(:focus-visible) {
outline: none;
}
/* forms */
form {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
}
fieldset {
margin: 0;
padding: 0;
border: 0;
}
legend {
display: block;
max-width: 100%;
margin-bottom: 0.5rem;
color: inherit;
white-space: normal;
}
[type="text"],
[type="password"],
[type="date"],
[type="datetime"],
[type="datetime-local"],
[type="month"],
[type="week"],
[type="email"],
[type="number"],
[type="search"],
[type="tel"],
[type="time"],
[type="url"],
[type="color"],
textarea {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
display: block;
display: flex;
align-items: center;
width: 100%;
height: 2.5rem;
margin: 0;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background-color: hsl(0 0% 100% / 10%);
background-blend-mode: luminosity;
box-shadow: none;
font-family: var(--font-body);
font-size: 1rem;
font-weight: normal;
line-height: 1.5;
color: var(--color-foreground);
transition: box-shadow 200ms, border-color 50ms ease-out,
background-color 50ms ease-out, color 50ms ease-out;
}
[data-light] [type="text"],
[data-light] [type="password"],
[data-light] [type="date"],
[data-light] [type="datetime"],
[data-light] [type="datetime-local"],
[data-light] [type="month"],
[data-light] [type="week"],
[data-light] [type="email"],
[data-light] [type="number"],
[data-light] [type="search"],
[data-light] [type="tel"],
[data-light] [type="time"],
[data-light] [type="url"],
[data-light] [type="color"],
[data-light] textarea {
color: var(--color-background);
background-color: hsl(0 0% 0% / 10%);
}
[type="text"][aria-invalid="true"],
[type="password"][aria-invalid="true"],
[type="date"][aria-invalid="true"],
[type="datetime"][aria-invalid="true"],
[type="datetime-local"][aria-invalid="true"],
[type="month"][aria-invalid="true"],
[type="week"][aria-invalid="true"],
[type="email"][aria-invalid="true"],
[type="number"][aria-invalid="true"],
[type="search"][aria-invalid="true"],
[type="tel"][aria-invalid="true"],
[type="time"][aria-invalid="true"],
[type="url"][aria-invalid="true"],
[type="color"][aria-invalid="true"],
textarea[aria-invalid="true"] {
border-color: var(--color-invalid);
}
textarea {
display: block;
min-height: 50px;
max-width: 100%;
}
textarea[rows] {
height: auto;
}
input:disabled,
input[readonly],
textarea:disabled,
textarea[readonly] {
opacity: 0.7;
cursor: not-allowed;
}
[type="file"],
[type="checkbox"],
[type="radio"] {
margin: 0;
}
[type="file"] {
width: 100%;
}
label {
margin: 0;
}
[type="checkbox"] + label,
[type="radio"] + label {
margin-left: 0.5rem;
}
label > [type="checkbox"],
label > [type="radio"] {
margin-right: 0.5rem;
}
::placeholder {
color: hsl(0 0% 100% / 65%);
}
.form-validation-error {
margin: 0;
margin-top: 0.25em;
color: var(--color-invalid);
font-size: 0.8rem;
}
.error-container {
background-color: hsla(356, 77%, 59%, 0.747);
border-radius: 0.25rem;
padding: 0.5rem 1rem;
}
h1 {
font-size: 3.75rem;
line-height: 1;
}
h2 {
font-size: 1.875rem;
line-height: 2.25rem;
}
h3 {
font-size: 1.5rem;
line-height: 2rem;
}
h4 {
font-size: 1.25rem;
line-height: 1.75rem;
}
h5 {
font-size: 1.125rem;
line-height: 1.75rem;
}
h1 {
font-size: 3rem;
line-height: 1;
}
h2 {
font-size: 2.25rem;
line-height: 2.5rem;
}
h3 {
font-size: 1.25rem;
line-height: 1.75rem;
}
h4 {
font-size: 1.125rem;
line-height: 1.75rem;
}
h5,
h6 {
font-size: 1rem;
line-height: 1.5rem;
}
.container {
--gutter: 40px;
}
/*
* when the user visits this page, this style will apply, when they leave, it
* will get unloaded, so don't worry so much about conflicting styles between
* pages!
*/
body {
background-image: var(--gradient-background);
}
.container {
min-height: inherit;
}
.container,
.content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.content {
padding-top: 3rem;
padding-bottom: 3rem;
}
h1 {
margin: 0;
text-shadow: 0 3px 0 rgba(0, 0, 0, 0.75);
text-align: center;
line-height: 0.5;
}
h1 span {
display: block;
font-size: 4.5rem;
line-height: 1;
text-transform: uppercase;
text-shadow: 0 0.2em 0.5em rgba(0, 0, 0, 0.5), 0 5px 0
rgba(0, 0, 0, 0.75);
}
nav ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
gap: 1rem;
font-family: var(--font-display);
font-size: 1.125rem;
line-height: 1;
}
nav ul a:hover {
text-decoration-style: wavy;
text-decoration-thickness: 1px;
}
@media print, (min-width: 640px) {
h1 span {
font-size: 6rem;
}
nav ul {
font-size: 1.25rem;
gap: 1.5rem;
}
}
@media screen and (min-width: 1024px) {
h1 span {
font-size: 8rem;
}
}
.jokes-layout {
display: flex;
flex-direction: column;
min-height: inherit;
}
.jokes-header {
padding-top: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--color-border);
}
.jokes-header .container {
display: flex;
justify-content: space-between;
align-items: center;
}
.jokes-header .home-link {
font-family: var(--font-display);
font-size: 3rem;
}
.jokes-header .home-link a {
color: var(--color-foreground);
}
.jokes-header .home-link a:hover {
text-decoration: none;
}
.jokes-header .logo-medium {
display: none;
}
.jokes-header a:hover {
text-decoration-style: wavy;
text-decoration-thickness: 1px;
}
.jokes-header .user-info {
display: flex;
gap: 1rem;
align-items: center;
white-space: nowrap;
}
.jokes-main {
padding-top: 2rem;
padding-bottom: 2rem;
flex: 1 1 100%;
}
.jokes-main .container {
display: flex;
gap: 1rem;
}
.jokes-list {
max-width: 12rem;
}
.jokes-outlet {
flex: 1;
}
.jokes-footer {
padding-top: 2rem;
padding-bottom: 1rem;
border-top: 1px solid var(--color-border);
}
@media print, (min-width: 640px) {
.jokes-header .logo {
display: none;
}
.jokes-header .logo-medium {
display: block;
}
.jokes-main {
padding-top: 3rem;
padding-bottom: 3rem;
}
}
@media (max-width: 639px) {
.jokes-main .container {
flex-direction: column;
}
}
πΏ Also, download the font and its license and put them in public/fonts/baloo
.
πΏ While you're downloading assets, you may as well download the social image and put that at public/social.png
. You'll need that later.
πΏ Add the links
export to app/root.tsx
and app/routes/jokes.tsx
to bring in some CSS to make the page look nice (note: each will have its own CSS file(s)). You can look at the CSS and add some structure to your JSX elements to make things look appealing. I'm going to add some links too.
app/root.tsx
will be the one that links to the global
CSS files. Why do you think the name "global" makes sense for the root route's styles?
The global-large.css
and global-medium.css
files are for media query-based CSS.
<link />
tags can use media queries? Check out the MDN page for <link />
.
import type { LinksFunction } from "@remix-run/node";
import {
Links,
LiveReload,
Outlet,
} from "@remix-run/react";
import globalLargeStylesUrl from "~/styles/global-large.css";
import globalMediumStylesUrl from "~/styles/global-medium.css";
import globalStylesUrl from "~/styles/global.css";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: globalStylesUrl },
{
rel: "stylesheet",
href: globalMediumStylesUrl,
media: "print, (min-width: 640px)",
},
{
rel: "stylesheet",
href: globalLargeStylesUrl,
media: "screen and (min-width: 1024px)",
},
];
export default function App() {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
<title>Remix: So great, it's funny!</title>
<Links />
</head>
<body>
<Outlet />
<LiveReload />
</body>
</html>
);
}
import type { LinksFunction } from "@remix-run/node";
import { Link, Outlet } from "@remix-run/react";
import stylesUrl from "~/styles/jokes.css";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: stylesUrl },
];
export default function JokesRoute() {
return (
<div className="jokes-layout">
<header className="jokes-header">
<div className="container">
<h1 className="home-link">
<Link
to="/"
title="Remix Jokes"
aria-label="Remix Jokes"
>
<span className="logo">π€ͺ</span>
<span className="logo-medium">Jπ€ͺKES</span>
</Link>
</h1>
</div>
</header>
<main className="jokes-main">
<div className="container">
<div className="jokes-list">
<Link to=".">Get a random joke</Link>
<p>Here are a few more jokes to check out:</p>
<ul>
<li>
<Link to="some-joke-id">Hippo</Link>
</li>
</ul>
<Link to="new" className="button">
Add your own
</Link>
</div>
<div className="jokes-outlet">
<Outlet />
</div>
</div>
</main>
</div>
);
}
πΏ Let's also add a link to the jokes from the homepage and follow some class names in the CSS to make the homepage look nice.
import type { LinksFunction } from "@remix-run/node";
import { Link } from "@remix-run/react";
import stylesUrl from "~/styles/index.css";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: stylesUrl },
];
export default function IndexRoute() {
return (
<div className="container">
<div className="content">
<h1>
Remix <span>Jokes!</span>
</h1>
<nav>
<ul>
<li>
<Link to="jokes">Read Jokes</Link>
</li>
</ul>
</nav>
</div>
</div>
);
}
As we work through the rest of the tutorial, you may want to check the class names in those CSS files, so you can take full advantage of that CSS.
One quick note about CSS. A lot of you folks may be used to using runtime libraries for CSS (like Styled-Components). While you can use those with Remix, we'd like to encourage you to look into more traditional approaches to CSS. Many of the problems that led to the creation of these styling solutions aren't really problems in Remix, so you can often go with a simpler styling approach.
That said, many Remix users are very happy with Tailwind CSS, and we recommend this approach. Basically, if it can give you a URL (or a CSS file which you can import to get a URL), then it's generally a good approach because Remix can then leverage the browser platform for caching and loading/unloading.
Most real-world applications require some form of data persistence. In our case, we want to save our jokes to a database so people can laugh at our hilarity and even submit their own (coming soon in the authentication section!).
You can use any persistence solution you like with Remix; Firebase, Supabase, Airtable, Hasura, Google Spreadsheets, Cloudflare Workers KV, Fauna, a custom PostgreSQL, or even your backend team's REST/GraphQL APIs. Seriously. Whatever you want.
In this tutorial we're going to use our own SQLite database. Essentially, it's a database that lives in a file on your computer, is surprisingly capable, and best of all it's supported by Prisma, our favorite database ORM! It's a great place to start if you're not sure what database to use.
There are two packages that we need to get started:
prisma
for interacting with our database and schema during development.@prisma/client
for making queries to our database during runtime.πΏ Install the Prisma packages:
npm install --save-dev prisma
npm install @prisma/client
πΏ Now we can initialize Prisma with SQLite:
npx prisma init --datasource-provider sqlite
That gives us this output:
β Your Prisma schema was created at prisma/schema.prisma
You can now open it in your favorite editor.
warn You already have a .gitignore file. Don't forget to add `.env` in it to not commit any private information.
Next steps:
1. Set the DATABASE_URL in the .env file to point to your existing database. If your database has no tables yet, read https://pris.ly/d/getting-started
2. Run prisma db pull to turn your database schema into a Prisma schema.
3. Run prisma generate to generate the Prisma Client. You can then start querying your database.
More information in our documentation:
https://pris.ly/d/getting-started
Now that we've got Prisma initialized, we can start modeling our app data. Because this isn't a Prisma tutorial, I'll just hand you that, and you can read more about the Prisma schema from their docs:
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model Joke {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String
content String
}
πΏ With that in place, run this:
npx prisma db push
This command will give you this output:
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": SQLite database "dev.db" at "file:./dev.db"
SQLite database dev.db created at file:./dev.db
π Your database is now in sync with your Prisma schema. Done in 39ms
β Generated Prisma Client (4.12.0 | library) to ./node_modules/@prisma/client in 26ms
This command did a few things. For one, it created our database file in prisma/dev.db
. Then it pushed all the necessary changes to our database to match the schema we provided. Finally, it generated Prisma's TypeScript types, so we'll get stellar autocomplete and type checking as we use its API for interacting with our database.
πΏ Let's add that prisma/dev.db
to our .gitignore
so we don't accidentally commit it to our repository. As mentioned in the Prisma output, we don't want to commit our secrets, so your .env
file is already added to the .gitignore
out of the box!
node_modules
/.cache
/build
/public/build
.env
/prisma/dev.db
prisma/dev.db
file and run npx prisma db push
again.
Next, we're going to write a little file that will "seed" our database with test data. Again, this isn't really remix-specific stuff, so I'll just give this to you (don't worry, we'll get back to remix soon):
πΏ Copy this into a new file called prisma/seed.ts
import { PrismaClient } from "@prisma/client";
const db = new PrismaClient();
async function seed() {
await Promise.all(
getJokes().map((joke) => {
return db.joke.create({ data: joke });
})
);
}
seed();
function getJokes() {
// shout-out to https://icanhazdadjoke.com/
return [
{
name: "Road worker",
content: `I never wanted to believe that my Dad was stealing from his job as a road worker. But when I got home, all the signs were there.`,
},
{
name: "Frisbee",
content: `I was wondering why the frisbee was getting bigger, then it hit me.`,
},
{
name: "Trees",
content: `Why do trees seem suspicious on sunny days? Dunno, they're just a bit shady.`,
},
{
name: "Skeletons",
content: `Why don't skeletons ride roller coasters? They don't have the stomach for it.`,
},
{
name: "Hippos",
content: `Why don't you find hippopotamuses hiding in trees? They're really good at it.`,
},
{
name: "Dinner",
content: `What did one plate say to the other plate? Dinner is on me!`,
},
{
name: "Elevator",
content: `My first time using an elevator was an uplifting experience. The second time let me down.`,
},
];
}
Feel free to add your own jokes if you like.
Now we just need to run this file. We wrote it in TypeScript to get type safety (this is much more useful as our app and data models grow in complexity). So we'll need a way to run it.
πΏ Install ts-node
and tsconfig-paths
as dev dependencies:
npm install --save-dev ts-node tsconfig-paths
πΏ And now we can run our seed.ts
file with that:
npx ts-node --require tsconfig-paths/register prisma/seed.ts
Now our database has those jokes in it. No joke!
But I don't want to have to remember to run that script any time I reset the database. Luckily, we don't have to!
πΏ Add this to your package.json
:
{
"prisma": {
"seed": "ts-node --require tsconfig-paths/register prisma/seed.ts"
}
}
Now, whenever we reset the database, Prisma will call our seeding file as well.
Ok, one last thing we need to do is connect to the database in our app. We do this at the top of our prisma/seed.ts
file:
import { PrismaClient } from "@prisma/client";
const db = new PrismaClient();
This works just fine, but the problem is, during development, we don't want to close down and completely restart our server every time we make a server-side change. So @remix-run/serve
actually rebuilds our code and requires it brand new. The problem here is that every time we make a code change, we'll make a new connection to the database and eventually run out of connections! This is such a common problem with database-accessing apps that Prisma has a warning for it:
Warning: 10 Prisma Clients are already running
So we've got a bit of extra work to do to avoid this development time problem.
Note that this isn't a remix-only problem. Any time you have "live reload" of server code, you're going to have to either disconnect and reconnect to databases (which can be slow) or use the global
singleton workaround.
πΏ Copy this into two new files called app/utils/singleton.server.ts
& app/utils/db.server.ts
export const singleton = <Value>(
name: string,
valueFactory: () => Value
): Value => {
const g = global as any;
g.__singletons ??= {};
g.__singletons[name] ??= valueFactory();
return g.__singletons[name];
};
import { PrismaClient } from "@prisma/client";
import { singleton } from "./singleton.server";
// Hard-code a unique key, so we can look up the client when this module gets re-imported
export const db = singleton(
"prisma",
() => new PrismaClient()
);
I'll leave analysis of this code as an exercise for the reader because again, this has nothing to do with Remix directly.
The one thing that I will call out is the file name convention. The .server
part of the filename informs Remix that this code should never end up in the browser. This is optional, because Remix does a good job of ensuring server code doesn't end up in the client. But sometimes some server-only dependencies are difficult to treeshake, so adding the .server
to the filename is a hint to the compiler to not worry about this module or its imports when bundling for the browser. The .server
acts as a sort of boundary for the compiler.
Ok, ready to get back to writing Remix code? Me too!
Our goal is to put a list of jokes on the /jokes
route, so we can have a list of links to jokes people can choose from. In Remix, each route module is responsible for getting its own data. So if we want data on the /jokes
route, then we'll be updating the app/routes/jokes.tsx
file.
To load data in a Remix route module, you use a loader
. This is simply an async
function you export that returns a response, and is accessed on the component through the useLoaderData
hook. Here's a quick example:
// this is just an example. No need to copy/paste this π
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { db } from "~/utils/db.server";
export const loader = async () => {
return json({
sandwiches: await db.sandwich.findMany(),
});
};
export default function Sandwiches() {
const data = useLoaderData<typeof loader>();
return (
<ul>
{data.sandwiches.map((sandwich) => (
<li key={sandwich.id}>{sandwich.name}</li>
))}
</ul>
);
}
Does that give you a good idea of what to do here? If not, you can take a look at my solution in the <details>
below π
Remix and the tsconfig.json
you get from the starter template are configured to allow imports from the app/
directory via ~
as demonstrated above, so you don't have ../../
all over the place.
πΏ Update the app/routes/jokes.tsx
route module to load jokes from our database and render a list of links to the jokes.
import type { LinksFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import {
Link,
Outlet,
useLoaderData,
} from "@remix-run/react";
import stylesUrl from "~/styles/jokes.css";
import { db } from "~/utils/db.server";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: stylesUrl },
];
export const loader = async () => {
return json({
jokeListItems: await db.joke.findMany(),
});
};
export default function JokesRoute() {
const data = useLoaderData<typeof loader>();
return (
<div className="jokes-layout">
<header className="jokes-header">
<div className="container">
<h1 className="home-link">
<Link
to="/"
title="Remix Jokes"
aria-label="Remix Jokes"
>
<span className="logo">π€ͺ</span>
<span className="logo-medium">Jπ€ͺKES</span>
</Link>
</h1>
</div>
</header>
<main className="jokes-main">
<div className="container">
<div className="jokes-list">
<Link to=".">Get a random joke</Link>
<p>Here are a few more jokes to check out:</p>
<ul>
{data.jokeListItems.map(({ id, name }) => (
<li key={id}>
<Link to={id}>{name}</Link>
</li>
))}
</ul>
<Link to="new" className="button">
Add your own
</Link>
</div>
<div className="jokes-outlet">
<Outlet />
</div>
</div>
</main>
</div>
);
}
And here's what we have with that now:
I want to call out something specific in my solution. Here's my loader:
export const loader = async () => {
return json({
jokeListItems: await db.joke.findMany({
orderBy: { createdAt: "desc" },
select: { id: true, name: true },
take: 5,
}),
});
};
Notice that all I need for this page is the joke id
and name
. I don't need to bother getting the content
. I'm also limiting to a total of 5 items and ordering by creation date, so we get the latest jokes. So with prisma
, I can change my query to be exactly what I need and avoid sending too much data to the client! That makes my app faster and more responsive for my users.
And to make it even cooler, you don't necessarily need Prisma or direct database access to do this. You've got a GraphQL backend you're hitting? Sweet, use your regular GraphQL stuff in your loader. It's even better than doing it on the client because you don't need to worry about shipping a huge GraphQL client to the client. Keep that on your server and filter down to what you want.
Oh, you've just got REST endpoints you hit? That's fine too! You can easily filter out the extra data before sending it off in your loader. Because it all happens on the server, you can save your user's download size easily without having to convince your backend engineers to change their entire API. Neat!
Filtering out data you don't render isn't just about sending less over the wire, you should also filter out any sensitive data you don't want exposed to the client.
In our code we're using the useLoaderData
's type generic and pass our loader
so we can get nice auto-complete, but it's not really getting us type safety because the loader
and the useLoaderData
are running in completely different environments. Remix ensures we get what the server sent, but who really knows? Maybe in a fit of rage, your co-worker set up your server to automatically remove references to dogs (they prefer cats).
So the only way to really be 100% positive that your data is correct, you should use assertion functions on the data
you get back from useLoaderData
. That's outside the scope of this tutorial, but we're fans of zod which can aid in this.
Before we get to the /jokes/:jokeId
route, here's a quick example of how you can access params (like :jokeId
) in your loader.
export const loader = async ({
params,
}: LoaderFunctionArgs) => {
console.log(params); // <-- {jokeId: "123"}
};
And here's how you get the joke from Prisma:
const joke = await db.joke.findUnique({
where: { id: jokeId },
});
/jokes/:jokeId
, and when we talk about the file system it's /app/routes/jokes.$jokeId.tsx
.
πΏ Great! Now you know everything you need to continue and connect the /jokes/:jokeId
route in app/routes/jokes.$jokeId.tsx
.
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Link, useLoaderData } from "@remix-run/react";
import { db } from "~/utils/db.server";
export const loader = async ({
params,
}: LoaderFunctionArgs) => {
const joke = await db.joke.findUnique({
where: { id: params.jokeId },
});
if (!joke) {
throw new Error("Joke not found");
}
return json({ joke });
};
export default function JokeRoute() {
const data = useLoaderData<typeof loader>();
return (
<div>
<p>Here's your hilarious joke:</p>
<p>{data.joke.content}</p>
<Link to=".">"{data.joke.name}" Permalink</Link>
</div>
);
}
With that you should be able to go to /jokes
and click on a link to get the joke:
We'll handle the case where someone tries to access a joke that doesn't exist in the database in the next section.
Next, let's handle the /jokes
index route in app/routes/jokes._index.tsx
that shows a random joke.
Here's how you get a random joke from Prisma:
const count = await db.joke.count();
const randomRowNumber = Math.floor(Math.random() * count);
const [randomJoke] = await db.joke.findMany({
skip: randomRowNumber,
take: 1,
});
πΏ You should be able to get the loader working from there.
import { json } from "@remix-run/node";
import { Link, useLoaderData } from "@remix-run/react";
import { db } from "~/utils/db.server";
export const loader = async () => {
const count = await db.joke.count();
const randomRowNumber = Math.floor(Math.random() * count);
const [randomJoke] = await db.joke.findMany({
skip: randomRowNumber,
take: 1,
});
return json({ randomJoke });
};
export default function JokesIndexRoute() {
const data = useLoaderData<typeof loader>();
return (
<div>
<p>Here's a random joke:</p>
<p>{data.randomJoke.content}</p>
<Link to={data.randomJoke.id}>
"{data.randomJoke.name}" Permalink
</Link>
</div>
);
}
With that your /jokes
route should display a list of links to jokes as well as a random joke:
We've got ourselves a /jokes/new
route, but that form doesn't do anything yet. Let's wire it up! As a reminder here's what that code should look like right now (the method="post"
is important so make sure yours has it):
export default function NewJokeRoute() {
return (
<div>
<p>Add your own hilarious joke</p>
<form method="post">
<div>
<label>
Name: <input type="text" name="name" />
</label>
</div>
<div>
<label>
Content: <textarea name="content" />
</label>
</div>
<div>
<button type="submit" className="button">
Add
</button>
</div>
</form>
</div>
);
}
Not much there. Just a form. What if I told you that you could make that form work with a single export to the route module? Well you can! It's the action
function export! Read up on that a bit.
Here's the Prisma code you'll need:
const joke = await db.joke.create({
data: { name, content },
});
πΏ Create an action
in app/routes/jokes.new.tsx
.
import type { ActionFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { db } from "~/utils/db.server";
export const action = async ({
request,
}: ActionFunctionArgs) => {
const form = await request.formData();
const content = form.get("content");
const name = form.get("name");
// we do this type check to be extra sure and to make TypeScript happy
// we'll explore validation next!
if (
typeof content !== "string" ||
typeof name !== "string"
) {
throw new Error("Form not submitted correctly.");
}
const fields = { content, name };
const joke = await db.joke.create({ data: fields });
return redirect(`/jokes/${joke.id}`);
};
export default function NewJokeRoute() {
return (
<div>
<p>Add your own hilarious joke</p>
<form method="post">
<div>
<label>
Name: <input type="text" name="name" />
</label>
</div>
<div>
<label>
Content: <textarea name="content" />
</label>
</div>
<div>
<button type="submit" className="button">
Add
</button>
</div>
</form>
</div>
);
}
If you've got that working, you should be able to create new jokes and be redirected to the new joke's page.
The redirect
utility is a simple utility in Remix for creating a Response
object that has the right headers/status codes to redirect the user.
Hooray! How cool is that? No useEffect
or useAnything
hooks. Just a form, and an async function to process the submission. Pretty cool. You can definitely still do all that stuff if you wanted to, but why would you? This is really nice.
Another thing you'll notice is that when we were redirected to the joke's new page, it was there! But we didn't have to think about updating the cache at all. Remix handles invalidating the cache for us automatically. You don't have to think about it. That is cool π
Why don't we add some validation? We could definitely do the typical React validation approach. Wiring up useState
with onChange
handlers and such. And sometimes that's nice to get some real-time validation as the user's typing. But even if you do all that work, you're still going to want to do validation on the server.
Before I set you off on this one, there's one more thing you need to know about route module action
functions. The return value is expected to be the same as the loader
function: A Response
, or (as a convenience) a serializable JavaScript object. Normally you want to redirect
when the action is successful to avoid the annoying "confirm resubmission" dialog you might have seen on some websites.
But if there's an error, you can return an object with the error messages and then the component can get those values from useActionData
and display them to the user.
πΏ Go ahead and validate that the name
and content
fields are long enough. I'd say the name should be at least 3 characters long and the content should be at least 10 characters long. Do this validation server-side.
import type { ActionFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { useActionData } from "@remix-run/react";
import { db } from "~/utils/db.server";
import { badRequest } from "~/utils/request.server";
function validateJokeContent(content: string) {
if (content.length < 10) {
return "That joke is too short";
}
}
function validateJokeName(name: string) {
if (name.length < 3) {
return "That joke's name is too short";
}
}
export const action = async ({
request,
}: ActionFunctionArgs) => {
const form = await request.formData();
const content = form.get("content");
const name = form.get("name");
if (
typeof content !== "string" ||
typeof name !== "string"
) {
return badRequest({
fieldErrors: null,
fields: null,
formError: "Form not submitted correctly.",
});
}
const fieldErrors = {
content: validateJokeContent(content),
name: validateJokeName(name),
};
const fields = { content, name };
if (Object.values(fieldErrors).some(Boolean)) {
return badRequest({
fieldErrors,
fields,
formError: null,
});
}
const joke = await db.joke.create({ data: fields });
return redirect(`/jokes/${joke.id}`);
};
export default function NewJokeRoute() {
const actionData = useActionData<typeof action>();
return (
<div>
<p>Add your own hilarious joke</p>
<form method="post">
<div>
<label>
Name:{" "}
<input
defaultValue={actionData?.fields?.name}
name="name"
type="text"
aria-invalid={Boolean(
actionData?.fieldErrors?.name
)}
aria-errormessage={
actionData?.fieldErrors?.name
? "name-error"
: undefined
}
/>
</label>
{actionData?.fieldErrors?.name ? (
<p
className="form-validation-error"
id="name-error"
role="alert"
>
{actionData.fieldErrors.name}
</p>
) : null}
</div>
<div>
<label>
Content:{" "}
<textarea
defaultValue={actionData?.fields?.content}
name="content"
aria-invalid={Boolean(
actionData?.fieldErrors?.content
)}
aria-errormessage={
actionData?.fieldErrors?.content
? "content-error"
: undefined
}
/>
</label>
{actionData?.fieldErrors?.content ? (
<p
className="form-validation-error"
id="content-error"
role="alert"
>
{actionData.fieldErrors.content}
</p>
) : null}
</div>
<div>
{actionData?.formError ? (
<p
className="form-validation-error"
role="alert"
>
{actionData.formError}
</p>
) : null}
<button type="submit" className="button">
Add
</button>
</div>
</form>
</div>
);
}
import { json } from "@remix-run/node";
/**
* This helper function helps us to return the accurate HTTP status,
* 400 Bad Request, to the client.
*/
export const badRequest = <T>(data: T) =>
json<T>(data, { status: 400 });
Great! You should now have a form that validates the fields on the server and displays those errors on the client:
Why don't you pop open my code example for a second? I want to show you a few things about the way I'm doing this.
First I want you to notice that I've passed typeof action
to the useActionData
generic function. This way, actionData
type will properly be inferred, and we'll get some type safety. Keep in mind that useActionData
can return undefined
if the action hasn't been called yet, so we've got a bit of defensive programming going on there.
You may also notice that I return the fields as well. This is so that the form can be re-rendered with the values from the server in the event that JavaScript fails to load for some reason. That's what the defaultValue
stuff is all about as well.
The badRequest
helper function will automatically infer the type of the passed data, while still returning the accurate HTTP status, 400 Bad Request
, to the client. If we just used json()
without specifying the status, that would result in a 200 OK
response, which isn't suitable since the form submission had errors.
Another thing I want to call out is how all of this is just so nice and declarative. You don't have to think about state at all here. Your action gets some data, you process it and return a value. The component consumes the action data and renders based on that value. No managing state here. No thinking about race conditions. Nothing.
Oh, and if you do want to have client-side validation (for while the user is typing), you can simply call the validateJokeContent
and validateJokeName
functions that the action is using. You can actually seamlessly share code between the client and server! Now that is cool!
It's the moment we've all been waiting for! We're going to add authentication to our little application. The reason we want to add authentication is so jokes can be associated to the users who created them.
One thing that would be good to understand for this section is how HTTP cookies work on the web.
We're going to handroll our own authentication from scratch. Don't worry, I promise it's not as scary as it sounds.
prisma/dev.db
file and run npx prisma db push
again. Remember to also restart your dev server with npm run dev
.
Let's start by showing you our updated prisma/schema.prisma
file.
πΏ Go ahead and update your prisma/schema.prisma
file to look like this:
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
username String @unique
passwordHash String
jokes Joke[]
}
model Joke {
id String @id @default(uuid())
jokesterId String
jokester User @relation(fields: [jokesterId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String
content String
}
With that updated, let's go ahead and reset our database to this schema:
πΏ Run this:
npx prisma db push
It will prompt you to reset the database, hit "y" to confirm.
That will give you this output:
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": SQLite database "dev.db" at "file:./dev.db"
β οΈ We found changes that cannot be executed:
β’ Added the required column `jokesterId` to the `Joke` table without a default value. There are 9 rows in this table, it is not possible to execute this step.
β To apply this change we need to reset the database, do you want to continue? All data will be lost. β¦ yes
The SQLite database "dev.db" from "file:./dev.db" was successfully reset.
π Your database is now in sync with your Prisma schema. Done in 1.56s
β Generated Prisma Client (4.12.0 | library) to ./node_modules/@prisma/client in 34ms
With this change, we're going to start experiencing some TypeScript errors in our project because you can no longer create a joke
without a jokesterId
value.
πΏ Let's start by fixing our prisma/seed.ts
file.
import { PrismaClient } from "@prisma/client";
const db = new PrismaClient();
async function seed() {
const kody = await db.user.create({
data: {
username: "kody",
// this is a hashed version of "twixrox"
passwordHash:
"$2b$10$K7L1OJ45/4Y2nIvhRVpCe.FSmhDdWoXehVzJptJ/op0lSsvqNu/1u",
},
});
await Promise.all(
getJokes().map((joke) => {
const data = { jokesterId: kody.id, ...joke };
return db.joke.create({ data });
})
);
}
seed();
function getJokes() {
// shout-out to https://icanhazdadjoke.com/
return [
{
name: "Road worker",
content: `I never wanted to believe that my Dad was stealing from his job as a road worker. But when I got home, all the signs were there.`,
},
{
name: "Frisbee",
content: `I was wondering why the frisbee was getting bigger, then it hit me.`,
},
{
name: "Trees",
content: `Why do trees seem suspicious on sunny days? Dunno, they're just a bit shady.`,
},
{
name: "Skeletons",
content: `Why don't skeletons ride roller coasters? They don't have the stomach for it.`,
},
{
name: "Hippos",
content: `Why don't you find hippopotamuses hiding in trees? They're really good at it.`,
},
{
name: "Dinner",
content: `What did one plate say to the other plate? Dinner is on me!`,
},
{
name: "Elevator",
content: `My first time using an elevator was an uplifting experience. The second time let me down.`,
},
];
}
πΏ Great, now run the seed again:
npx prisma db seed
And that outputs:
Environment variables loaded from .env
Running seed command `ts-node --require tsconfig-paths/register prisma/seed.ts` ...
π± The seed command has been executed.
Great! Our database is now ready to go.
So our authentication will be of the traditional username/password variety. We'll be using bcryptjs
to hash our passwords so nobody will be able to reasonably brute-force their way into an account.
πΏ Go ahead and get that installed right now, so we don't forget:
npm install bcryptjs
πΏ The bcryptjs
library has TypeScript definitions in DefinitelyTyped, so let's install those as well:
npm install --save-dev @types/bcryptjs
Let me give you a quick diagram of the flow of things:
Here's that written out:
/login
route./jokes
route with the Set-Cookie
header.Alright, enough high-level stuff. Let's start writing some Remix code!
We're going to create a login page, and I've got some CSS for you to use on that page:
/*
* when the user visits this page, this style will apply, when they leave, it
* will get unloaded, so don't worry so much about conflicting styles between
* pages!
*/
body {
background-image: var(--gradient-background);
}
.container {
min-height: inherit;
}
.container,
.content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.content {
padding: 1rem;
background-color: hsl(0, 0%, 100%);
border-radius: 5px;
box-shadow: 0 0.2rem 1rem rgba(0, 0, 0, 0.5);
width: 400px;
max-width: 100%;
}
@media print, (min-width: 640px) {
.content {
padding: 2rem;
border-radius: 8px;
}
}
h1 {
margin-top: 0;
}
fieldset {
display: flex;
justify-content: center;
}
fieldset > :not(:last-child) {
margin-right: 2rem;
}
.links ul {
margin-top: 1rem;
padding: 0;
list-style: none;
display: flex;
gap: 1.5rem;
align-items: center;
}
.links a:hover {
text-decoration-style: wavy;
text-decoration-thickness: 1px;
}
πΏ Create a /login
route by adding a app/routes/login.tsx
file.
import type { LinksFunction } from "@remix-run/node";
import { Link, useSearchParams } from "@remix-run/react";
import stylesUrl from "~/styles/login.css";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: stylesUrl },
];
export default function Login() {
const [searchParams] = useSearchParams();
return (
<div className="container">
<div className="content" data-light="">
<h1>Login</h1>
<form method="post">
<input
type="hidden"
name="redirectTo"
value={
searchParams.get("redirectTo") ?? undefined
}
/>
<fieldset>
<legend className="sr-only">
Login or Register?
</legend>
<label>
<input
type="radio"
name="loginType"
value="login"
defaultChecked
/>{" "}
Login
</label>
<label>
<input
type="radio"
name="loginType"
value="register"
/>{" "}
Register
</label>
</fieldset>
<div>
<label htmlFor="username-input">Username</label>
<input
type="text"
id="username-input"
name="username"
/>
</div>
<div>
<label htmlFor="password-input">Password</label>
<input
id="password-input"
name="password"
type="password"
/>
</div>
<button type="submit" className="button">
Submit
</button>
</form>
</div>
<div className="links">
<ul>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/jokes">Jokes</Link>
</li>
</ul>
</div>
</div>
);
}
This should look something like this:
Notice in my solution I'm using useSearchParams
to get the redirectTo
query parameter and putting that in a hidden input. This way our action
can know where to redirect the user. This will be useful later when we redirect a user to the login page.
Great, now that we've got the UI looking nice, let's add some logic. This will be very similar to the sort of thing we did in the /jokes/new
route. Fill in as much as you can (validation and stuff) and we'll just leave comments for the parts of the logic we don't have implemented yet (like actually registering/logging in).
πΏ Implement validation with an action
in app/routes/login.tsx
import type {
ActionFunctionArgs,
LinksFunction,
} from "@remix-run/node";
import {
Link,
useActionData,
useSearchParams,
} from "@remix-run/react";
import stylesUrl from "~/styles/login.css";
import { db } from "~/utils/db.server";
import { badRequest } from "~/utils/request.server";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: stylesUrl },
];
function validateUsername(username: string) {
if (username.length < 3) {
return "Usernames must be at least 3 characters long";
}
}
function validatePassword(password: string) {
if (password.length < 6) {
return "Passwords must be at least 6 characters long";
}
}
function validateUrl(url: string) {
const urls = ["/jokes", "/", "https://remix.run"];
if (urls.includes(url)) {
return url;
}
return "/jokes";
}
export const action = async ({
request,
}: ActionFunctionArgs) => {
const form = await request.formData();
const loginType = form.get("loginType");
const password = form.get("password");
const username = form.get("username");
const redirectTo = validateUrl(
(form.get("redirectTo") as string) || "/jokes"
);
if (
typeof loginType !== "string" ||
typeof password !== "string" ||
typeof username !== "string"
) {
return badRequest({
fieldErrors: null,
fields: null,
formError: "Form not submitted correctly.",
});
}
const fields = { loginType, password, username };
const fieldErrors = {
password: validatePassword(password),
username: validateUsername(username),
};
if (Object.values(fieldErrors).some(Boolean)) {
return badRequest({
fieldErrors,
fields,
formError: null,
});
}
switch (loginType) {
case "login": {
// login to get the user
// if there's no user, return the fields and a formError
// if there is a user, create their session and redirect to /jokes
return badRequest({
fieldErrors: null,
fields,
formError: "Not implemented",
});
}
case "register": {
const userExists = await db.user.findFirst({
where: { username },
});
if (userExists) {
return badRequest({
fieldErrors: null,
fields,
formError: `User with username ${username} already exists`,
});
}
// create the user
// create their session and redirect to /jokes
return badRequest({
fieldErrors: null,
fields,
formError: "Not implemented",
});
}
default: {
return badRequest({
fieldErrors: null,
fields,
formError: "Login type invalid",
});
}
}
};
export default function Login() {
const actionData = useActionData<typeof action>();
const [searchParams] = useSearchParams();
return (
<div className="container">
<div className="content" data-light="">
<h1>Login</h1>
<form method="post">
<input
type="hidden"
name="redirectTo"
value={
searchParams.get("redirectTo") ?? undefined
}
/>
<fieldset>
<legend className="sr-only">
Login or Register?
</legend>
<label>
<input
type="radio"
name="loginType"
value="login"
defaultChecked={
!actionData?.fields?.loginType ||
actionData?.fields?.loginType === "login"
}
/>{" "}
Login
</label>
<label>
<input
type="radio"
name="loginType"
value="register"
defaultChecked={
actionData?.fields?.loginType ===
"register"
}
/>{" "}
Register
</label>
</fieldset>
<div>
<label htmlFor="username-input">Username</label>
<input
type="text"
id="username-input"
name="username"
defaultValue={actionData?.fields?.username}
aria-invalid={Boolean(
actionData?.fieldErrors?.username
)}
aria-errormessage={
actionData?.fieldErrors?.username
? "username-error"
: undefined
}
/>
{actionData?.fieldErrors?.username ? (
<p
className="form-validation-error"
role="alert"
id="username-error"
>
{actionData.fieldErrors.username}
</p>
) : null}
</div>
<div>
<label htmlFor="password-input">Password</label>
<input
id="password-input"
name="password"
type="password"
defaultValue={actionData?.fields?.password}
aria-invalid={Boolean(
actionData?.fieldErrors?.password
)}
aria-errormessage={
actionData?.fieldErrors?.password
? "password-error"
: undefined
}
/>
{actionData?.fieldErrors?.password ? (
<p
className="form-validation-error"
role="alert"
id="password-error"
>
{actionData.fieldErrors.password}
</p>
) : null}
</div>
<div id="form-error-message">
{actionData?.formError ? (
<p
className="form-validation-error"
role="alert"
>
{actionData.formError}
</p>
) : null}
</div>
<button type="submit" className="button">
Submit
</button>
</form>
</div>
<div className="links">
<ul>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/jokes">Jokes</Link>
</li>
</ul>
</div>
</div>
);
}
Once you've got that done, your form should look something like this:
Sweet! Now it's time for the juicy stuff. Let's start with the login
side of things. We seed in a user with the username kody
and the password (hashed) is twixrox
. So we want to implement enough logic that will allow us to login as that user. We're going to put this logic in a separate file called app/utils/session.server.ts
.
Here's what we need in that file to get started:
login
that accepts the username
and password
username
null
bcrypt.compare
to compare the given password
to the user's passwordHash
null
πΏ Create a file called app/utils/session.server.ts
and implement the above requirements.
import bcrypt from "bcryptjs";
import { db } from "./db.server";
type LoginForm = {
password: string;
username: string;
};
export async function login({
password,
username,
}: LoginForm) {
const user = await db.user.findUnique({
where: { username },
});
if (!user) {
return null;
}
const isCorrectPassword = await bcrypt.compare(
password,
user.passwordHash
);
if (!isCorrectPassword) {
return null;
}
return { id: user.id, username };
}
Great, with that in place, we can now update app/routes/login.tsx
to use it:
// ...
import stylesUrl from "~/styles/login.css";
import { db } from "~/utils/db.server";
import { badRequest } from "~/utils/request.server";
import { login } from "~/utils/session.server";
// ...
export const action = async ({
request,
}: ActionFunctionArgs) => {
// ...
switch (loginType) {
case "login": {
const user = await login({ username, password });
console.log({ user });
if (!user) {
return badRequest({
fieldErrors: null,
fields,
formError:
"Username/Password combination is incorrect",
});
}
// if there is a user, create their session and redirect to /jokes
return badRequest({
fieldErrors: null,
fields,
formError: "Not implemented",
});
}
// ...
}
};
export default function Login() {
// ...
}
To check our work, I added a console.log
to app/routes/login.tsx
after the login
call.
actions
and loaders
run on the server, so console.log
calls you put in those you can't see in the browser console. Those will show up in the terminal window you're running your server in.
πΏ With that in place, try to log in with the username kody
and the password twixrox
and check the terminal output. Here's what I get:
{
user: {
id: '1dc45f54-4061-4d9e-8a6d-28d6df6a8d7f',
username: 'kody'
}
}
npx prisma studio
to see the database in the browser. It's possible you don't have any data because you forgot to run npx prisma db seed
(like I did when I was writing this π
).
Wahoo! We got the user! Now we need to put that user's ID into the session. We're going to do this in app/utils/session.server.ts
. Remix has a built-in abstraction to help us with managing several types of storage mechanisms for sessions (here are the docs). We'll be using createCookieSessionStorage
as it's the simplest and scales quite well.
πΏ Write a createUserSession
function in app/utils/session.server.ts
that accepts a user ID and a route to redirect to. It should do the following:
getSession
function)userId
field on the sessionSet-Cookie
header (via the cookie storage commitSession
function)Note: If you need a hand, there's a small example of how the whole basic flow goes in the session docs. Once you have that, you'll want to use it in app/routes/login.tsx
to set the session and redirect to the /jokes
route.
import {
createCookieSessionStorage,
redirect,
} from "@remix-run/node";
import bcrypt from "bcryptjs";
import { db } from "./db.server";
type LoginForm = {
username: string;
password: string;
};
export async function login({
username,
password,
}: LoginForm) {
const user = await db.user.findUnique({
where: { username },
});
if (!user) {
return null;
}
const isCorrectPassword = await bcrypt.compare(
password,
user.passwordHash
);
if (!isCorrectPassword) {
return null;
}
return { id: user.id, username };
}
const sessionSecret = process.env.SESSION_SECRET;
if (!sessionSecret) {
throw new Error("SESSION_SECRET must be set");
}
const storage = createCookieSessionStorage({
cookie: {
name: "RJ_session",
// normally you want this to be `secure: true`
// but that doesn't work on localhost for Safari
// https://web.dev/when-to-use-local-https/
secure: process.env.NODE_ENV === "production",
secrets: [sessionSecret],
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 30,
httpOnly: true,
},
});
export async function createUserSession(
userId: string,
redirectTo: string
) {
const session = await storage.getSession();
session.set("userId", userId);
return redirect(redirectTo, {
headers: {
"Set-Cookie": await storage.commitSession(session),
},
});
}
// ...
import stylesUrl from "~/styles/login.css";
import { db } from "~/utils/db.server";
import { badRequest } from "~/utils/request.server";
import {
createUserSession,
login,
} from "~/utils/session.server";
// ...
export const action = async ({
request,
}: ActionFunctionArgs) => {
// ...
switch (loginType) {
case "login": {
const user = await login({ username, password });
if (!user) {
return badRequest({
fieldErrors: null,
fields,
formError: `Username/Password combination is incorrect`,
});
}
return createUserSession(user.id, redirectTo);
}
// ...
}
};
// ...
I want to call out the SESSION_SECRET
environment variable I'm using really quick. The value of the secrets
option is not the sort of thing you want in your code because the baddies could use it for their nefarious purposes. So instead we are going to read the value from the environment. This means you'll need to set the environment variable in your .env
file. Incidentally, Prisma loads that file for us automatically so all we need to do is make sure we set that value when we deploy to production.
πΏ Update .env
file with SESSION_SECRET
(with any value you like).
With that, pop open your Network tab, go to /login
and enter kody
and twixrox
and check the response headers in the network tab. Should look something like this:
And if you check the cookies section of the Application tab then you should have the cookie set in there as well.
And now every request the browser makes to our server will include that cookie (we don't have to do anything on the client, this is how cookies work):
So we can now check whether the user is authenticated on the server by reading that header to get the userId
we had set into it. To test this out, let's fix the /jokes/new
route by adding the jokesterId
field to db.joke.create
call.
πΏ Update app/utils/session.server.ts
to get the userId
from the session. In my solution I create three functions: getUserSession(request: Request)
, getUserId(request: Request)
and requireUserId(request: Request, redirectTo: string)
.
import {
createCookieSessionStorage,
redirect,
} from "@remix-run/node";
import bcrypt from "bcryptjs";
import { db } from "./db.server";
type LoginForm = {
username: string;
password: string;
};
export async function login({
username,
password,
}: LoginForm) {
const user = await db.user.findUnique({
where: { username },
});
if (!user) {
return null;
}
const isCorrectPassword = await bcrypt.compare(
password,
user.passwordHash
);
if (!isCorrectPassword) {
return null;
}
return { id: user.id, username };
}
const sessionSecret = process.env.SESSION_SECRET;
if (!sessionSecret) {
throw new Error("SESSION_SECRET must be set");
}
const storage = createCookieSessionStorage({
cookie: {
name: "RJ_session",
// normally you want this to be `secure: true`
// but that doesn't work on localhost for Safari
// https://web.dev/when-to-use-local-https/
secure: process.env.NODE_ENV === "production",
secrets: [sessionSecret],
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 30,
httpOnly: true,
},
});
function getUserSession(request: Request) {
return storage.getSession(request.headers.get("Cookie"));
}
export async function getUserId(request: Request) {
const session = await getUserSession(request);
const userId = session.get("userId");
if (!userId || typeof userId !== "string") {
return null;
}
return userId;
}
export async function requireUserId(
request: Request,
redirectTo: string = new URL(request.url).pathname
) {
const session = await getUserSession(request);
const userId = session.get("userId");
if (!userId || typeof userId !== "string") {
const searchParams = new URLSearchParams([
["redirectTo", redirectTo],
]);
throw redirect(`/login?${searchParams}`);
}
return userId;
}
export async function createUserSession(
userId: string,
redirectTo: string
) {
const session = await storage.getSession();
session.set("userId", userId);
return redirect(redirectTo, {
headers: {
"Set-Cookie": await storage.commitSession(session),
},
});
}
throw
ing a Response
?!
In my example, I created a requireUserId
which will throw a redirect
. Remember redirect
is a utility function that returns a Response
object. Remix will catch that thrown response and send it back to the client. It's a great way to "exit early" in abstractions like this so users of our requireUserId
function can just assume that the return will always give us the userId
and don't need to worry about what happens if there isn't a userId
because the response is thrown which stops their code execution!
We'll cover this more in the error handling sections later.
You may also notice that our solution makes use of the login
route's redirectTo
feature we had earlier.
πΏ Now update app/routes/jokes.new.tsx
to use that function to get the userId
and pass it to the db.joke.create
call.
import type { ActionFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { useActionData } from "@remix-run/react";
import { db } from "~/utils/db.server";
import { badRequest } from "~/utils/request.server";
import { requireUserId } from "~/utils/session.server";
function validateJokeContent(content: string) {
if (content.length < 10) {
return "That joke is too short";
}
}
function validateJokeName(name: string) {
if (name.length < 3) {
return "That joke's name is too short";
}
}
export const action = async ({
request,
}: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const form = await request.formData();
const content = form.get("content");
const name = form.get("name");
if (
typeof content !== "string" ||
typeof name !== "string"
) {
return badRequest({
fieldErrors: null,
fields: null,
formError: "Form not submitted correctly.",
});
}
const fieldErrors = {
content: validateJokeContent(content),
name: validateJokeName(name),
};
const fields = { content, name };
if (Object.values(fieldErrors).some(Boolean)) {
return badRequest({
fieldErrors,
fields,
formError: null,
});
}
const joke = await db.joke.create({
data: { ...fields, jokesterId: userId },
});
return redirect(`/jokes/${joke.id}`);
};
export default function NewJokeRoute() {
const actionData = useActionData<typeof action>();
return (
<div>
<p>Add your own hilarious joke</p>
<form method="post">
<div>
<label>
Name:{" "}
<input
defaultValue={actionData?.fields?.name}
name="name"
type="text"
aria-invalid={Boolean(
actionData?.fieldErrors?.name
)}
aria-errormessage={
actionData?.fieldErrors?.name
? "name-error"
: undefined
}
/>
</label>
{actionData?.fieldErrors?.name ? (
<p
className="form-validation-error"
id="name-error"
role="alert"
>
{actionData.fieldErrors.name}
</p>
) : null}
</div>
<div>
<label>
Content:{" "}
<textarea
defaultValue={actionData?.fields?.content}
name="content"
aria-invalid={Boolean(
actionData?.fieldErrors?.content
)}
aria-errormessage={
actionData?.fieldErrors?.content
? "content-error"
: undefined
}
/>
</label>
{actionData?.fieldErrors?.content ? (
<p
className="form-validation-error"
id="content-error"
role="alert"
>
{actionData.fieldErrors.content}
</p>
) : null}
</div>
<div>
{actionData?.formError ? (
<p
className="form-validation-error"
role="alert"
>
{actionData.formError}
</p>
) : null}
<button type="submit" className="button">
Add
</button>
</div>
</form>
</div>
);
}
Super! So now if a user attempts to create a new joke, they'll be redirected to the login page because a userId
is required to create a new joke.
We should probably give people the ability to see that they're logged in and a way to log out right? Yeah, I think so. Let's implement that.
πΏ Update app/utils/session.server.ts
to add a getUser
function that gets the user from Prisma and a logout
function that uses destroySession
to log the user out.
import {
createCookieSessionStorage,
redirect,
} from "@remix-run/node";
import bcrypt from "bcryptjs";
import { db } from "./db.server";
type LoginForm = {
password: string;
username: string;
};
export async function login({
password,
username,
}: LoginForm) {
const user = await db.user.findUnique({
where: { username },
});
if (!user) {
return null;
}
const isCorrectPassword = await bcrypt.compare(
password,
user.passwordHash
);
if (!isCorrectPassword) {
return null;
}
return { id: user.id, username };
}
const sessionSecret = process.env.SESSION_SECRET;
if (!sessionSecret) {
throw new Error("SESSION_SECRET must be set");
}
const storage = createCookieSessionStorage({
cookie: {
name: "RJ_session",
// normally you want this to be `secure: true`
// but that doesn't work on localhost for Safari
// https://web.dev/when-to-use-local-https/
secure: process.env.NODE_ENV === "production",
secrets: [sessionSecret],
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 30,
httpOnly: true,
},
});
function getUserSession(request: Request) {
return storage.getSession(request.headers.get("Cookie"));
}
export async function getUserId(request: Request) {
const session = await getUserSession(request);
const userId = session.get("userId");
if (!userId || typeof userId !== "string") {
return null;
}
return userId;
}
export async function requireUserId(
request: Request,
redirectTo: string = new URL(request.url).pathname
) {
const session = await getUserSession(request);
const userId = session.get("userId");
if (!userId || typeof userId !== "string") {
const searchParams = new URLSearchParams([
["redirectTo", redirectTo],
]);
throw redirect(`/login?${searchParams}`);
}
return userId;
}
export async function getUser(request: Request) {
const userId = await getUserId(request);
if (typeof userId !== "string") {
return null;
}
const user = await db.user.findUnique({
select: { id: true, username: true },
where: { id: userId },
});
if (!user) {
throw await logout(request);
}
return user;
}
export async function logout(request: Request) {
const session = await getUserSession(request);
return redirect("/login", {
headers: {
"Set-Cookie": await storage.destroySession(session),
},
});
}
export async function createUserSession(
userId: string,
redirectTo: string
) {
const session = await storage.getSession();
session.set("userId", userId);
return redirect(redirectTo, {
headers: {
"Set-Cookie": await storage.commitSession(session),
},
});
}
πΏ Great, now we're going to update the app/routes/jokes.tsx
route, so we can display a login link if the user isn't logged in. If they are logged in then we'll display their username and a logout form. I'm also going to clean up the UI a bit to match the class names we've got as well, so feel free to copy/paste the example when you're ready.
import type {
LinksFunction,
LoaderFunctionArgs,
} from "@remix-run/node";
import { json } from "@remix-run/node";
import {
Link,
Outlet,
useLoaderData,
} from "@remix-run/react";
import stylesUrl from "~/styles/jokes.css";
import { db } from "~/utils/db.server";
import { getUser } from "~/utils/session.server";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: stylesUrl },
];
export const loader = async ({
request,
}: LoaderFunctionArgs) => {
const jokeListItems = await db.joke.findMany({
orderBy: { createdAt: "desc" },
select: { id: true, name: true },
take: 5,
});
const user = await getUser(request);
return json({ jokeListItems, user });
};
export default function JokesRoute() {
const data = useLoaderData<typeof loader>();
return (
<div className="jokes-layout">
<header className="jokes-header">
<div className="container">
<h1 className="home-link">
<Link
to="/"
title="Remix Jokes"
aria-label="Remix Jokes"
>
<span className="logo">π€ͺ</span>
<span className="logo-medium">Jπ€ͺKES</span>
</Link>
</h1>
{data.user ? (
<div className="user-info">
<span>{`Hi ${data.user.username}`}</span>
<form action="/logout" method="post">
<button type="submit" className="button">
Logout
</button>
</form>
</div>
) : (
<Link to="/login">Login</Link>
)}
</div>
</header>
<main className="jokes-main">
<div className="container">
<div className="jokes-list">
<Link to=".">Get a random joke</Link>
<p>Here are a few more jokes to check out:</p>
<ul>
{data.jokeListItems.map(({ id, name }) => (
<li key={id}>
<Link to={id}>{name}</Link>
</li>
))}
</ul>
<Link to="new" className="button">
Add your own
</Link>
</div>
<div className="jokes-outlet">
<Outlet />
</div>
</div>
</main>
</div>
);
}
import type { ActionFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { logout } from "~/utils/session.server";
export const action = async ({
request,
}: ActionFunctionArgs) => logout(request);
export const loader = async () => redirect("/");
Hopefully getting the user in the loader and rendering them in the component was pretty straightforward. There are a few things I want to call out about other parts of my version of the code before we continue.
First, the new logout
route is just there to make it easy for us to logout. The reason that we're using an action (rather than a loader) is because we want to avoid CSRF problems by using a POST request rather than a GET request. This is why the logout button is a form and not a link. Additionally, Remix will only re-call our loaders when we perform an action
, so if we used a loader
then the cache would not get invalidated. The loader
is just there in case someone somehow lands on that page, we'll just redirect them back home.
<Link to="new" className="button">
Add your own
</Link>
Notice that the to
prop is set to "new" without any /
. This is the benefit of nested routing. You don't have to construct the entire URL. It can be relative. This is the same thing for the <Link to=".">Get a random joke</Link>
link which will effectively tell Remix to reload the data for the current route.
Terrific, now our app looks like this:
I suppose now would be a good time to add support for user registration! Did you forget like I did? π Well, let's get that bit added before moving on.
Luckily, all we need to do to support this is to update app/utils/session.server.ts
with a register
function that's pretty similar to our login
function. The difference here is that we need to use bcrypt.hash
to hash the password before we store it in the database. Then update the register
case in our app/routes/login.tsx
route to handle the registration.
πΏ Update both app/utils/session.server.ts
and app/routes/login.tsx
to handle user registration.
import {
createCookieSessionStorage,
redirect,
} from "@remix-run/node";
import bcrypt from "bcryptjs";
import { db } from "./db.server";
type LoginForm = {
password: string;
username: string;
};
export async function register({
password,
username,
}: LoginForm) {
const passwordHash = await bcrypt.hash(password, 10);
const user = await db.user.create({
data: { passwordHash, username },
});
return { id: user.id, username };
}
export async function login({
password,
username,
}: LoginForm) {
const user = await db.user.findUnique({
where: { username },
});
if (!user) {
return null;
}
const isCorrectPassword = await bcrypt.compare(
password,
user.passwordHash
);
if (!isCorrectPassword) {
return null;
}
return { id: user.id, username };
}
const sessionSecret = process.env.SESSION_SECRET;
if (!sessionSecret) {
throw new Error("SESSION_SECRET must be set");
}
const storage = createCookieSessionStorage({
cookie: {
name: "RJ_session",
// normally you want this to be `secure: true`
// but that doesn't work on localhost for Safari
// https://web.dev/when-to-use-local-https/
secure: process.env.NODE_ENV === "production",
secrets: [sessionSecret],
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 30,
httpOnly: true,
},
});
function getUserSession(request: Request) {
return storage.getSession(request.headers.get("Cookie"));
}
export async function getUserId(request: Request) {
const session = await getUserSession(request);
const userId = session.get("userId");
if (!userId || typeof userId !== "string") {
return null;
}
return userId;
}
export async function requireUserId(
request: Request,
redirectTo: string = new URL(request.url).pathname
) {
const session = await getUserSession(request);
const userId = session.get("userId");
if (!userId || typeof userId !== "string") {
const searchParams = new URLSearchParams([
["redirectTo", redirectTo],
]);
throw redirect(`/login?${searchParams}`);
}
return userId;
}
export async function getUser(request: Request) {
const userId = await getUserId(request);
if (typeof userId !== "string") {
return null;
}
const user = await db.user.findUnique({
select: { id: true, username: true },
where: { id: userId },
});
if (!user) {
throw await logout(request);
}
return user;
}
export async function logout(request: Request) {
const session = await getUserSession(request);
return redirect("/login", {
headers: {
"Set-Cookie": await storage.destroySession(session),
},
});
}
export async function createUserSession(
userId: string,
redirectTo: string
) {
const session = await storage.getSession();
session.set("userId", userId);
return redirect(redirectTo, {
headers: {
"Set-Cookie": await storage.commitSession(session),
},
});
}
import type {
ActionFunctionArgs,
LinksFunction,
} from "@remix-run/node";
import {
Link,
useActionData,
useSearchParams,
} from "@remix-run/react";
import stylesUrl from "~/styles/login.css";
import { db } from "~/utils/db.server";
import { badRequest } from "~/utils/request.server";
import {
createUserSession,
login,
register,
} from "~/utils/session.server";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: stylesUrl },
];
function validateUsername(username: string) {
if (username.length < 3) {
return "Usernames must be at least 3 characters long";
}
}
function validatePassword(password: string) {
if (password.length < 6) {
return "Passwords must be at least 6 characters long";
}
}
function validateUrl(url: string) {
const urls = ["/jokes", "/", "https://remix.run"];
if (urls.includes(url)) {
return url;
}
return "/jokes";
}
export const action = async ({
request,
}: ActionFunctionArgs) => {
const form = await request.formData();
const loginType = form.get("loginType");
const password = form.get("password");
const username = form.get("username");
const redirectTo = validateUrl(
(form.get("redirectTo") as string) || "/jokes"
);
if (
typeof loginType !== "string" ||
typeof password !== "string" ||
typeof username !== "string"
) {
return badRequest({
fieldErrors: null,
fields: null,
formError: "Form not submitted correctly.",
});
}
const fields = { loginType, password, username };
const fieldErrors = {
password: validatePassword(password),
username: validateUsername(username),
};
if (Object.values(fieldErrors).some(Boolean)) {
return badRequest({
fieldErrors,
fields,
formError: null,
});
}
switch (loginType) {
case "login": {
const user = await login({ username, password });
console.log({ user });
if (!user) {
return badRequest({
fieldErrors: null,
fields,
formError:
"Username/Password combination is incorrect",
});
}
return createUserSession(user.id, redirectTo);
}
case "register": {
const userExists = await db.user.findFirst({
where: { username },
});
if (userExists) {
return badRequest({
fieldErrors: null,
fields,
formError: `User with username ${username} already exists`,
});
}
const user = await register({ username, password });
if (!user) {
return badRequest({
fieldErrors: null,
fields,
formError:
"Something went wrong trying to create a new user.",
});
}
return createUserSession(user.id, redirectTo);
}
default: {
return badRequest({
fieldErrors: null,
fields,
formError: "Login type invalid",
});
}
}
};
export default function Login() {
const actionData = useActionData<typeof action>();
const [searchParams] = useSearchParams();
return (
<div className="container">
<div className="content" data-light="">
<h1>Login</h1>
<form method="post">
<input
type="hidden"
name="redirectTo"
value={
searchParams.get("redirectTo") ?? undefined
}
/>
<fieldset>
<legend className="sr-only">
Login or Register?
</legend>
<label>
<input
type="radio"
name="loginType"
value="login"
defaultChecked={
!actionData?.fields?.loginType ||
actionData?.fields?.loginType === "login"
}
/>{" "}
Login
</label>
<label>
<input
type="radio"
name="loginType"
value="register"
defaultChecked={
actionData?.fields?.loginType ===
"register"
}
/>{" "}
Register
</label>
</fieldset>
<div>
<label htmlFor="username-input">Username</label>
<input
type="text"
id="username-input"
name="username"
defaultValue={actionData?.fields?.username}
aria-invalid={Boolean(
actionData?.fieldErrors?.username
)}
aria-errormessage={
actionData?.fieldErrors?.username
? "username-error"
: undefined
}
/>
{actionData?.fieldErrors?.username ? (
<p
className="form-validation-error"
role="alert"
id="username-error"
>
{actionData.fieldErrors.username}
</p>
) : null}
</div>
<div>
<label htmlFor="password-input">Password</label>
<input
id="password-input"
name="password"
type="password"
defaultValue={actionData?.fields?.password}
aria-invalid={Boolean(
actionData?.fieldErrors?.password
)}
aria-errormessage={
actionData?.fieldErrors?.password
? "password-error"
: undefined
}
/>
{actionData?.fieldErrors?.password ? (
<p
className="form-validation-error"
role="alert"
id="password-error"
>
{actionData.fieldErrors.password}
</p>
) : null}
</div>
<div id="form-error-message">
{actionData?.formError ? (
<p
className="form-validation-error"
role="alert"
>
{actionData.formError}
</p>
) : null}
</div>
<button type="submit" className="button">
Submit
</button>
</form>
</div>
<div className="links">
<ul>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/jokes">Jokes</Link>
</li>
</ul>
</div>
</div>
);
}
Phew, there we go. Now users can register for a new account!
I'm sorry, but there's no way you'll be able to avoid errors at some point. Servers fall over, co-workers use // @ts-ignore
, and so on. So we need to just embrace the possibility of unexpected errors and deal with them.
Luckily, error handling in Remix is stellar. You may have used React's Error Boundary feature. With Remix, your route modules can export an ErrorBoundary
component, and it will be used. But it's even cooler because it works on the server too! Not only that, but it'll handle errors in loader
s and action
s too! Wowza! So let's get to it!
Just like the useLoaderData
hook to get data from the loader
and the useActionData
hook to get data from the action
, the ErrorBoundary
gets the thrown instance from the useRouteError
hook.
We're going to add four ErrorBoundary
s in our app. One in each of the child routes in app/routes/jokes.*
in case there's an error reading or processing stuff with the jokes, and one in app/root.tsx
to handle errors for everything else.
app/root.tsx
ErrorBoundary
is a bit more complicated
Remember that the app/root.tsx
module is responsible for rendering our <html>
element. When the ErrorBoundary
is rendered, it's rendered in place of the default export. That means the app/root.tsx
module should render the <html>
element along with the <Link />
elements, etc.
πΏ Add a simple ErrorBoundary
to each of those files.
import type { LinksFunction } from "@remix-run/node";
import {
Links,
LiveReload,
Outlet,
useRouteError,
} from "@remix-run/react";
import type { PropsWithChildren } from "react";
import globalLargeStylesUrl from "~/styles/global-large.css";
import globalMediumStylesUrl from "~/styles/global-medium.css";
import globalStylesUrl from "~/styles/global.css";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: globalStylesUrl },
{
rel: "stylesheet",
href: globalMediumStylesUrl,
media: "print, (min-width: 640px)",
},
{
rel: "stylesheet",
href: globalLargeStylesUrl,
media: "screen and (min-width: 1024px)",
},
];
function Document({
children,
title = "Remix: So great, it's funny!",
}: PropsWithChildren<{ title?: string }>) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
<title>{title}</title>
<Links />
</head>
<body>
{children}
<LiveReload />
</body>
</html>
);
}
export default function App() {
return (
<Document>
<Outlet />
</Document>
);
}
export function ErrorBoundary() {
const error = useRouteError();
const errorMessage =
error instanceof Error
? error.message
: "Unknown error";
return (
<Document title="Uh-oh!">
<div className="error-container">
<h1>App Error</h1>
<pre>{errorMessage}</pre>
</div>
</Document>
);
}
// ...
import {
Link,
useLoaderData,
useParams,
} from "@remix-run/react";
// ...
export function ErrorBoundary() {
const { jokeId } = useParams();
return (
<div className="error-container">
There was an error loading joke by the id "${jokeId}".
Sorry.
</div>
);
}
// ...
export function ErrorBoundary() {
return (
<div className="error-container">
Something unexpected went wrong. Sorry about that.
</div>
);
}
// ...
export function ErrorBoundary() {
return (
<div className="error-container">
I did a whoopsies.
</div>
);
}
Ok great, with those in place, let's check what happens when there's an error. Go ahead and just add this to the default component, loader, or action of each of the routes.
throw new Error("Testing Error Boundary");
Here's what I get:
What I love about this is that in the case of the children routes, the only unusable part of the app is the part that actually broke. The rest of the app is completely interactive. There's another point for the user's experience!
Sometimes users do things we can anticipate. I'm not talking about validation necessarily. I'm talking about things like whether the user's authenticated (status 401
) or authorized (status 403
) to do what they're trying to do. Or maybe they're looking for something that isn't there (status 404
).
It might help to think of the unexpected errors as 500-level errors (server errors) and the expected errors as 400-level errors (client errors).
To check for client error responses, Remix offers the isRouteErrorResponse
helper function. In the case that your server code detects a problem, it'll throw a Response
object. Remix then catches that thrown Response
and renders your ErrorBoundary
. Since you can throw whatever you want, the isRouteErrorResponse
helper function is a way to check if the thrown instance is a Response
object.
One last thing, this isn't for form validations and stuff. We already discussed that earlier with useActionData
. This is just for situations where the user did something that means we can't reasonably render our default component, so we want to render something else instead.
ErrorBoundary
allows our default exports to represent the "happy path" and not worry about errors. If the default component is rendered, then we can assume all is right with the world.
With that understanding, we're going to add a isRouteErrorResponse
check to the following routes:
app/root.tsx
- Just as a last resort fallback.app/routes/jokes.$jokeId.tsx
- When a user tries to access a joke that doesn't exist (404).app/routes/jokes.new.tsx
- When a user tries to go to this page without being authenticated (401). Right now they'll just get redirected to the login if they try to submit it without authenticating. That would be super annoying to spend time writing a joke only to get redirected. Rather than inexplicably redirecting them, we could render a message that says they need to authenticate first.app/routes/jokes._index.tsx
- If there are no jokes in the database then a random joke is 404-not found. (simulate this by deleting the prisma/dev.db
and running npx prisma db push
. Don't forget to run npx prisma db seed
afterward to get your seed data back.)πΏ Let's add these isRouteErrorResponse
checks to the routes.
import type { LinksFunction } from "@remix-run/node";
import {
isRouteErrorResponse,
Links,
LiveReload,
Outlet,
useRouteError,
} from "@remix-run/react";
import type { PropsWithChildren } from "react";
import globalLargeStylesUrl from "~/styles/global-large.css";
import globalMediumStylesUrl from "~/styles/global-medium.css";
import globalStylesUrl from "~/styles/global.css";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: globalStylesUrl },
{
rel: "stylesheet",
href: globalMediumStylesUrl,
media: "print, (min-width: 640px)",
},
{
rel: "stylesheet",
href: globalLargeStylesUrl,
media: "screen and (min-width: 1024px)",
},
];
function Document({
children,
title = "Remix: So great, it's funny!",
}: PropsWithChildren<{ title?: string }>) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
<title>{title}</title>
<Links />
</head>
<body>
{children}
<LiveReload />
</body>
</html>
);
}
export default function App() {
return (
<Document>
<Outlet />
</Document>
);
}
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<Document
title={`${error.status} ${error.statusText}`}
>
<div className="error-container">
<h1>
{error.status} {error.statusText}
</h1>
</div>
</Document>
);
}
const errorMessage =
error instanceof Error
? error.message
: "Unknown error";
return (
<Document title="Uh-oh!">
<div className="error-container">
<h1>App Error</h1>
<pre>{errorMessage}</pre>
</div>
</Document>
);
}
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import {
isRouteErrorResponse,
Link,
useLoaderData,
useParams,
useRouteError,
} from "@remix-run/react";
import { db } from "~/utils/db.server";
export const loader = async ({
params,
}: LoaderFunctionArgs) => {
const joke = await db.joke.findUnique({
where: { id: params.jokeId },
});
if (!joke) {
throw new Response("What a joke! Not found.", {
status: 404,
});
}
return json({ joke });
};
export default function JokeRoute() {
const data = useLoaderData<typeof loader>();
return (
<div>
<p>Here's your hilarious joke:</p>
<p>{data.joke.content}</p>
<Link to=".">"{data.joke.name}" Permalink</Link>
</div>
);
}
export function ErrorBoundary() {
const { jokeId } = useParams();
const error = useRouteError();
if (isRouteErrorResponse(error) && error.status === 404) {
return (
<div className="error-container">
Huh? What the heck is "{jokeId}"?
</div>
);
}
return (
<div className="error-container">
There was an error loading joke by the id "${jokeId}".
Sorry.
</div>
);
}
import { json } from "@remix-run/node";
import {
isRouteErrorResponse,
Link,
useLoaderData,
useRouteError,
} from "@remix-run/react";
import { db } from "~/utils/db.server";
export const loader = async () => {
const count = await db.joke.count();
const randomRowNumber = Math.floor(Math.random() * count);
const [randomJoke] = await db.joke.findMany({
skip: randomRowNumber,
take: 1,
});
if (!randomJoke) {
throw new Response("No random joke found", {
status: 404,
});
}
return json({ randomJoke });
};
export default function JokesIndexRoute() {
const data = useLoaderData<typeof loader>();
return (
<div>
<p>Here's a random joke:</p>
<p>{data.randomJoke.content}</p>
<Link to={data.randomJoke.id}>
"{data.randomJoke.name}" Permalink
</Link>
</div>
);
}
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error) && error.status === 404) {
return (
<div className="error-container">
<p>There are no jokes to display.</p>
<Link to="new">Add your own</Link>
</div>
);
}
return (
<div className="error-container">
I did a whoopsies.
</div>
);
}
import type {
ActionFunctionArgs,
LoaderFunctionArgs,
} from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import {
isRouteErrorResponse,
Link,
useActionData,
useRouteError,
} from "@remix-run/react";
import { db } from "~/utils/db.server";
import { badRequest } from "~/utils/request.server";
import {
getUserId,
requireUserId,
} from "~/utils/session.server";
export const loader = async ({
request,
}: LoaderFunctionArgs) => {
const userId = await getUserId(request);
if (!userId) {
throw new Response("Unauthorized", { status: 401 });
}
return json({});
};
function validateJokeContent(content: string) {
if (content.length < 10) {
return "That joke is too short";
}
}
function validateJokeName(name: string) {
if (name.length < 3) {
return "That joke's name is too short";
}
}
export const action = async ({
request,
}: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const form = await request.formData();
const content = form.get("content");
const name = form.get("name");
if (
typeof content !== "string" ||
typeof name !== "string"
) {
return badRequest({
fieldErrors: null,
fields: null,
formError: "Form not submitted correctly.",
});
}
const fieldErrors = {
content: validateJokeContent(content),
name: validateJokeName(name),
};
const fields = { content, name };
if (Object.values(fieldErrors).some(Boolean)) {
return badRequest({
fieldErrors,
fields,
formError: null,
});
}
const joke = await db.joke.create({
data: { ...fields, jokesterId: userId },
});
return redirect(`/jokes/${joke.id}`);
};
export default function NewJokeRoute() {
const actionData = useActionData<typeof action>();
return (
<div>
<p>Add your own hilarious joke</p>
<form method="post">
<div>
<label>
Name:{" "}
<input
defaultValue={actionData?.fields?.name}
name="name"
type="text"
aria-invalid={Boolean(
actionData?.fieldErrors?.name
)}
aria-errormessage={
actionData?.fieldErrors?.name
? "name-error"
: undefined
}
/>
</label>
{actionData?.fieldErrors?.name ? (
<p
className="form-validation-error"
id="name-error"
role="alert"
>
{actionData.fieldErrors.name}
</p>
) : null}
</div>
<div>
<label>
Content:{" "}
<textarea
defaultValue={actionData?.fields?.content}
name="content"
aria-invalid={Boolean(
actionData?.fieldErrors?.content
)}
aria-errormessage={
actionData?.fieldErrors?.content
? "content-error"
: undefined
}
/>
</label>
{actionData?.fieldErrors?.content ? (
<p
className="form-validation-error"
id="content-error"
role="alert"
>
{actionData.fieldErrors.content}
</p>
) : null}
</div>
<div>
{actionData?.formError ? (
<p
className="form-validation-error"
role="alert"
>
{actionData.formError}
</p>
) : null}
<button type="submit" className="button">
Add
</button>
</div>
</form>
</div>
);
}
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error) && error.status === 401) {
return (
<div className="error-container">
<p>You must be logged in to create a joke.</p>
<Link to="/login">Login</Link>
</div>
);
}
return (
<div className="error-container">
Something unexpected went wrong. Sorry about that.
</div>
);
}
Here's what I've got with that:
Awesome! We're ready to handle errors, and it didn't complicate our happy path one bit! π
Oh, and don't you love how it's all contextual? So the rest of the app continues to function just as well. Another point for user experience πͺ
You know what, while we're adding ErrorBoundary
s. Why don't we improve the app/routes/jokes.$jokeId.tsx
route a bit by allowing users to delete the joke if they own it. If they don't, we can give them a 403 error in the ErrorBoundary
.
One thing to keep in mind with delete
is that HTML forms only support method="get"
and method="post"
. They don't support method="delete"
. So to make sure our form will work with and without JavaScript, it's a good idea to do something like this:
<form method="post">
<button name="intent" type="submit" value="delete">
Delete
</button>
</form>
And then the action
can determine whether the intention is to delete based on the request.formData().get('intent')
.
πΏ Add a delete capability to app/routes/jokes.$jokeId.tsx
route.
import type {
ActionFunctionArgs,
LoaderFunctionArgs,
} from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import {
isRouteErrorResponse,
Link,
useLoaderData,
useParams,
useRouteError,
} from "@remix-run/react";
import { db } from "~/utils/db.server";
import { requireUserId } from "~/utils/session.server";
export const loader = async ({
params,
}: LoaderFunctionArgs) => {
const joke = await db.joke.findUnique({
where: { id: params.jokeId },
});
if (!joke) {
throw new Response("What a joke! Not found.", {
status: 404,
});
}
return json({ joke });
};
export const action = async ({
params,
request,
}: ActionFunctionArgs) => {
const form = await request.formData();
if (form.get("intent") !== "delete") {
throw new Response(
`The intent ${form.get("intent")} is not supported`,
{ status: 400 }
);
}
const userId = await requireUserId(request);
const joke = await db.joke.findUnique({
where: { id: params.jokeId },
});
if (!joke) {
throw new Response("Can't delete what does not exist", {
status: 404,
});
}
if (joke.jokesterId !== userId) {
throw new Response(
"Pssh, nice try. That's not your joke",
{ status: 403 }
);
}
await db.joke.delete({ where: { id: params.jokeId } });
return redirect("/jokes");
};
export default function JokeRoute() {
const data = useLoaderData<typeof loader>();
return (
<div>
<p>Here's your hilarious joke:</p>
<p>{data.joke.content}</p>
<Link to=".">"{data.joke.name}" Permalink</Link>
<form method="post">
<button
className="button"
name="intent"
type="submit"
value="delete"
>
Delete
</button>
</form>
</div>
);
}
export function ErrorBoundary() {
const { jokeId } = useParams();
const error = useRouteError();
if (isRouteErrorResponse(error)) {
if (error.status === 400) {
return (
<div className="error-container">
What you're trying to do is not allowed.
</div>
);
}
if (error.status === 403) {
return (
<div className="error-container">
Sorry, but "{jokeId}" is not your joke.
</div>
);
}
if (error.status === 404) {
return (
<div className="error-container">
Huh? What the heck is "{jokeId}"?
</div>
);
}
}
return (
<div className="error-container">
There was an error loading joke by the id "${jokeId}".
Sorry.
</div>
);
}
Now that people will get a proper error message if they try to delete a joke that is not theirs, maybe we could also simply hide the delete button if the user doesn't own the joke.
import type {
ActionFunctionArgs,
LoaderFunctionArgs,
} from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import {
isRouteErrorResponse,
Link,
useLoaderData,
useParams,
useRouteError,
} from "@remix-run/react";
import { db } from "~/utils/db.server";
import {
getUserId,
requireUserId,
} from "~/utils/session.server";
export const loader = async ({
params,
request,
}: LoaderFunctionArgs) => {
const userId = await getUserId(request);
const joke = await db.joke.findUnique({
where: { id: params.jokeId },
});
if (!joke) {
throw new Response("What a joke! Not found.", {
status: 404,
});
}
return json({
isOwner: userId === joke.jokesterId,
joke,
});
};
export const action = async ({
params,
request,
}: ActionFunctionArgs) => {
const form = await request.formData();
if (form.get("intent") !== "delete") {
throw new Response(
`The intent ${form.get("intent")} is not supported`,
{ status: 400 }
);
}
const userId = await requireUserId(request);
const joke = await db.joke.findUnique({
where: { id: params.jokeId },
});
if (!joke) {
throw new Response("Can't delete what does not exist", {
status: 404,
});
}
if (joke.jokesterId !== userId) {
throw new Response(
"Pssh, nice try. That's not your joke",
{ status: 403 }
);
}
await db.joke.delete({ where: { id: params.jokeId } });
return redirect("/jokes");
};
export default function JokeRoute() {
const data = useLoaderData<typeof loader>();
return (
<div>
<p>Here's your hilarious joke:</p>
<p>{data.joke.content}</p>
<Link to=".">"{data.joke.name}" Permalink</Link>
{data.isOwner ? (
<form method="post">
<button
className="button"
name="intent"
type="submit"
value="delete"
>
Delete
</button>
</form>
) : null}
</div>
);
}
export function ErrorBoundary() {
const { jokeId } = useParams();
const error = useRouteError();
if (isRouteErrorResponse(error)) {
if (error.status === 400) {
return (
<div className="error-container">
What you're trying to do is not allowed.
</div>
);
}
if (error.status === 403) {
return (
<div className="error-container">
Sorry, but "{jokeId}" is not your joke.
</div>
);
}
if (error.status === 404) {
return (
<div className="error-container">
Huh? What the heck is "{jokeId}"?
</div>
);
}
}
return (
<div className="error-container">
There was an error loading joke by the id "${jokeId}".
Sorry.
</div>
);
}
Meta tags are useful for SEO and social media. The tricky bit is that often the part of the code that has access to the data you need is in components that request/use the data.
This is why Remix has the meta
export. Why don't you go through and add a few useful meta tags to the following routes:
app/routes/login.tsx
app/routes/jokes.$jokeId.tsx
- (this one you can reference the joke's name in the title which is fun)But before you get started, remember that we're in charge of rendering everything from the <html>
to the </html>
which means we need to make sure these meta
tags are rendered in the <head>
of the <html>
. This is why Remix gives us a Meta
component.
πΏ Add the Meta
component to app/root.tsx
, and add the meta
export to the routes mentioned above. The Meta
component needs to be placed above the existing <title>
tag to be able to overwrite it when provided.
import type {
LinksFunction,
MetaFunction,
} from "@remix-run/node";
import {
isRouteErrorResponse,
Links,
LiveReload,
Meta,
Outlet,
useRouteError,
} from "@remix-run/react";
import type { PropsWithChildren } from "react";
import globalLargeStylesUrl from "~/styles/global-large.css";
import globalMediumStylesUrl from "~/styles/global-medium.css";
import globalStylesUrl from "~/styles/global.css";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: globalStylesUrl },
{
rel: "stylesheet",
href: globalMediumStylesUrl,
media: "print, (min-width: 640px)",
},
{
rel: "stylesheet",
href: globalLargeStylesUrl,
media: "screen and (min-width: 1024px)",
},
];
export const meta: MetaFunction = () => {
const description =
"Learn Remix and laugh at the same time!";
return [
{ name: "description", content: description },
{ name: "twitter:description", content: description },
{ title: "Remix: So great, it's funny!" },
];
};
function Document({
children,
title,
}: PropsWithChildren<{ title?: string }>) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
<meta name="keywords" content="Remix,jokes" />
<meta
name="twitter:image"
content="https://remix-jokes.lol/social.png"
/>
<meta
name="twitter:card"
content="summary_large_image"
/>
<meta name="twitter:creator" content="@remix_run" />
<meta name="twitter:site" content="@remix_run" />
<meta name="twitter:title" content="Remix Jokes" />
<Meta />
{title ? <title>{title}</title> : null}
<Links />
</head>
<body>
{children}
<LiveReload />
</body>
</html>
);
}
export default function App() {
return (
<Document>
<Outlet />
</Document>
);
}
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<Document
title={`${error.status} ${error.statusText}`}
>
<div className="error-container">
<h1>
{error.status} {error.statusText}
</h1>
</div>
</Document>
);
}
const errorMessage =
error instanceof Error
? error.message
: "Unknown error";
return (
<Document title="Uh-oh!">
<div className="error-container">
<h1>App Error</h1>
<pre>{errorMessage}</pre>
</div>
</Document>
);
}
import type {
ActionFunctionArgs,
LinksFunction,
MetaFunction,
} from "@remix-run/node";
import {
Link,
useActionData,
useSearchParams,
} from "@remix-run/react";
import stylesUrl from "~/styles/login.css";
import { db } from "~/utils/db.server";
import { badRequest } from "~/utils/request.server";
import {
createUserSession,
login,
register,
} from "~/utils/session.server";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: stylesUrl },
];
export const meta: MetaFunction = () => {
const description =
"Login to submit your own jokes to Remix Jokes!";
return [
{ name: "description", content: description },
{ name: "twitter:description", content: description },
{ title: "Remix Jokes | Login" },
];
};
function validateUsername(username: string) {
if (username.length < 3) {
return "Usernames must be at least 3 characters long";
}
}
function validatePassword(password: string) {
if (password.length < 6) {
return "Passwords must be at least 6 characters long";
}
}
function validateUrl(url: string) {
const urls = ["/jokes", "/", "https://remix.run"];
if (urls.includes(url)) {
return url;
}
return "/jokes";
}
export const action = async ({
request,
}: ActionFunctionArgs) => {
const form = await request.formData();
const loginType = form.get("loginType");
const password = form.get("password");
const username = form.get("username");
const redirectTo = validateUrl(
(form.get("redirectTo") as string) || "/jokes"
);
if (
typeof loginType !== "string" ||
typeof password !== "string" ||
typeof username !== "string"
) {
return badRequest({
fieldErrors: null,
fields: null,
formError: "Form not submitted correctly.",
});
}
const fields = { loginType, password, username };
const fieldErrors = {
password: validatePassword(password),
username: validateUsername(username),
};
if (Object.values(fieldErrors).some(Boolean)) {
return badRequest({
fieldErrors,
fields,
formError: null,
});
}
switch (loginType) {
case "login": {
const user = await login({ username, password });
console.log({ user });
if (!user) {
return badRequest({
fieldErrors: null,
fields,
formError:
"Username/Password combination is incorrect",
});
}
return createUserSession(user.id, redirectTo);
}
case "register": {
const userExists = await db.user.findFirst({
where: { username },
});
if (userExists) {
return badRequest({
fieldErrors: null,
fields,
formError: `User with username ${username} already exists`,
});
}
const user = await register({ username, password });
if (!user) {
return badRequest({
fieldErrors: null,
fields,
formError:
"Something went wrong trying to create a new user.",
});
}
return createUserSession(user.id, redirectTo);
}
default: {
return badRequest({
fieldErrors: null,
fields,
formError: "Login type invalid",
});
}
}
};
export default function Login() {
const actionData = useActionData<typeof action>();
const [searchParams] = useSearchParams();
return (
<div className="container">
<div className="content" data-light="">
<h1>Login</h1>
<form method="post">
<input
type="hidden"
name="redirectTo"
value={
searchParams.get("redirectTo") ?? undefined
}
/>
<fieldset>
<legend className="sr-only">
Login or Register?
</legend>
<label>
<input
type="radio"
name="loginType"
value="login"
defaultChecked={
!actionData?.fields?.loginType ||
actionData?.fields?.loginType === "login"
}
/>{" "}
Login
</label>
<label>
<input
type="radio"
name="loginType"
value="register"
defaultChecked={
actionData?.fields?.loginType ===
"register"
}
/>{" "}
Register
</label>
</fieldset>
<div>
<label htmlFor="username-input">Username</label>
<input
type="text"
id="username-input"
name="username"
defaultValue={actionData?.fields?.username}
aria-invalid={Boolean(
actionData?.fieldErrors?.username
)}
aria-errormessage={
actionData?.fieldErrors?.username
? "username-error"
: undefined
}
/>
{actionData?.fieldErrors?.username ? (
<p
className="form-validation-error"
role="alert"
id="username-error"
>
{actionData.fieldErrors.username}
</p>
) : null}
</div>
<div>
<label htmlFor="password-input">Password</label>
<input
id="password-input"
name="password"
type="password"
defaultValue={actionData?.fields?.password}
aria-invalid={Boolean(
actionData?.fieldErrors?.password
)}
aria-errormessage={
actionData?.fieldErrors?.password
? "password-error"
: undefined
}
/>
{actionData?.fieldErrors?.password ? (
<p
className="form-validation-error"
role="alert"
id="password-error"
>
{actionData.fieldErrors.password}
</p>
) : null}
</div>
<div id="form-error-message">
{actionData?.formError ? (
<p
className="form-validation-error"
role="alert"
>
{actionData.formError}
</p>
) : null}
</div>
<button type="submit" className="button">
Submit
</button>
</form>
</div>
<div className="links">
<ul>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/jokes">Jokes</Link>
</li>
</ul>
</div>
</div>
);
}
import type {
ActionFunctionArgs,
LoaderFunctionArgs,
MetaFunction,
} from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import {
isRouteErrorResponse,
Link,
useLoaderData,
useParams,
useRouteError,
} from "@remix-run/react";
import { db } from "~/utils/db.server";
import {
getUserId,
requireUserId,
} from "~/utils/session.server";
export const meta: MetaFunction<typeof loader> = ({
data,
}) => {
const { description, title } = data
? {
description: `Enjoy the "${data.joke.name}" joke and much more`,
title: `"${data.joke.name}" joke`,
}
: { description: "No joke found", title: "No joke" };
return [
{ name: "description", content: description },
{ name: "twitter:description", content: description },
{ title },
];
};
export const loader = async ({
params,
request,
}: LoaderFunctionArgs) => {
const userId = await getUserId(request);
const joke = await db.joke.findUnique({
where: { id: params.jokeId },
});
if (!joke) {
throw new Response("What a joke! Not found.", {
status: 404,
});
}
return json({
isOwner: userId === joke.jokesterId,
joke,
});
};
export const action = async ({
params,
request,
}: ActionFunctionArgs) => {
const form = await request.formData();
if (form.get("intent") !== "delete") {
throw new Response(
`The intent ${form.get("intent")} is not supported`,
{ status: 400 }
);
}
const userId = await requireUserId(request);
const joke = await db.joke.findUnique({
where: { id: params.jokeId },
});
if (!joke) {
throw new Response("Can't delete what does not exist", {
status: 404,
});
}
if (joke.jokesterId !== userId) {
throw new Response(
"Pssh, nice try. That's not your joke",
{ status: 403 }
);
}
await db.joke.delete({ where: { id: params.jokeId } });
return redirect("/jokes");
};
export default function JokeRoute() {
const data = useLoaderData<typeof loader>();
return (
<div>
<p>Here's your hilarious joke:</p>
<p>{data.joke.content}</p>
<Link to=".">"{data.joke.name}" Permalink</Link>
{data.isOwner ? (
<form method="post">
<button
className="button"
name="intent"
type="submit"
value="delete"
>
Delete
</button>
</form>
) : null}
</div>
);
}
export function ErrorBoundary() {
const { jokeId } = useParams();
const error = useRouteError();
if (isRouteErrorResponse(error)) {
if (error.status === 400) {
return (
<div className="error-container">
What you're trying to do is not allowed.
</div>
);
}
if (error.status === 403) {
return (
<div className="error-container">
Sorry, but "{jokeId}" is not your joke.
</div>
);
}
if (error.status === 404) {
return (
<div className="error-container">
Huh? What the heck is "{jokeId}"?
</div>
);
}
}
return (
<div className="error-container">
There was an error loading joke by the id "${jokeId}".
Sorry.
</div>
);
}
Sweet! Now search engines and social media platforms will like our site a bit better.
Sometimes we want our routes to render something other than an HTML document. For example, maybe you have an endpoint that generates your social image for a blog post, or the image for a product, or the CSV data for a report, or an RSS feed, or sitemap, or maybe you want to implement API routes for your mobile app, or anything else.
This is what Resource Routes are for. I think it'd be cool to have an RSS feed of all our jokes. I think it would make sense to be at the URL /jokes.rss
. For that to work, you'll need to escape the .
because that character has special meaning in Remix route filenames. Learn more about escaping special characters here.
For this one, you'll probably want to at least peek at the example unless you want to go read up on the RSS spec π .
πΏ Make a /jokes.rss
route.
import type { LoaderFunctionArgs } from "@remix-run/node";
import { db } from "~/utils/db.server";
function escapeCdata(s: string) {
return s.replace(/\]\]>/g, "]]]]><![CDATA[>");
}
function escapeHtml(s: string) {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
export const loader = async ({
request,
}: LoaderFunctionArgs) => {
const jokes = await db.joke.findMany({
include: { jokester: { select: { username: true } } },
orderBy: { createdAt: "desc" },
take: 100,
});
const host =
request.headers.get("X-Forwarded-Host") ??
request.headers.get("host");
if (!host) {
throw new Error("Could not determine domain URL.");
}
const protocol = host.includes("localhost")
? "http"
: "https";
const domain = `${protocol}://${host}`;
const jokesUrl = `${domain}/jokes`;
const rssString = `
<rss xmlns:blogChannel="${jokesUrl}" version="2.0">
<channel>
<title>Remix Jokes</title>
<link>${jokesUrl}</link>
<description>Some funny jokes</description>
<language>en-us</language>
<generator>Kody the Koala</generator>
<ttl>40</ttl>
${jokes
.map((joke) =>
`
<item>
<title><![CDATA[${escapeCdata(
joke.name
)}]]></title>
<description><![CDATA[A funny joke called ${escapeHtml(
joke.name
)}]]></description>
<author><![CDATA[${escapeCdata(
joke.jokester.username
)}]]></author>
<pubDate>${joke.createdAt.toUTCString()}</pubDate>
<link>${jokesUrl}/${joke.id}</link>
<guid>${jokesUrl}/${joke.id}</guid>
</item>
`.trim()
)
.join("\n")}
</channel>
</rss>
`.trim();
return new Response(rssString, {
headers: {
"Cache-Control": `public, max-age=${
60 * 10
}, s-maxage=${60 * 60 * 24}`,
"Content-Type": "application/xml",
"Content-Length": String(
Buffer.byteLength(rssString)
),
},
});
};
Wahoo! You can seriously do anything you can imagine with this API. You could even make a JSON API for a native version of your app if you wanted to. Lots of power here.
πΏ Feel free to throw a link to that RSS feed on app/routes/_index.tsx
and app/routes/jokes.tsx
pages. Note that if you use <Link />
you'll want to use the reloadDocument
prop because you can't do a client-side transition to a URL that's not technically part of the React app.
import type { LinksFunction } from "@remix-run/node";
import { Link } from "@remix-run/react";
import stylesUrl from "~/styles/index.css";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: stylesUrl },
];
export default function IndexRoute() {
return (
<div className="container">
<div className="content">
<h1>
Remix <span>Jokes!</span>
</h1>
<nav>
<ul>
<li>
<Link to="jokes">Read Jokes</Link>
</li>
<li>
<Link reloadDocument to="/jokes.rss">
RSS
</Link>
</li>
</ul>
</nav>
</div>
</div>
);
}
import type {
LinksFunction,
LoaderFunctionArgs,
} from "@remix-run/node";
import { json } from "@remix-run/node";
import {
Link,
Outlet,
useLoaderData,
} from "@remix-run/react";
import stylesUrl from "~/styles/jokes.css";
import { db } from "~/utils/db.server";
import { getUser } from "~/utils/session.server";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: stylesUrl },
];
export const loader = async ({
request,
}: LoaderFunctionArgs) => {
const jokeListItems = await db.joke.findMany({
orderBy: { createdAt: "desc" },
select: { id: true, name: true },
take: 5,
});
const user = await getUser(request);
return json({ jokeListItems, user });
};
export default function JokesRoute() {
const data = useLoaderData<typeof loader>();
return (
<div className="jokes-layout">
<header className="jokes-header">
<div className="container">
<h1 className="home-link">
<Link
to="/"
title="Remix Jokes"
aria-label="Remix Jokes"
>
<span className="logo">π€ͺ</span>
<span className="logo-medium">Jπ€ͺKES</span>
</Link>
</h1>
{data.user ? (
<div className="user-info">
<span>{`Hi ${data.user.username}`}</span>
<form action="/logout" method="post">
<button type="submit" className="button">
Logout
</button>
</form>
</div>
) : (
<Link to="/login">Login</Link>
)}
</div>
</header>
<main className="jokes-main">
<div className="container">
<div className="jokes-list">
<Link to=".">Get a random joke</Link>
<p>Here are a few more jokes to check out:</p>
<ul>
{data.jokeListItems.map(({ id, name }) => (
<li key={id}>
<Link to={id}>{name}</Link>
</li>
))}
</ul>
<Link to="new" className="button">
Add your own
</Link>
</div>
<div className="jokes-outlet">
<Outlet />
</div>
</div>
</main>
<footer className="jokes-footer">
<div className="container">
<Link reloadDocument to="/jokes.rss">
RSS
</Link>
</div>
</footer>
</div>
);
}
Maybe we should actually include JavaScript on our JavaScript app. π
Seriously, pull up your network tab and navigate to our app.
Did you notice that our app isn't loading any JavaScript before now? π This actually is pretty significant. Our entire app can work without JavaScript on the page at all. This is because Remix leverages the platform so well for us.
Why does it matter that our app works without JavaScript? Is it because we're worried about the 0.002% of users who run around with JS disabled? Not really. It's because not everyone's connected to your app on a lightning-fast connection and sometimes JavaScript takes some time to load or fails to load at all. Making your app functional without JavaScript means that when that happens, your app still works for your users even before the JavaScript finishes loading.
Another point for user experience!
There are reasons to include JavaScript on the page. For example, some common UI experiences can't be accessible without JavaScript (focus management in particular is not great when you have full-page reloads all over the place). And we can make an even nicer user experience with optimistic UI (coming soon) when we have JavaScript on the page. But we thought it'd be cool to show you how far you can get with Remix without JavaScript for your users on poor network connections. πͺ
Ok, so let's load JavaScript on this page now π
πΏ Use Remix's <Scripts />
component to load all the JavaScript files in app/root.tsx
.
import type {
LinksFunction,
MetaFunction,
} from "@remix-run/node";
import {
isRouteErrorResponse,
Links,
LiveReload,
Meta,
Outlet,
Scripts,
useRouteError,
} from "@remix-run/react";
import type { PropsWithChildren } from "react";
import globalLargeStylesUrl from "~/styles/global-large.css";
import globalMediumStylesUrl from "~/styles/global-medium.css";
import globalStylesUrl from "~/styles/global.css";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: globalStylesUrl },
{
rel: "stylesheet",
href: globalMediumStylesUrl,
media: "print, (min-width: 640px)",
},
{
rel: "stylesheet",
href: globalLargeStylesUrl,
media: "screen and (min-width: 1024px)",
},
];
export const meta: MetaFunction = () => {
const description =
"Learn Remix and laugh at the same time!";
return [
{ name: "description", content: description },
{ name: "twitter:description", content: description },
{ title: "Remix: So great, it's funny!" },
];
};
function Document({
children,
title,
}: PropsWithChildren<{ title?: string }>) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
<meta name="keywords" content="Remix,jokes" />
<meta
name="twitter:image"
content="https://remix-jokes.lol/social.png"
/>
<meta
name="twitter:card"
content="summary_large_image"
/>
<meta name="twitter:creator" content="@remix_run" />
<meta name="twitter:site" content="@remix_run" />
<meta name="twitter:title" content="Remix Jokes" />
<Meta />
{title ? <title>{title}</title> : null}
<Links />
</head>
<body>
{children}
<Scripts />
<LiveReload />
</body>
</html>
);
}
export default function App() {
return (
<Document>
<Outlet />
</Document>
);
}
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<Document
title={`${error.status} ${error.statusText}`}
>
<div className="error-container">
<h1>
{error.status} {error.statusText}
</h1>
</div>
</Document>
);
}
const errorMessage =
error instanceof Error
? error.message
: "Unknown error";
return (
<Document title="Uh-oh!">
<div className="error-container">
<h1>App Error</h1>
<pre>{errorMessage}</pre>
</div>
</Document>
);
}
πΏ Another thing we can do now is you can console.error(error);
in all your ErrorBoundary
components, and you'll get even server-side errors logged in the browser's console. π€―
import type {
LinksFunction,
MetaFunction,
} from "@remix-run/node";
import {
isRouteErrorResponse,
Links,
LiveReload,
Meta,
Outlet,
Scripts,
useRouteError,
} from "@remix-run/react";
import type { PropsWithChildren } from "react";
import globalLargeStylesUrl from "~/styles/global-large.css";
import globalMediumStylesUrl from "~/styles/global-medium.css";
import globalStylesUrl from "~/styles/global.css";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: globalStylesUrl },
{
rel: "stylesheet",
href: globalMediumStylesUrl,
media: "print, (min-width: 640px)",
},
{
rel: "stylesheet",
href: globalLargeStylesUrl,
media: "screen and (min-width: 1024px)",
},
];
export const meta: MetaFunction = () => {
const description =
"Learn Remix and laugh at the same time!";
return [
{ name: "description", content: description },
{ name: "twitter:description", content: description },
{ title: "Remix: So great, it's funny!" },
];
};
function Document({
children,
title,
}: PropsWithChildren<{ title?: string }>) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
<meta name="keywords" content="Remix,jokes" />
<meta
name="twitter:image"
content="https://remix-jokes.lol/social.png"
/>
<meta
name="twitter:card"
content="summary_large_image"
/>
<meta name="twitter:creator" content="@remix_run" />
<meta name="twitter:site" content="@remix_run" />
<meta name="twitter:title" content="Remix Jokes" />
<Meta />
{title ? <title>{title}</title> : null}
<Links />
</head>
<body>
{children}
<Scripts />
<LiveReload />
</body>
</html>
);
}
export default function App() {
return (
<Document>
<Outlet />
</Document>
);
}
export function ErrorBoundary() {
const error = useRouteError();
console.error(error);
if (isRouteErrorResponse(error)) {
return (
<Document
title={`${error.status} ${error.statusText}`}
>
<div className="error-container">
<h1>
{error.status} {error.statusText}
</h1>