Jokes App Tutorial
You want to learn Remix? You’re in the right place. Let’s build Remix Jokes!
Work through this tutorial with Kent in this live stream
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.
Click me
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.
💿 Hello, I’m Rachel the Remix Disc. I’ll show up whenever you have to actually do something.
Feel free to explore as you go, but if you deviate from the tutorial too much (like trying to deploy before getting to that step for example), you may find it doesn’t work like you expected because you missed something important.
We won’t add JavaScript to the browser until toward the end of the tutorial. This is to show you how well your application will work when JavaScript takes a long time to load (or fails to load at all). So until we actually add JavaScript to the page, you won’t be able to use things like useState
until we get to that step.
Outline
Here are the topics we’ll be covering in this tutorial:
- Generating a new Remix project
- Conventional files
- Routes (including the nested variety ✨)
- Styling
- Database interactions (via
sqlite
andprisma
) - Mutations
- Validation
- Authentication
- Error handling: Both unexpected (the dev made a whoopsies) and expected (the end-user made a whoopsies) errors
- SEO with Meta Tags
- JavaScript…
- Resource Routes
- Deployment
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).
Prerequisites
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 deploy 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!
Generating a new Remix project
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”, choose “Just the basics”, then the “Remix App Server” deploy target, use TypeScript, and have it run the installation for us:
? Where would you like to create your app? remix-jokes
? What type of app do you want to create? Just the basics
? Where do you want to deploy? Choose Remix App Server if you're unsure; it's easy to change deployment targets. Remix App Server
? TypeScript or JavaScript? TypeScript
? Do you want me to run `npm install`? 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.
Explore the project structure
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.
Routes
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.
app/routes/_index.tsx
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
:
app/root.tsx
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>
);
}
Remember to have the dev server running with 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).
app/routes/jokes.tsx
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
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.
app/routes/jokes.new.tsx
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:
Parameterized Routes
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!):
app/routes/jokes.$jokeId.tsx
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!
Styling
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.
app/routes/_index.tsx
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
💿 Go ahead and add the Remix <Links />
component to app/root.tsx
within the <head>
.
app/root.tsx
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.
💿 Copy this to `app/styles/global.css`
@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;
}
``` 💿 Copy this to \`app/styles/global-large.css\`
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;
}
`` 💿 Copy this to \
app/styles/global-medium.css`
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;
}
``` 💿 Copy this to \`app/styles/index.css\`
/*
- 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;
}
}
`` 💿 Copy this to \
app/styles/jokes.css`
.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.
The 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.
Did you know that <link />
tags can use media queries? Check out the MDN page for .
app/root.tsx
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>
);
}
``` app/routes/jokes.tsx
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 (
🤪 J🤪KES
Here are a few more jokes to check out:
- Hippo
💿 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.
app/routes/\_index.tsx
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 (
Remix Jokes!
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](https://www.styled-components.com)). 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](https://tailwindcss.com), 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.
## Database
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](https://firebase.google.com), [Supabase](https://supabase.com), [Airtable](https://www.airtable.com), [Hasura](https://hasura.io), [Google Spreadsheets](https://www.google.com/sheets/about), [Cloudflare Workers KV](https://www.cloudflare.com/products/workers-kv), [Fauna](https://fauna.com/features), a custom [PostgreSQL](https://www.postgresql.org), or even your backend team's REST/GraphQL APIs. Seriously. Whatever you want.
### Set up Prisma
The Prisma team has built [a VSCode extension](https://marketplace.visualstudio.com/items?itemName=Prisma.prisma) you might find quite helpful when working on the Prisma schema.
In this tutorial we're going to use our own [SQLite](https://sqlite.org/index.html) 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](https://www.prisma.io), 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:
- 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
- Run prisma db pull to turn your database schema into a Prisma schema.
- 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](https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference):
// 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
If your database gets messed up, you can always delete the `prisma/dev.db` file and run `npx prisma db push` again. Remember to also restart your dev server with `npm run dev`.
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.
### Connect to the database
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 do the workaround I'm about to show you.
💿 Copy this into a new file called `app/utils/db.server.ts`
import { PrismaClient } from “@prisma/client”;
let db: PrismaClient;
declare global { var db: PrismaClient | undefined; }
// This is needed because in development we don’t want to restart // the server with every change, but we want to make sure we don’t // create a new connection to the DB with every change either. // In production, we’ll have a single connection to the DB. if (process.env.NODEENV === “production”) { db = new PrismaClient(); } else { if (!global.db) { global.db = new PrismaClient(); } db = global._db; db.$connect(); }
export { db };
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.
### Read from the database in a Remix loader
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]($13d55f9e524bba6a.md). This is simply an `async` function you export that returns a response, and is accessed on the component through the [useLoaderData]($8d451ce3a0cc9c78.md) 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
-
{data.sandwiches.map((sandwich) => (
- {sandwich.name} ))}
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.
app/routes/jokes.tsx
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
return (
🤪 J🤪KES
Here are a few more jokes to check out:
-
{data.jokeListItems.map(({ id, name }) => (
- {name} ))}
And here's what we have with that now:
![List of links to jokes](/projects/remix-1.19-en/3b8494d40e69d30615a8bdec0f4bb718.png)
### Data overfetching
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](https://bundlephobia.com/package/graphql@16.0.1) 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.
Whatever you return from your loader will be exposed to the client, even if the component doesn't render it. Treat your loaders with the same care as public API endpoints.
### Network Type Safety
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](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#assertion-functions) on the `data` you get back from `useLoaderData`. That's outside the scope of this tutorial, but we're fans of [zod](https://npm.im/zod) which can aid in this.
### Wrap up database queries
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 }: LoaderArgs) => { console.log(params); // <— {jokeId: “123”} };
And here's how you get the joke from Prisma:
const joke = await db.joke.findUnique({ where: { id: jokeId }, });
Remember, when we're referencing the URL route, it's `/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`.
app/routes/jokes.$jokeId.tsx
import type { LoaderArgs } 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 }: LoaderArgs) => { 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
return (
Here’s your hilarious joke:
{data.joke.content}
“{data.joke.name}” Permalink
With that you should be able to go to [/jokes](http://localhost:3000/jokes) and click on a link to get the joke:
![Jokes page showing a unique joke](/projects/remix-1.19-en/8a3cb6a8086e234b0e7e6d2495773952.png)
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.
app/routes/jokes.\_index.tsx
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
return (
Here’s a random joke:
{data.randomJoke.content}
“{data.randomJoke.name}” Permalink
With that your [/jokes](http://localhost:3000/jokes) route should display a list of links to jokes as well as a random joke:
![Jokes page showing a random joke](/projects/remix-1.19-en/1e743d886df72890a9daf5c0b3881271.png)
## Mutations
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 (
Add your own hilarious joke
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]($037a802cb86861f4.md) 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`.
app/routes/jokes.new.tsx
import type { ActionArgs } from “@remix-run/node”; import { redirect } from “@remix-run/node”;
import { db } from “~/utils/db.server”;
export const action = async ({ request }: ActionArgs) => { 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 (
Add your own hilarious joke
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](https://developer.mozilla.org/en-US/docs/Web/API/Response) object that has the right headers/status codes to redirect the user.
![Create new joke form filled out](/projects/remix-1.19-en/9693aa859457295e7902532effa116eb.png)
![Newly created joke displayed](/projects/remix-1.19-en/99d9d8f7b244b58af04ff550b472ee4f.png)
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]($51e1c9c8080e435b.md) 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.
app/routes/jokes.new.tsx
import type { ActionArgs } 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 }: ActionArgs) => { 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
return (
Add your own hilarious joke
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 =
(data: T) => json (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!
Authentication
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.
Preparing the database
Remember, if your database gets messed up, you can always delete the 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.
Auth Flow Overview
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:
- On the
/login
route. - User submits login form.
- Form data is validated.
- If the form data is invalid, return the form with the errors.
- Login type is “register”
- Check whether the username is available
- If the username is not available, return the form with an error.
- Hash the password
- Create a new user
- Check whether the username is available
- Login type is “login”
- Check whether the user exists
- If the user doesn’t exist, return the form with an error.
- Check whether the password hash matches
- If the password hash doesn’t match, return the form with an error.
- Check whether the user exists
- Create a new session
- Redirect to the
/jokes
route with theSet-Cookie
header.
Build the login form
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:
💿 Copy this CSS into `app/styles/login.css`
/*
* 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.
app/routes/login.tsx
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
app/routes/login.tsx
import type {
ActionArgs,
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 }: ActionArgs) => {
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:
- Export a function called
login
that accepts theusername
andpassword
- Queries Prisma for a user with the
username
- If there is no user, return
null
- Use
bcrypt.compare
to compare the givenpassword
to the user’spasswordHash
- If the passwords don’t match, return
null
- If the passwords match, return the user
💿 Create a file called app/utils/session.server.ts
and implement the above requirements.
app/utils/session.server.ts
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:
app/routes/login.tsx
// ...
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 }: ActionArgs) => {
// ...
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.
Remember, 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'
}
}
If you’re having trouble, run 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:
- creates a new session (via the cookie storage
getSession
function) - sets the
userId
field on the session - redirects to the given route setting the
Set-Cookie
header (via the cookie storagecommitSession
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.
app/utils/session.server.ts
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),
},
});
}
``` app/routes/login.tsx
// …
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 }: ActionArgs) => { // …
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](https://developer.chrome.com/docs/devtools/network/reference), go to [/login](http://localhost:3000/login) and enter `kody` and `twixrox` and check the response headers in the network tab. Should look something like this:
![DevTools Network tab showing a "Set-Cookie" header on the POST response](/projects/remix-1.19-en/4ced0621e6e0c2f76d080052161d5376.png)
And if you check the cookies section of the [Application tab](https://developer.chrome.com/docs/devtools/storage/cookies) then you should have the cookie set in there as well.
![DevTools Application tab showing ](/projects/remix-1.19-en/50afca5dae9603623f17d4180d587467.png)
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](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies)):
![Request headers showing the Cookie](/projects/remix-1.19-en/6fa89af444430aa23ae949c4231ad641.png)
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.
Remember to check [the docs]($8a4691ad58986411.md) to learn how to get the session from the request
💿 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)`.
app/utils/session.server.ts
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), }, }); }
Did you notice in my example that we're `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](https://developer.mozilla.org/en-US/docs/Web/API/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.
app/routes/jokes.new.tsx
import type { ActionArgs } 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 }: ActionArgs) => { 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
return (
Add your own hilarious joke
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.
### Build Logout Action
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]($8a4691ad58986411.md#using-sessions) to log the user out.
app/utils/session.server.ts
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.
app/routes/jokes.tsx
import type { LinksFunction, LoaderArgs, } 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 }: LoaderArgs) => { 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
return (
🤪 J🤪KES
{data.user ? (Hi ${data.user.username}
}
Here are a few more jokes to check out:
-
{data.jokeListItems.map(({ id, name }) => (
- {name} ))}
import type { ActionArgs } from “@remix-run/node”; import { redirect } from “@remix-run/node”;
import { logout } from “~/utils/session.server”;
export const action = async ({ request }: ActionArgs) => 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](https://developer.mozilla.org/en-US/docs/Glossary/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.
Add your own
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:
![Jokes page nice and designed](/projects/remix-1.19-en/78a1b128997357c5283128b4351834c2.png)
![New Joke form designed](/projects/remix-1.19-en/175d0d4dbb6996eb61c20828d1721c21.png)
### User Registration
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.
app/utils/session.server.ts
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 { ActionArgs, 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 }: ActionArgs) => { 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
Login
- Home
- Jokes
Phew, there we go. Now users can register for a new account!
## Unexpected errors
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](https://reactjs.org/docs/error-boundaries.html#gatsby-focus-wrapper). With Remix, your route modules can export an [ErrorBoundary component]($e7f162a671926ad5.md), 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.
The `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.
app/root.tsx
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 (
export default function App() {
return (
export function ErrorBoundary() { const error = useRouteError();
const errorMessage =
error instanceof Error
? error.message
: “Unknown error”;
return (
App Error
- {errorMessage}
// …
import { Link, useLoaderData, useParams, } from “@remix-run/react”;
// …
export function ErrorBoundary() { const { jokeId } = useParams(); return (
// …
export function ErrorBoundary() { return (
// …
export function ErrorBoundary() { return (
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:
![App error](/projects/remix-1.19-en/ea6657465141c17c317780fe0790b3b2.png)
![Joke Page Error](/projects/remix-1.19-en/e0150313341b6c142caf554fc97b67c2.png)
![Joke Index Page Error](/projects/remix-1.19-en/7cefd7780f8068d9696403fa2abd4bf7.png)
![New Joke Page Error](/projects/remix-1.19-en/6eec2c3fc32e3cf97d6c9ec5223ff191.png)
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!
## Expected errors
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](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#server_error_responses)) and the expected errors as 400-level errors ([client errors](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#client_error_responses)).
To check for client error responses, Remix offers the [isRouteErrorResponse]($df9dbcfeb1d2f9d6.md) helper function. In the case that your server code detects a problem, it'll throw a [Response](https://developer.mozilla.org/en-US/docs/Web/API/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` afterwards to get your seed data back.)
💿 Let's add these `isRouteErrorResponse` checks to the routes.
app/root.tsx
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 (
export default function App() {
return (
export function ErrorBoundary() { const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
{error.status} {error.statusText}
const errorMessage =
error instanceof Error
? error.message
: “Unknown error”;
return (
App Error
- {errorMessage}
import type { LoaderArgs } 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 }: LoaderArgs) => { 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
return (
Here’s your hilarious joke:
{data.joke.content}
“{data.joke.name}” Permalinkexport function ErrorBoundary() { const { jokeId } = useParams(); const error = useRouteError();
if (isRouteErrorResponse(error) && error.status === 404) { return (
return (
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
return (
Here’s a random joke:
{data.randomJoke.content}
“{data.randomJoke.name}” Permalinkexport function ErrorBoundary() { const error = useRouteError();
if (isRouteErrorResponse(error) && error.status === 404) { return (
There are no jokes to display.
Add your ownreturn (
import type { ActionArgs, LoaderArgs, } 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 }: LoaderArgs) => { 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 }: ActionArgs) => { 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
return (
Add your own hilarious joke
export function ErrorBoundary() { const error = useRouteError();
if (isRouteErrorResponse(error) && error.status === 401) { return (
You must be logged in to create a joke.
Loginreturn (
Here's what I've got with that:
![App 400 Bad Request](/projects/remix-1.19-en/d5663467247eb7aae88f0ef069cb546b.png)
![A 404 on the joke page](/projects/remix-1.19-en/a9016f5eda54439d2c0578ff869debb6.png)
![A 404 on the random joke page](/projects/remix-1.19-en/3183e32440b4255efe85372748643cc7.png)
![A 401 on the new joke page](/projects/remix-1.19-en/1a24ef77a6399bfa23ba0ca177e52adb.png)
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:
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.
app/routes/jokes.$jokeId.tsx
import type { ActionArgs, LoaderArgs, } 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 }: LoaderArgs) => { 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,
}: ActionArgs) => {
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
return (
Here’s your hilarious joke:
{data.joke.content}
“{data.joke.name}” Permalinkexport function ErrorBoundary() { const { jokeId } = useParams(); const error = useRouteError();
if (isRouteErrorResponse(error)) { if (error.status === 400) { return (
return (
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.
app/routes/jokes.$jokeId.tsx
import type { ActionArgs, LoaderArgs, } 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, }: LoaderArgs) => { 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,
}: ActionArgs) => {
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
return (
Here’s your hilarious joke:
{data.joke.content}
“{data.joke.name}” Permalink {data.isOwner ? ( ) : null}export function ErrorBoundary() { const { jokeId } = useParams(); const error = useRouteError();
if (isRouteErrorResponse(error)) { if (error.status === 400) { return (
return (
## SEO with Meta tags
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]($b3f57aab550c37ce.md) export. Why don't you go through and add a useful few 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]($f5cc29632de57ed5.md).
💿 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.
app/root.tsx
import type { LinksFunction, V2_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: V2_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 (
{title ? : null}
export default function App() {
return (
export function ErrorBoundary() { const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
{error.status} {error.statusText}
const errorMessage =
error instanceof Error
? error.message
: “Unknown error”;
return (
App Error
- {errorMessage}
import type { ActionArgs, LinksFunction, V2_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: V2_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 }: ActionArgs) => { 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
Login
- Home
- Jokes
import type { ActionArgs, LoaderArgs, V2_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: V2_MetaFunctionEnjoy 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, }: LoaderArgs) => { 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,
}: ActionArgs) => {
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
return (
Here’s your hilarious joke:
{data.joke.content}
“{data.joke.name}” Permalink {data.isOwner ? ( ) : null}export function ErrorBoundary() { const { jokeId } = useParams(); const error = useRouteError();
if (isRouteErrorResponse(error)) { if (error.status === 400) { return (
return (
Sweet! Now search engines and social media platforms will like our site a bit better.
## Resource Routes
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]($b88837aa9e5312fd.md) 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]($5d23c31bb1ed9c61.md#escaping-special-characters).
Believe it or not, you've actually already made one of these. Check out your logout route! No UI necessary because it's just there to handle mutations and redirect lost souls.
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.
app/routes/jokes\[.\]rss.tsx
import type { LoaderArgs } 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, “'”); }
export const loader = async ({ request }: LoaderArgs) => { 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) =>
.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)
),
},
});
};
![XML document for RSS feed](/projects/remix-1.19-en/cde83aaa7dcae5e083dda09c5f8dd546.png)
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.
app/routes/\_index.tsx
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 (
Remix Jokes!
import type { LinksFunction, LoaderArgs, } 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 }: LoaderArgs) => { 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
return (
🤪 J🤪KES
{data.user ? (Hi ${data.user.username}
}
Here are a few more jokes to check out:
-
{data.jokeListItems.map(({ id, name }) => (
- {name} ))}
## JavaScript...
Maybe we should actually include JavaScript on our JavaScript app. 😂
Seriously, pull up your network tab and navigate to our app.
![Network tab indicating no JavaScript is loaded](/projects/remix-1.19-en/8cb4c993ed9028e52f8c7d1e8e6ab415.png)
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]($1b172853d241c8de.md) to load all the JavaScript files in `app/root.tsx`.
app/root.tsx
import type { LinksFunction, V2_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: V2_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 (
{title ? : null}
export default function App() {
return (
export function ErrorBoundary() { const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
{error.status} {error.statusText}
const errorMessage =
error instanceof Error
? error.message
: “Unknown error”;
return (
App Error
- {errorMessage}
![Network tab showing JavaScript loaded](/projects/remix-1.19-en/14b5a2957cdd573ca8599a3da91a0dae.png)
💿 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. 🤯
app/root.tsx
import type { LinksFunction, V2_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: V2_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 (
{title ? : null}
export default function App() {
return (
export function ErrorBoundary() { const error = useRouteError(); console.error(error);
if (isRouteErrorResponse(error)) {
return (
{error.status} {error.statusText}
const errorMessage =
error instanceof Error
? error.message
: “Unknown error”;
return (
App Error
- {errorMessage}
import type { ActionArgs, LoaderArgs, V2_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: V2_MetaFunctionEnjoy 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, }: LoaderArgs) => { 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,
}: ActionArgs) => {
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
return (
Here’s your hilarious joke:
{data.joke.content}
“{data.joke.name}” Permalink {data.isOwner ? ( ) : null}export function ErrorBoundary() { const { jokeId } = useParams(); const error = useRouteError(); console.error(error);
if (isRouteErrorResponse(error)) { if (error.status === 400) { return (
return (
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
return (
Here’s a random joke:
{data.randomJoke.content}
“{data.randomJoke.name}” Permalinkexport function ErrorBoundary() { const error = useRouteError(); console.error(error);
if (isRouteErrorResponse(error) && error.status === 404) { return (
There are no jokes to display.
Add your ownreturn (
import type { ActionArgs, LoaderArgs, } 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 }: LoaderArgs) => { 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 }: ActionArgs) => { 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
return (
Add your own hilarious joke
export function ErrorBoundary() { const error = useRouteError(); console.error(error);
if (isRouteErrorResponse(error) && error.status === 401) { return (
You must be logged in to create a joke.
Loginreturn (
![Browser console showing the log of a server-side error](/projects/remix-1.19-en/151f264d8b4e3769bfc2c49c7d36d676.png)
### Forms
Remix has its own [<Form />]($ce9bdcd1d3799219.md) component. When JavaScript is not yet loaded, it works the same way as a regular form, but when JavaScript is enabled, it's "progressively enhanced" to make a `fetch` request instead, so we don't do a full-page reload.
💿 Find all `<form />` elements and change them to the Remix `<Form />` component.
app/routes/login.tsx
import type { ActionArgs, LinksFunction, V2_MetaFunction, } from “@remix-run/node”; import { Form, 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: V2_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 }: ActionArgs) => { 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
Login
- Home
- Jokes
import type { LinksFunction, LoaderArgs, } from “@remix-run/node”; import { json } from “@remix-run/node”; import { Form, 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 }: LoaderArgs) => { 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
return (
🤪 J🤪KES
{data.user ? (Hi ${data.user.username}
}
Here are a few more jokes to check out:
-
{data.jokeListItems.map(({ id, name }) => (
- {name} ))}
import type { ActionArgs, LoaderArgs, V2_MetaFunction, } from “@remix-run/node”; import { json, redirect } from “@remix-run/node”; import { Form, 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: V2_MetaFunctionEnjoy 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, }: LoaderArgs) => { 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,
}: ActionArgs) => {
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
return (
Here’s your hilarious joke:
{data.joke.content}
“{data.joke.name}” Permalink {data.isOwner ? ( ) : null}export function ErrorBoundary() { const { jokeId } = useParams(); const error = useRouteError(); console.error(error);
if (isRouteErrorResponse(error)) { if (error.status === 400) { return (
return (
import type { ActionArgs, LoaderArgs, } from “@remix-run/node”; import { json, redirect } from “@remix-run/node”; import { Form, 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 }: LoaderArgs) => { 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 }: ActionArgs) => { 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
return (
Add your own hilarious joke
export function ErrorBoundary() { const error = useRouteError(); console.error(error);
if (isRouteErrorResponse(error) && error.status === 401) { return (
You must be logged in to create a joke.
Loginreturn (
### Prefetching
If a user focuses or mouses-over a link, it's likely they want to go there. So we can prefetch the page that they're going to. And this is all it takes to enable that for a specific link:
Somewhere Neat
💿 Add `prefetch="intent"` to the list of Joke links in `app/routes/jokes.tsx`.
app/routes/jokes.tsx
import type { LinksFunction, LoaderArgs, } from “@remix-run/node”; import { json } from “@remix-run/node”; import { Form, 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 }: LoaderArgs) => { 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
return (
🤪 J🤪KES
{data.user ? (Hi ${data.user.username}
}
Here are a few more jokes to check out:
-
{data.jokeListItems.map(({ id, name }) => (
- {name} ))}
## Optimistic UI
Now that we have JavaScript on the page, we can benefit from _progressive enhancement_ and make our site _even better_ with JavaScript by adding some _optimistic UI_ to our app.
Even though our app is quite fast (especially locally 😅), some users may have a poor connection to our app. This means that they're going to submit their jokes, but then they'll have to wait for a while before they see anything. We could add a loading spinner somewhere, but it'd be a much better user experience to be optimistic about the success of the request and render what the user would see.
We have a pretty in depth [guide on Optimistic UI]($ec29ad502db1a21e.md), so go give that a read
💿 Add optimistic UI to the `app/routes/jokes.new.tsx` route.
Note, you'll probably want to create a new file in `app/components/` called `joke.tsx` so you can reuse that UI in both `app/routes/jokes.$jokeId.tsx` & `app/routes/jokes.new.tsx`.
app/components/joke.tsx
import type { Joke } from “@prisma/client”; import { Form, Link } from “@remix-run/react”;
export function JokeDisplay({
canDelete = true,
isOwner,
joke,
}: {
canDelete?: boolean;
isOwner: boolean;
joke: Pick
Here’s your hilarious joke:
{joke.content}
“{joke.name}” Permalink {isOwner ? ( ) : null}import type { ActionArgs, LoaderArgs, V2_MetaFunction, } from “@remix-run/node”; import { json, redirect } from “@remix-run/node”; import { isRouteErrorResponse, useLoaderData, useParams, useRouteError, } from “@remix-run/react”;
import { JokeDisplay } from “~/components/joke”; import { db } from “~/utils/db.server”; import { getUserId, requireUserId, } from “~/utils/session.server”;
export const meta: V2_MetaFunctionEnjoy 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, }: LoaderArgs) => { 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,
}: ActionArgs) => {
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
return (
export function ErrorBoundary() { const { jokeId } = useParams(); const error = useRouteError(); console.error(error);
if (isRouteErrorResponse(error)) { if (error.status === 400) { return (
return (
import type { ActionArgs, LoaderArgs, } from “@remix-run/node”; import { json, redirect } from “@remix-run/node”; import { Form, isRouteErrorResponse, Link, useActionData, useNavigation, useRouteError, } from “@remix-run/react”;
import { JokeDisplay } from “~/components/joke”; import { db } from “~/utils/db.server”; import { badRequest } from “~/utils/request.server”; import { getUserId, requireUserId, } from “~/utils/session.server”;
export const loader = async ({ request }: LoaderArgs) => { 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 }: ActionArgs) => { 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
if (navigation.formData) {
const content = navigation.formData.get(“content”);
const name = navigation.formData.get(“name”);
if (
typeof content === “string” &&
typeof name === “string” &&
!validateJokeContent(content) &&
!validateJokeName(name)
) {
return (
return (
Add your own hilarious joke
export function ErrorBoundary() { const error = useRouteError(); console.error(error);
if (isRouteErrorResponse(error) && error.status === 401) { return (
You must be logged in to create a joke.
Loginreturn (
One thing I like about my example is that it can use the _exact_ same validation functions that the server uses! So if what they submitted will fail server-side validation, we don't even bother rendering the optimistic UI because we know it would fail.
That said, this declarative optimistic UI approach is fantastic because we don't have to worry about error recovery. If the request fails, then our component will be re-rendered, it will no longer be a submission and everything will work as it did before. Nice!
Here's a demonstration of what that experience looks like:
## Deployment
I feel pretty great about the user experience we've created here. So let's get this thing deployed! With Remix, you have a lot of options for deployment. When you ran `npx create-remix@latest` at the start of this tutorial, there were several options given to you. Because the tutorial we've built relies on Node.js (`prisma`), we're going to deploy to one of our favorite hosting providers: [Fly.io](https://fly.io).
💿 Before proceeding, you're going to need to [install fly](https://fly.io/docs/hands-on/installing) and [sign up for an account](https://fly.io/docs/hands-on/sign-up).
Fly.io asks you a credit card number at account creation (see why in [their blog article](https://fly.io/blog/free-postgres/#a-note-about-credit-cards)) but there are free tiers that cover the needs of this app hosted as a simple side project.
💿 Once you've done that, run this command from within your project directory:
fly launch
The folks at fly were kind enough to put together a great setup experience. They'll detect your Remix project and ask you a few questions to get you started. Here's my output/choices:
Creating app in /Users/kentcdodds/Desktop/remix-jokes Scanning source code Detected a Remix app ? Choose an app name (leave blank to generate one): remix-jokes automatically selected personal organization: Kent C. Dodds Some regions require a paid plan (fra, maa). See https://fly.io/plans to set up a plan.
? Choose a region for deployment: Dallas, Texas (US) (dfw) Created app ‘remix-jokes’ in organization ‘personal’ Admin URL: https://fly.io/apps/remix-jokes Hostname: remix-jokes.fly.dev Created a 1GB volume vol_18l524yj27947zmp in the dfw region Wrote config file fly.toml
This launch configuration uses SQLite on a single, dedicated volume. It will not scale beyond a single VM. Look into ‘fly postgres’ for a more robust production database.
? Would you like to deploy now? No
Your app is ready! Deploy with flyctl deploy
You'll want to choose a different app name because I already took `remix-jokes` (sorry 🙃).
It also allowed you to select a region, I recommend choosing one that's close to you. If you decide to deploy a real app on Fly in the future, you may decide to scale up your fly to multiple regions.
Fly also detected that this project is using SQLite with Prisma and created a persistence volume for us.
We don't want to deploy right now because we have an environment variable we need to set! So choose "No".
Fly generated a few files for us:
- `fly.toml` - Fly-specific configuration
- `Dockerfile` - Remix-specific Dockerfile for the app
- `.dockerignore` - It just ignores `node_modules` because we'll run the installation as we build the image.
💿 Now set the `SESSION_SECRET` environment variable by running this command:
fly secrets set SESSION_SECRET=your-secret-here
`your-secret-here` can be whatever you want. It's just a string that's used to sign the session cookie. Use a password generator if you like.
One other thing we need to do is get Prisma ready to set up our database for the first time. Now that we're happy with our schema, we can create our first migration.
💿 Run this command:
npx prisma migrate dev
This will create a migration file in the `migrations` directory. You may get an error when it tries to run the seed file. You can safely ignore that. It will ask you what you want to call your migration:
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
✔ Enter a name for the new migration: … init
💿 I called mine "init". Then you'll get the rest of the output:
Applying migration 20211121111251_init
The following migration(s) have been created and applied from new schema changes:
migrations/ └─ 20211121111251_init/ └─ migration.sql
Your database is now in sync with your schema.
✔ Generated Prisma Client (4.12.0 | library) to ./node_modules/@prisma/client in 52ms
Running seed command ts-node --require tsconfig-paths/register prisma/seed.ts
…
🌱 The seed command has been executed.
💿 If you did get an error when running the seed, you can run it manually now:
npx prisma db seed
With that done, you're ready to deploy.
💿 Run this command:
fly deploy ```
This will build the docker image and deploy it on Fly in the region you selected. It will take a little while. While you wait, you can think of someone you haven’t talked to in a while and shoot them a message telling them why you appreciate them.
Great! We’re done and you made someone’s day! Success!
Your app is now live at https://<your-app-name>.fly.dev
! You can find that URL in your fly account online as well: fly.io/apps.
Any time you make a change, simply run fly deploy
again to redeploy.
Conclusion
Phew! And there we have it. If you made it through this whole thing then I’m really impressed (tweet your success)! There’s a lot to Remix, and we’ve only gotten you started. Good luck on the rest of your Remix journey!