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

Remix Jokes

This tutorial is the comprehensive way to getting an overview of the primary APIs available in Remix. By the end, you’ll have a full application you can show your mom, significant other, or dog and I’m sure they’ll be just as excited about Remix as you are (though I make no guarantees).

We’re going to be laser focused on Remix. This means that we’re going to skip over a few things that are a distraction from the core ideas we want you to learn about Remix. For example, we’ll show you how to get a CSS stylesheet on the page, but we’re not going to make you write the styles by yourself. So we’ll just give you stuff you can copy/paste for that kind of thing. However, if you’d prefer to write it all out yourself, you totally can (it’ll just take you much longer). So we’ll put it in little <details> elements you have to click to expand to not spoil anything if you’d prefer to code it out yourself.

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.

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 and prisma)
  • 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 (note, currently hosting sqlite on fly will cost a few bucks a month).

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:

  1. npx create-remix@latest

This may ask you whether you want to install create-remix to run the command. Enter y. It will only be installed temporarily to run the setup script.

When the fun Remix animation is finished, it’ll ask you a few questions. We’ll call our app “remix-jokes”, choose the “Remix App Server” deploy target, use TypeScript, and have it run the installation for us:

  1. R E M I X
  2. 💿 Welcome to Remix! Let's get you set up with a new project.
  3. ? Where would you like to create your app? remix-jokes
  4. ? Where do you want to deploy? Choose Remix if you're unsure, it's easy to change deployment targets. Remix
  5. App Server
  6. ? TypeScript or JavaScript? TypeScript
  7. ? 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

  1. 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:

  1. remix-jokes
  2. ├── README.md
  3. ├── app
  4. ├── entry.client.tsx
  5. ├── entry.server.tsx
  6. ├── root.tsx
  7. ├── routes
  8. ├── demos
  9. ├── about
  10. ├── index.tsx
  11. └── whoa.tsx
  12. ├── about.tsx
  13. ├── actions.tsx
  14. ├── correct.tsx
  15. ├── params
  16. ├── $id.tsx
  17. └── index.tsx
  18. └── params.tsx
  19. └── index.tsx
  20. └── styles
  21. ├── dark.css
  22. ├── demos
  23. └── about.css
  24. └── global.css
  25. ├── package-lock.json
  26. ├── package.json
  27. ├── public
  28. └── favicon.ico
  29. ├── remix.config.js
  30. ├── remix.env.d.ts
  31. └── tsconfig.json

Let’s talk briefly about a few of these files:

  • app/ - This is where all your Remix app code goes
  • app/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:

  1. npm run build

That should output something like this:

  1. Building Remix app in production mode...
  2. 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 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:

  1. npm start

This will start the server and output this:

  1. Remix App Server started at http://localhost:3000

Open up that URL and you should be presented with something that looks a bit like this:

The Remix Starter App

Feel free to read a bit of what’s in there and explore the code if you like. I’ll be here when you get back. You done? Ok, sweet.

💿 Now stop the server and delete all this stuff:

  • app/routes
  • app/styles

💿 Replace the contents of app/root.tsx with this:

  1. import { LiveReload } from "remix";
  2. export default function App() {
  3. return (
  4. <html lang="en">
  5. <head>
  6. <meta charSet="utf-8" />
  7. <title>Remix: So great, it's funny!</title>
  8. </head>
  9. <body>
  10. Hello world
  11. {process.env.NODE_ENV === "development" ? (
  12. <LiveReload />
  13. ) : null}
  14. </body>
  15. </html>
  16. );
  17. }

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:

  1. app
  2. ├── entry.client.tsx
  3. ├── entry.server.tsx
  4. └── root.tsx

💿 With that set up, go ahead and start the dev server up with this command:

  1. npm run dev

Open http://localhost:3000 and the app should greet the world:

Bare bones hello world app

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:

  1. /
  2. /jokes
  3. /jokes/:jokeId
  4. /jokes/new
  5. /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

  1. export default function IndexRoute() {
  2. return <div>Hello Index Route</div>;
  3. }

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:

app/root.tsx

  1. import { LiveReload, Outlet } from "remix";
  2. export default function App() {
  3. return (
  4. <html lang="en">
  5. <head>
  6. <meta charSet="utf-8" />
  7. <title>Remix: So great, it's funny!</title>
  8. </head>
  9. <body>
  10. <Outlet />
  11. {process.env.NODE_ENV === "development" ? (
  12. <LiveReload />
  13. ) : null}
  14. </body>
  15. </html>
  16. );
  17. }

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.

A 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

  1. import { Outlet } from "remix";
  2. export default function JokesRoute() {
  3. return (
  4. <div>
  5. <h1>J🤪KES</h1>
  6. <main>
  7. <Outlet />
  8. </main>
  9. </div>
  10. );
  11. }

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

  1. export default function JokesIndexRoute() {
  2. return (
  3. <div>
  4. <p>Here's a random joke:</p>
  5. <p>
  6. I was wondering why the frisbee was getting bigger,
  7. then it hit me.
  8. </p>
  9. </div>
  10. );
  11. }

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:

A random joke on the jokes page: "I was wondering why the frisbee was getting bigger, then it hit me"

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

  1. export default function NewJokeRoute() {
  2. return (
  3. <div>
  4. <p>Add your own hilarious joke</p>
  5. <form method="post">
  6. <div>
  7. <label>
  8. Name: <input type="text" name="name" />
  9. </label>
  10. </div>
  11. <div>
  12. <label>
  13. Content: <textarea name="content" />
  14. </label>
  15. </div>
  16. <div>
  17. <button type="submit" className="button">
  18. Add
  19. </button>
  20. </div>
  21. </form>
  22. </div>
  23. );
  24. }

Great, so now going to /jokes/new should display your form:

A new joke form

Parameterized Routes

Soon we’ll add a database that stores our jokes by an ID, so lets 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 lookup that part of the URL up 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

  1. export default function JokeRoute() {
  2. return (
  3. <div>
  4. <p>Here's your hilarious joke:</p>
  5. <p>
  6. Why don't you find hippopotamuses hiding in trees?
  7. They're really good at it.
  8. </p>
  9. </div>
  10. );
  11. }

Great, so now going to /jokes/anything-you-want should display what you just created (in addition to the parent routes):

A new joke form

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 it’s Nested Routing support to CSS and allows you to associate links to routes. When the route is active, the link 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:

  1. body {
  2. color: hsl(0, 0%, 100%);
  3. background-image: radial-gradient(
  4. circle,
  5. rgba(152, 11, 238, 1) 0%,
  6. rgba(118, 15, 181, 1) 35%,
  7. rgba(58, 13, 85, 1) 100%
  8. );
  9. }

💿 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

  1. import type { LinksFunction } from "remix";
  2. import stylesUrl from "../styles/index.css";
  3. export const links: LinksFunction = () => {
  4. return [{ rel: "stylesheet", href: stylesUrl }];
  5. };
  6. export default function IndexRoute() {
  7. return <div>Hello Index Route</div>;
  8. }

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

💿 Go ahead and add the Remix <Links /> component to app/root.tsx within the <head>.

app/root.tsx

  1. import { Links, LiveReload, Outlet } from "remix";
  2. export default function App() {
  3. return (
  4. <html lang="en">
  5. <head>
  6. <meta charSet="utf-8" />
  7. <title>Remix: So great, it's funny!</title>
  8. <Links />
  9. </head>
  10. <body>
  11. <Outlet />
  12. {process.env.NODE_ENV === "development" ? (
  13. <LiveReload />
  14. ) : null}
  15. </body>
  16. </html>
  17. );
  18. }

Great, now check / again and it should be nice and styled for you:

The homepage with a purple gradient background and white text with the words "Hello Index Route"

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.

The jokes page with no background gradient

🤯 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 lazy-loaded. 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`

  1. @font-face {
  2. font-family: "baloo";
  3. src: url("/fonts/baloo/baloo.woff") format("woff");
  4. font-weight: normal;
  5. font-style: normal;
  6. }
  7. :root {
  8. --hs-links: 48 100%;
  9. --color-foreground: hsl(0, 0%, 100%);
  10. --color-background: hsl(278, 73%, 19%);
  11. --color-links: hsl(var(--hs-links) 50%);
  12. --color-links-hover: hsl(var(--hs-links) 45%);
  13. --color-border: hsl(277, 85%, 38%);
  14. --color-invalid: hsl(356, 100%, 71%);
  15. --gradient-background: radial-gradient(
  16. circle,
  17. rgba(152, 11, 238, 1) 0%,
  18. rgba(118, 15, 181, 1) 35%,
  19. rgba(58, 13, 85, 1) 100%
  20. );
  21. --font-body: -apple-system, "Segoe UI", Helvetica Neue, Helvetica,
  22. Roboto, Arial, sans-serif, system-ui, "Apple Color Emoji",
  23. "Segoe UI Emoji";
  24. --font-display: baloo, var(--font-body);
  25. }
  26. html {
  27. box-sizing: border-box;
  28. }
  29. *,
  30. *::before,
  31. *::after {
  32. box-sizing: inherit;
  33. }
  34. :-moz-focusring {
  35. outline: auto;
  36. }
  37. :focus {
  38. outline: var(--color-links) solid 2px;
  39. outline-offset: 2px;
  40. }
  41. html,
  42. body {
  43. padding: 0;
  44. margin: 0;
  45. color: var(--color-foreground);
  46. background-color: var(--color-background);
  47. }
  48. [data-light] {
  49. --color-invalid: hsl(356, 70%, 39%);
  50. color: var(--color-background);
  51. background-color: var(--color-foreground);
  52. }
  53. body {
  54. font-family: var(--font-body);
  55. line-height: 1.5;
  56. background-repeat: no-repeat;
  57. min-height: 100vh;
  58. min-height: calc(100vh - env(safe-area-inset-bottom));
  59. }
  60. a {
  61. color: var(--color-links);
  62. text-decoration: none;
  63. }
  64. a:hover {
  65. color: var(--color-links-hover);
  66. text-decoration: underline;
  67. }
  68. hr {
  69. display: block;
  70. height: 1px;
  71. border: 0;
  72. background-color: var(--color-border);
  73. margin-top: 2rem;
  74. margin-bottom: 2rem;
  75. }
  76. h1,
  77. h2,
  78. h3,
  79. h4,
  80. h5,
  81. h6 {
  82. font-family: var(--font-display);
  83. margin: 0;
  84. }
  85. h1 {
  86. font-size: 2.25rem;
  87. line-height: 2.5rem;
  88. }
  89. h2 {
  90. font-size: 1.5rem;
  91. line-height: 2rem;
  92. }
  93. h3 {
  94. font-size: 1.25rem;
  95. line-height: 1.75rem;
  96. }
  97. h4 {
  98. font-size: 1.125rem;
  99. line-height: 1.75rem;
  100. }
  101. h5,
  102. h6 {
  103. font-size: 0.875rem;
  104. line-height: 1.25rem;
  105. }
  106. .sr-only {
  107. position: absolute;
  108. width: 1px;
  109. height: 1px;
  110. padding: 0;
  111. margin: -1px;
  112. overflow: hidden;
  113. clip: rect(0, 0, 0, 0);
  114. white-space: nowrap;
  115. border-width: 0;
  116. }
  117. .container {
  118. --gutter: 16px;
  119. width: 1024px;
  120. max-width: calc(100% - var(--gutter) * 2);
  121. margin-right: auto;
  122. margin-left: auto;
  123. }
  124. /* buttons */
  125. .button {
  126. --shadow-color: hsl(var(--hs-links) 30%);
  127. --shadow-size: 3px;
  128. -webkit-appearance: none;
  129. -moz-appearance: none;
  130. cursor: pointer;
  131. appearance: none;
  132. display: inline-flex;
  133. align-items: center;
  134. justify-content: center;
  135. background-color: var(--color-links);
  136. color: var(--color-background);
  137. font-family: var(--font-display);
  138. font-weight: bold;
  139. line-height: 1;
  140. font-size: 1.125rem;
  141. margin: 0;
  142. padding: 0.625em 1em;
  143. border: 0;
  144. border-radius: 4px;
  145. box-shadow: 0 var(--shadow-size) 0 0 var(--shadow-color);
  146. outline-offset: 2px;
  147. transform: translateY(0);
  148. transition: background-color 50ms ease-out, box-shadow
  149. 50ms ease-out,
  150. transform 100ms cubic-bezier(0.3, 0.6, 0.8, 1.25);
  151. }
  152. .button:hover {
  153. --raise: 1px;
  154. color: var(--color-background);
  155. text-decoration: none;
  156. box-shadow: 0 calc(var(--shadow-size) + var(--raise)) 0 0 var(
  157. --shadow-color
  158. );
  159. transform: translateY(calc(var(--raise) * -1));
  160. }
  161. .button:active {
  162. --press: 1px;
  163. box-shadow: 0 calc(var(--shadow-size) - var(--press)) 0 0 var(
  164. --shadow-color
  165. );
  166. transform: translateY(var(--press));
  167. background-color: var(--color-links-hover);
  168. }
  169. .button[disabled],
  170. .button[aria-disabled="true"] {
  171. transform: translateY(0);
  172. pointer-events: none;
  173. opacity: 0.7;
  174. }
  175. .button:focus:not(:focus-visible) {
  176. outline: none;
  177. }
  178. /* forms */
  179. form {
  180. display: flex;
  181. flex-direction: column;
  182. gap: 1rem;
  183. width: 100%;
  184. }
  185. fieldset {
  186. margin: 0;
  187. padding: 0;
  188. border: 0;
  189. }
  190. legend {
  191. display: block;
  192. max-width: 100%;
  193. margin-bottom: 0.5rem;
  194. color: inherit;
  195. white-space: normal;
  196. }
  197. [type="text"],
  198. [type="password"],
  199. [type="date"],
  200. [type="datetime"],
  201. [type="datetime-local"],
  202. [type="month"],
  203. [type="week"],
  204. [type="email"],
  205. [type="number"],
  206. [type="search"],
  207. [type="tel"],
  208. [type="time"],
  209. [type="url"],
  210. [type="color"],
  211. textarea {
  212. -webkit-appearance: none;
  213. -moz-appearance: none;
  214. appearance: none;
  215. display: block;
  216. display: flex;
  217. align-items: center;
  218. width: 100%;
  219. height: 2.5rem;
  220. margin: 0;
  221. padding: 0.5rem 0.75rem;
  222. border: 1px solid var(--color-border);
  223. border-radius: 4px;
  224. background-color: hsl(0 0% 100% / 10%);
  225. background-blend-mode: luminosity;
  226. box-shadow: none;
  227. font-family: var(--font-body);
  228. font-size: 1rem;
  229. font-weight: normal;
  230. line-height: 1.5;
  231. color: var(--color-foreground);
  232. transition: box-shadow 200ms, border-color 50ms ease-out,
  233. background-color 50ms ease-out, color 50ms ease-out;
  234. }
  235. [data-light] [type="text"],
  236. [data-light] [type="password"],
  237. [data-light] [type="date"],
  238. [data-light] [type="datetime"],
  239. [data-light] [type="datetime-local"],
  240. [data-light] [type="month"],
  241. [data-light] [type="week"],
  242. [data-light] [type="email"],
  243. [data-light] [type="number"],
  244. [data-light] [type="search"],
  245. [data-light] [type="tel"],
  246. [data-light] [type="time"],
  247. [data-light] [type="url"],
  248. [data-light] [type="color"],
  249. [data-light] textarea {
  250. color: var(--color-background);
  251. background-color: hsl(0 0% 0% / 10%);
  252. }
  253. [type="text"][aria-invalid="true"],
  254. [type="password"][aria-invalid="true"],
  255. [type="date"][aria-invalid="true"],
  256. [type="datetime"][aria-invalid="true"],
  257. [type="datetime-local"][aria-invalid="true"],
  258. [type="month"][aria-invalid="true"],
  259. [type="week"][aria-invalid="true"],
  260. [type="email"][aria-invalid="true"],
  261. [type="number"][aria-invalid="true"],
  262. [type="search"][aria-invalid="true"],
  263. [type="tel"][aria-invalid="true"],
  264. [type="time"][aria-invalid="true"],
  265. [type="url"][aria-invalid="true"],
  266. [type="color"][aria-invalid="true"],
  267. textarea[aria-invalid="true"] {
  268. border-color: var(--color-invalid);
  269. }
  270. textarea {
  271. display: block;
  272. min-height: 50px;
  273. max-width: 100%;
  274. }
  275. textarea[rows] {
  276. height: auto;
  277. }
  278. input:disabled,
  279. input[readonly],
  280. textarea:disabled,
  281. textarea[readonly] {
  282. opacity: 0.7;
  283. cursor: not-allowed;
  284. }
  285. [type="file"],
  286. [type="checkbox"],
  287. [type="radio"] {
  288. margin: 0;
  289. }
  290. [type="file"] {
  291. width: 100%;
  292. }
  293. label {
  294. margin: 0;
  295. }
  296. [type="checkbox"] + label,
  297. [type="radio"] + label {
  298. margin-left: 0.5rem;
  299. }
  300. label > [type="checkbox"],
  301. label > [type="radio"] {
  302. margin-right: 0.5rem;
  303. }
  304. ::placeholder {
  305. color: hsl(0 0% 100% / 65%);
  306. }
  307. .form-validation-error {
  308. margin: 0;
  309. margin-top: 0.25em;
  310. color: var(--color-invalid);
  311. font-size: 0.8rem;
  312. }
  313. .error-container {
  314. background-color: hsla(356, 77%, 59%, 0.747);
  315. border-radius: 0.25rem;
  316. padding: 0.5rem 1rem;
  317. }
  318. ``` 💿 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`

  1. h1 {
  2. font-size: 3rem;
  3. line-height: 1;
  4. }
  5. h2 {
  6. font-size: 2.25rem;
  7. line-height: 2.5rem;
  8. }
  9. h3 {
  10. font-size: 1.25rem;
  11. line-height: 1.75rem;
  12. }
  13. h4 {
  14. font-size: 1.125rem;
  15. line-height: 1.75rem;
  16. }
  17. h5,
  18. h6 {
  19. font-size: 1rem;
  20. line-height: 1.5rem;
  21. }
  22. .container {
  23. --gutter: 40px;
  24. }
  25. ``` 💿 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`

  1. .jokes-layout {
  2. display: flex;
  3. flex-direction: column;
  4. min-height: inherit;
  5. }
  6. .jokes-header {
  7. padding-top: 1rem;
  8. padding-bottom: 1rem;
  9. border-bottom: 1px solid var(--color-border);
  10. }
  11. .jokes-header .container {
  12. display: flex;
  13. justify-content: space-between;
  14. align-items: center;
  15. }
  16. .jokes-header .home-link {
  17. font-family: var(--font-display);
  18. font-size: 3rem;
  19. }
  20. .jokes-header .home-link a {
  21. color: var(--color-foreground);
  22. }
  23. .jokes-header .home-link a:hover {
  24. text-decoration: none;
  25. }
  26. .jokes-header .logo-medium {
  27. display: none;
  28. }
  29. .jokes-header a:hover {
  30. text-decoration-style: wavy;
  31. text-decoration-thickness: 1px;
  32. }
  33. .jokes-header .user-info {
  34. display: flex;
  35. gap: 1rem;
  36. align-items: center;
  37. white-space: nowrap;
  38. }
  39. .jokes-main {
  40. padding-top: 2rem;
  41. padding-bottom: 2rem;
  42. flex: 1 1 100%;
  43. }
  44. .jokes-main .container {
  45. display: flex;
  46. gap: 1rem;
  47. }
  48. .jokes-list {
  49. max-width: 12rem;
  50. }
  51. .jokes-outlet {
  52. flex: 1;
  53. }
  54. .jokes-footer {
  55. padding-top: 2rem;
  56. padding-bottom: 1rem;
  57. border-top: 1px solid var(--color-border);
  58. }
  59. @media print, (min-width: 640px) {
  60. .jokes-header .logo {
  61. display: none;
  62. }
  63. .jokes-header .logo-medium {
  64. display: block;
  65. }
  66. .jokes-main {
  67. padding-top: 3rem;
  68. padding-bottom: 3rem;
  69. }
  70. }
  71. @media (max-width: 639px) {
  72. .jokes-main .container {
  73. flex-direction: column;
  74. }
  75. }

💿 Also, download the font and it’s 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

  1. import type { LinksFunction } from "remix";
  2. import { Links, LiveReload, Outlet } from "remix";
  3. import globalStylesUrl from "./styles/global.css";
  4. import globalMediumStylesUrl from "./styles/global-medium.css";
  5. import globalLargeStylesUrl from "./styles/global-large.css";
  6. export const links: LinksFunction = () => {
  7. return [
  8. {
  9. rel: "stylesheet",
  10. href: globalStylesUrl
  11. },
  12. {
  13. rel: "stylesheet",
  14. href: globalMediumStylesUrl,
  15. media: "print, (min-width: 640px)"
  16. },
  17. {
  18. rel: "stylesheet",
  19. href: globalLargeStylesUrl,
  20. media: "screen and (min-width: 1024px)"
  21. }
  22. ];
  23. };
  24. export default function App() {
  25. return (
  26. <html lang="en">
  27. <head>
  28. <meta charSet="utf-8" />
  29. <title>Remix: So great, it's funny!</title>
  30. <Links />
  31. </head>
  32. <body>
  33. <Outlet />
  34. {process.env.NODE_ENV === "development" ? (
  35. <LiveReload />
  36. ) : null}
  37. </body>
  38. </html>
  39. );
  40. }
  41. ``` app/routes/jokes.tsx

import type { LinksFunction } from “remix”; import { Outlet, Link } from “remix”; import stylesUrl from “../styles/jokes.css”;

export const links: LinksFunction = () => { return [ { rel: “stylesheet”, href: stylesUrl } ]; };

export default function JokesRoute() { return (

🤪 J🤪KES

Get a random joke

Here are a few more jokes to check out:

  • Hippo
Add your own
); }

  1. 💿 Let's also add a link to the jokes from the homepage and follow some of the class names in the CSS to make the homepage look nice.
  2. app/routes/index.tsx

import type { LinksFunction } from “remix”; import { Link } from “remix”; import stylesUrl from “../styles/index.css”;

export const links: LinksFunction = () => { return [ { rel: “stylesheet”, href: stylesUrl } ]; };

export default function Index() { return (

Remix Jokes!

); }

  1. 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.
  2. 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.
  3. That said, many Remix users are very happy with [Tailwind](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 a generally a good approach because Remix can then leverage the browser platform for caching and loading/unloading.
  4. ## Database
  5. 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!).
  6. 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.
  7. ### Set up Prisma
  8. 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.
  9. 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.
  10. There are two packages that we need to get started:
  11. - `prisma` for interact with our database and schema during development
  12. - `@prisma/client` for making queries to our database during runtime.
  13. 💿 Install the prisma packages:

npm install —save-dev prisma npm install @prisma/client

  1. 💿 Now we can initialize prisma with sqlite:

npx prisma init —datasource-provider sqlite

  1. 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. Don’t forget to exclude .env to not commit any secret.

Next steps:

  1. Set the DATABASE_URL in the .env file to point to your existing database. If your database has no tables yet, read https://pris.ly/d/getting-started
  2. Run prisma db pull to turn your database schema into a Prisma schema.
  3. Run prisma generate to generate the Prisma Client. You can then start querying your database.

More information in our documentation: https://pris.ly/d/getting-started

  1. 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 scheme 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 }

  1. 💿 With that in place, run this:

npx prisma db push

  1. 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”

🚀 Your database is now in sync with your schema. Done in 194ms

✔ Generated Prisma Client (3.5.0) to ./node_modules/ @prisma/client in 26ms

  1. 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 it's API for interacting with our database.
  2. 💿 Let's add that `prisma/dev.db` to our `.gitignore` so we don't accidentally commit it to our repository. We'll also want to add the `.env` file to the `.gitignore` as mentioned in the prisma output so we don't commit our secrets!

node_modules

/.cache /build /public/build

/prisma/dev.db .env

  1. If your database gets messed up, you can always delete the `prisma/dev.db` file and run `npx prisma db push` again.
  2. 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):
  3. 💿 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. } ]; }

  1. Feel free to add your own jokes if you like.
  2. 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 datamodels grow in complexity). So we'll need a way to run it.
  3. 💿 Install `esbuild-register` as a dev dependency:

npm install —save-dev esbuild-register

  1. 💿 And now we can run our `seed.ts` file with that:

node —require esbuild-register prisma/seed.ts

  1. Now our database has those jokes in it. No joke!
  2. 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!
  3. 💿 Add this to your `package.json`:

// … “prisma”: { “seed”: “node —require esbuild-register prisma/seed.ts” }, “scripts”: { // …

  1. Now, whenever we reset the database, prisma will call our seeding file as well.
  2. ### Connect to the database
  3. 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();

  1. 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:
  2. > Warning: 10 Prisma Clients are already running
  3. So we've got a little bit of extra work to do to avoid this development time problem.
  4. 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.
  5. 💿 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. if (process.env.NODE_ENV === “production”) { db = new PrismaClient(); db.$connect(); } else { if (!global.db) { global.db = new PrismaClient(); global.db.$connect(); } db = global.db; }

export { db };

  1. I'll leave analysis of this code as an exercise for the reader because again, this has nothing to do with Remix directly.
  2. 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.
  3. ### Read from the database in a Remix loader
  4. Ok, ready to get back to writing Remix code? Me too!
  5. 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.
  6. To *load* data in a Remix route module, you use a [loader](https://remix.run/docs/en/v1.0.6/api/conventions#loader). This is simply an `async` function you export that returns a response, and is accessed on the component through the [useLoaderData](https://remix.run/docs/en/v1.0.6/api/remix#useloaderdata) hook. Here's a quick example:

// this is just an example. No need to copy/paste this 😄 import type { LoaderFunction } from “remix”; import type { User } from “@prisma/client”; import { db } from “~/utils/db.server”;

type LoaderData = { users: Array }; export let loader: LoaderFunction = async () => { const data: LoaderData = { users: await db.user.findMany() }; return { data }; };

export default function Users() { const data = useLoaderData(); return (

    {data.map(user => (
  • {user.name}
  • ))}
); }

  1. 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 😄
  2. 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.
  3. 💿 Update the `app/routes/jokes.tsx` route module to load jokes from our database and render a list of links to the jokes.
  4. app/routes/jokes.tsx

import { LinksFunction, LoaderFunction } from “remix”; import { Link, Outlet, useLoaderData } from “remix”; import { db } from “~/utils/db.server”; import stylesUrl from “../styles/jokes.css”;

export const links: LinksFunction = () => { return [ { rel: “stylesheet”, href: stylesUrl } ]; };

type LoaderData = { jokeListItems: Array<{ id: string; name: string }>; };

export const loader: LoaderFunction = async () => { const data: LoaderData = { jokeListItems: await db.joke.findMany() }; return data; };

export default function JokesRoute() { const data = useLoaderData();

return (

🤪 J🤪KES

Get a random joke

Here are a few more jokes to check out:

    {data.jokeListItems.map(joke => (
  • {joke.name}
  • ))}
Add your own
); }

  1. And here's what we have with that now:
  2. ![List of links to jokes](/projects/remix-1.0-en/3b8494d40e69d30615a8bdec0f4bb718.png)
  3. ### Data overfetching
  4. I want to call out something specific in my solution. Here's my loader:

type LoaderData = { jokeListItems: Array<{ id: string; name: string }>; };

export const loader: LoaderFunction = async () => { const data: LoaderData = { jokeListItems: await db.joke.findMany({ take: 5, select: { id: true, name: true }, orderBy: { createdAt: “desc” } }) }; return data; };

  1. 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.
  2. 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.
  3. 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!
  4. ### Network Type Safety
  5. In our code we're using the `useLoaderData`'s type generic and specifying our `LoaderData` 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).
  6. 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.
  7. ### Wrap up database queries
  8. 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: LoaderFunction = async ({ params }) => { console.log(params); // <— {jokeId: “123”} };

  1. And here's how you get the joke from prisma:

const joke = await db.joke.findUnique({ where: { id: jokeId } });

  1. 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`.
  2. 💿 Great! Now you know everything you need to continue and connect the `/jokes/:jokeId` route in `app/routes/jokes/$jokeId.tsx`.
  3. app/routes/jokes/$jokeId.tsx

import type { LoaderFunction } from “remix”; import { Link, useLoaderData } from “remix”; import type { Joke } from “@prisma/client”; import { db } from “~/utils/db.server”;

type LoaderData = { joke: Joke };

export const loader: LoaderFunction = async ({ params }) => { const joke = await db.joke.findUnique({ where: { id: params.jokeId } }); if (!joke) throw new Error(“Joke not found”); const data: LoaderData = { joke }; return data; };

export default function JokeRoute() { const data = useLoaderData();

return (

Here’s your hilarious joke:

{data.joke.content}

{data.joke.name} Permalink
); }

  1. With that you should be able to go to [/jokes](http://localhost:3000/jokes) and click on a link to get the joke:
  2. ![Jokes page showing a unique joke](/projects/remix-1.0-en/8a3cb6a8086e234b0e7e6d2495773952.png)
  3. We'll handle the case where someone tries to access a joke that doesn't exist in the database in the next section.
  4. Next, let's handle the `/jokes` index route in `app/routes/jokes/index.tsx` that shows a random joke.
  5. 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({ take: 1, skip: randomRowNumber });

  1. 💿 You should be able to get the loader working from there.
  2. app/routes/jokes/index.tsx

import type { LoaderFunction } from “remix”; import { useLoaderData, Link } from “remix”; import type { Joke } from “@prisma/client”; import { db } from “~/utils/db.server”;

type LoaderData = { randomJoke: Joke };

export const loader: LoaderFunction = async () => { const count = await db.joke.count(); const randomRowNumber = Math.floor(Math.random() * count); const [randomJoke] = await db.joke.findMany({ take: 1, skip: randomRowNumber }); const data: LoaderData = { randomJoke }; return data; };

export default function JokesIndexRoute() { const data = useLoaderData();

return (

Here’s a random joke:

{data.randomJoke.content}

“{data.randomJoke.name}” Permalink
); }

  1. With that your [/jokes](http://localhost:3000/jokes) route should display a list of links to jokes as well as a random joke:
  2. ![Jokes page showing a random joke](/projects/remix-1.0-en/1e743d886df72890a9daf5c0b3881271.png)
  3. ## Mutations
  4. 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

); }

  1. 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](https://remix.run/docs/en/v1.0.6/api/conventions#action) function export! Read up on that a bit.
  2. Here's the prisma code you'll need:

const joke = await db.joke.create({ data: { name, content } });

  1. 💿 Create an `action` in `app/routes/jokes/new.tsx`.
  2. app/routes/jokes/new.tsx

import type { ActionFunction } from “remix”; import { redirect } from “remix”; import { db } from “~/utils/db.server”;

export const action: ActionFunction = async ({ request }) => { const form = await request.formData(); const name = form.get(“name”); const content = form.get(“content”); // we do this type check to be extra sure and to make TypeScript happy // we’ll explore validation next! if ( typeof name !== “string” || typeof content !== “string” ) { throw new Error(Form not submitted correctly.); }

const fields = { name, content };

const joke = await db.joke.create({ data: fields }); return redirect(/jokes/${joke.id}); };

export default function NewJokeRoute() { return (

Add your own hilarious joke

); }

  1. If you've got that working, you should be able to create new jokes and be redirected to the new joke's page.
  2. 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.
  3. ![Create new joke form filled out](/projects/remix-1.0-en/9693aa859457295e7902532effa116eb.png)
  4. ![Newly created joke displayed](/projects/remix-1.0-en/99d9d8f7b244b58af04ff550b472ee4f.png)
  5. 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.
  6. 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 😎
  7. 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.
  8. 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 serializeable 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.
  9. 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](https://remix.run/docs/en/v1.0.6/api/remix#useactiondata) and display them to the user.
  10. 💿 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.
  11. app/routes/jokes/new.tsx

import type { ActionFunction } from “remix”; import { useActionData, redirect } from “remix”; import { db } from “~/utils/db.server”;

function validateJokeContent(content: string) { if (content.length < 10) { return That joke is too short; } }

function validateJokeName(name: string) { if (name.length < 2) { return That joke's name is too short; } }

type ActionData = { formError?: string; fieldErrors?: { name: string | undefined; content: string | undefined; }; fields?: { name: string; content: string; }; };

export const action: ActionFunction = async ({ request }): Promise => { const form = await request.formData(); const name = form.get(“name”); const content = form.get(“content”); if ( typeof name !== “string” || typeof content !== “string” ) { return { formError: Form not submitted correctly. }; }

const fieldErrors = { name: validateJokeName(name), content: validateJokeContent(content) }; const fields = { name, content }; if (Object.values(fieldErrors).some(Boolean)) { return { fieldErrors, fields }; }

const joke = await db.joke.create({ data: fields }); return redirect(/jokes/${joke.id}); };

export default function NewJokeRoute() { const actionData = useActionData< ActionData | undefined

();

return (

Add your own hilarious joke

{actionData?.fieldErrors?.name ? ( ) : null}
{actionData?.fieldErrors?.content ? ( ) : null}
); }

  1. Great! You should now have a form that validates the fields on the server and displays those errors on the client:
  2. ![New joke form with validation errors](/projects/remix-1.0-en/4edf1839160d176347d0c9202faf14fa.png)
  3. 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.
  4. First I want you to notice that I've added an `ActionData` type so we could 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.
  5. 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.
  6. 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.
  7. 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!
  8. ## Authentication
  9. 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.
  10. One thing that would be good to understand for this section is how [HTTP cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies) work on the web.
  11. We're going to handroll our own authentication from scratch. Don't worry, I promise it's not as scary as it sounds.
  12. ### Preparing the database
  13. Remember, if your database gets messed up, you can always delete the `prisma/dev.db` file and run `npx prisma db push` again.
  14. 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 }

  1. With that updated, let's go ahead and reset our database to this schema:
  2. 💿 Run this:

npx prisma db push

  1. It will prompt you to reset the database, hit "y" to confirm.
  2. 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 schema. Done in 1.56s

✔ Generated Prisma Client (3.5.0) to ./node_modules/@prisma/ client in 34ms

  1. 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.
  2. 💿 Let's start by fixing our `prisma/seed.ts` file.

import { PrismaClient } from “@prisma/client”; const prisma = new PrismaClient();

async function seed() { const kody = await prisma.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 prisma.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. } ]; }

  1. 💿 Great, now run the seed again:

npx prisma db seed

  1. And that outputs:

Environment variables loaded from .env Running seed command node --require esbuild-register prisma/seed.ts

🌱 The seed command has been executed.

  1. Great! Our database is now ready to go.
  2. ### Auth Flow Overview
  3. So our authentication will be of the traditional username/password variety. We'll be using [bcrypt](https://npm.im/bcrypt) to hash our passwords so nobody will be able to reasonably brute-force their way into an account.
  4. 💿 Go ahead and get that installed right now so we don't forget:

npm install bcrypt

  1. 💿 The `bcrypt` library has TypeScript definitions in DefinitelyTyped, so let's install those as well:

npm install —save-dev @types/bcrypt

  1. Let me give you a quick diagram of the flow of things:
  2. ![Excalidraw Authentication diagram](/projects/remix-1.0-en/de722758353dc735348b653d7a4049b0.png)
  3. Here's that written out:
  4. - On the `/login` route.
  5. - User submits login form.
  6. - Form data is validated.
  7. - If the form data is invalid, return the form with the errors.
  8. - Login type is "register"
  9. - Check whether the username is available
  10. - If the username is not available, return the form with an error.
  11. - Hash the password
  12. - Create a new user
  13. - Login type is "login"
  14. - Check whether the user exists
  15. - If the user doesn't exist, return the form with an error.
  16. - Check whether the password hash matches
  17. - If the password hash doesn't match, return the form with an error.
  18. - Create a new session
  19. - Redirect to the `/jokes` route with the `Set-Cookie` header.
  20. ### Build the login form
  21. Alright, enough high-level stuff. Let's start writing some Remix code!
  22. We're going to create a login page, and I've got some CSS for you to use on that page:
  23. 💿 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; }

  1. 💿 Create a `/login` route by adding a `app/routes/login.tsx` file.
  2. app/routes/login.tsx

import type { LinksFunction } from “remix”; import { Link, useSearchParams } from “remix”; import stylesUrl from “../styles/login.css”;

export const links: LinksFunction = () => { return [{ rel: “stylesheet”, href: stylesUrl }]; };

export default function Login() { const [searchParams] = useSearchParams(); return (

Login

Login or Register?
  • Home
  • Jokes
); }

  1. This should look something like this:
  2. ![A login form with a login/register radio button and username/password fields and a submit button](/projects/remix-1.0-en/e7f6fe488c109e88d6928fc58144af7c.png)
  3. 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.
  4. 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).
  5. 💿 Implement validation with an `action` in `app/routes/login.tsx`
  6. app/routes/login.tsx

import type { ActionFunction, LinksFunction } from “remix”; import { useActionData, Link, useSearchParams } from “remix”; import { db } from “~/utils/db.server”; import stylesUrl from “../styles/login.css”;

export const links: LinksFunction = () => { return [{ rel: “stylesheet”, href: stylesUrl }]; };

function validateUsername(username: unknown) { if (typeof username !== “string” || username.length < 3) { return Usernames must be at least 3 characters long; } }

function validatePassword(password: unknown) { if (typeof password !== “string” || password.length < 6) { return Passwords must be at least 6 characters long; } }

type ActionData = { formError?: string; fieldErrors?: { username: string | undefined; password: string | undefined; }; fields?: { loginType: string; username: string; password: string; }; };

export const action: ActionFunction = async ({ request }): Promise => { const form = await request.formData(); const loginType = form.get(“loginType”); const username = form.get(“username”); const password = form.get(“password”); const redirectTo = form.get(“redirectTo”); if ( typeof loginType !== “string” || typeof username !== “string” || typeof password !== “string” || typeof redirectTo !== “string” ) { return { formError: Form not submitted correctly. }; }

const fields = { loginType, username, password }; const fieldErrors = { username: validateUsername(username), password: validatePassword(password) }; if (Object.values(fieldErrors).some(Boolean)) return { fieldErrors, fields };

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 { fields, formError: “Not implemented” }; } case “register”: { const userExists = await db.user.findFirst({ where: { username } }); if (userExists) { return { fields, formError: User with username ${username} already exists }; } // create the user // create their session and redirect to /jokes return { fields, formError: “Not implemented” }; } default: { return { fields, formError: Login type invalid }; } } };

export default function Login() { const actionData = useActionData< ActionData | undefined

(); const [searchParams] = useSearchParams(); return (

Login

<form method=”post” aria-describedby={ actionData?.formError ? “form-error-message” : undefined }

  1. <input
  2. type="hidden"
  3. name="redirectTo"
  4. value={
  5. searchParams.get("redirectTo") ?? undefined
  6. }
  7. />
  8. <fieldset>
  9. <legend className="sr-only">
  10. Login or Register?
  11. </legend>
  12. <label>
  13. <input
  14. type="radio"
  15. name="loginType"
  16. value="login"
  17. defaultChecked={
  18. !actionData?.fields?.loginType ||
  19. actionData?.fields?.loginType === "login"
  20. }
  21. />{" "}
  22. Login
  23. </label>
  24. <label>
  25. <input
  26. type="radio"
  27. name="loginType"
  28. value="register"
  29. defaultChecked={
  30. actionData?.fields?.loginType ===
  31. "register"
  32. }
  33. />{" "}
  34. Register
  35. </label>
  36. </fieldset>
  37. <div>
  38. <label htmlFor="username-input">Username</label>
  39. <input
  40. type="text"
  41. id="username-input"
  42. name="username"
  43. defaultValue={actionData?.fields?.username}
  44. aria-invalid={Boolean(
  45. actionData?.fieldErrors?.username
  46. )}
  47. aria-describedby={
  48. actionData?.fieldErrors?.username
  49. ? "username-error"
  50. : undefined
  51. }
  52. />
  53. {actionData?.fieldErrors?.username ? (
  54. <p
  55. className="form-validation-error"
  56. role="alert"
  57. id="username-error"
  58. {actionData?.fieldErrors.username}
  59. </p>
  60. ) : null}
  61. </div>
  62. <div>
  63. <label htmlFor="password-input">Password</label>
  64. <input
  65. id="password-input"
  66. name="password"
  67. defaultValue={actionData?.fields?.password}
  68. type="password"
  69. aria-invalid={
  70. Boolean(
  71. actionData?.fieldErrors?.password
  72. ) || undefined
  73. }
  74. aria-describedby={
  75. actionData?.fieldErrors?.password
  76. ? "password-error"
  77. : undefined
  78. }
  79. />
  80. {actionData?.fieldErrors?.password ? (
  81. <p
  82. className="form-validation-error"
  83. role="alert"
  84. id="password-error"
  85. {actionData?.fieldErrors.password}
  86. </p>
  87. ) : null}
  88. </div>
  89. <div id="form-error-message">
  90. {actionData?.formError ? (
  91. <p
  92. className="form-validation-error"
  93. role="alert"
  94. {actionData?.formError}
  95. </p>
  96. ) : null}
  97. </div>
  98. <button type="submit" className="button">
  99. Submit
  100. </button>
  101. </form>
  102. </div>
  103. <div className="links">
  104. <ul>
  105. <li>
  106. <Link to="/">Home</Link>
  107. </li>
  108. <li>
  109. <Link to="/jokes">Jokes</Link>
  110. </li>
  111. </ul>
  112. </div>
  113. </div>

); } ```

Once you’ve got that done, your form should look something like this:

Login form with errors

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 the username and password
  • Queries prisma for a user with the username
  • If there is no user, return null
  • Use bcrypt.compare to compare the give password to the user’s passwordHash
  • 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

  1. import bcrypt from "bcrypt";
  2. import { db } from "./db.server";
  3. type LoginForm = {
  4. username: string;
  5. password: string;
  6. };
  7. export async function login({
  8. username,
  9. password
  10. }: LoginForm) {
  11. const user = await db.user.findUnique({
  12. where: { username }
  13. });
  14. if (!user) return null;
  15. const isCorrectPassword = await bcrypt.compare(
  16. password,
  17. user.passwordHash
  18. );
  19. if (!isCorrectPassword) return null;
  20. return user;
  21. }

Great, with that in place, now we can update app/routes/login.tsx to use it:

app/routes/login.tsx

  1. import type { ActionFunction, LinksFunction } from "remix";
  2. import { useActionData, Link } from "remix";
  3. import { db } from "~/utils/db.server";
  4. import { login } from "~/utils/session.server";
  5. import stylesUrl from "../styles/login.css";
  6. // ...
  7. export const action: ActionFunction = async ({
  8. request
  9. }): Promise<Response | ActionData> => {
  10. // ...
  11. switch (loginType) {
  12. case "login": {
  13. const user = await login({ username, password });
  14. console.log({ user });
  15. if (!user) {
  16. return {
  17. fields,
  18. formError: `Username/Password combination is incorrect`
  19. };
  20. }
  21. // if there is a user, create their session and redirect to /jokes
  22. return { fields, formError: "Not implemented" };
  23. }
  24. // ...
  25. }
  26. };
  27. export default function Login() {
  28. // ...
  29. }

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 login with the username “kody” and the password “twixrox” and check the terminal output. Here’s what I get:

  1. {
  2. user: {
  3. id: '1dc45f54-4061-4d9e-8a6d-28d6df6a8d7f',
  4. createdAt: 2021-11-21T00:28:52.560Z,
  5. updatedAt: 2021-11-21T00:28:52.560Z,
  6. username: 'kody',
  7. passwordHash: '$2b$10$K7L1OJ45/4Y2nIvhRVpCe.FSmhDdWoXehVzJptJ/op0lSsvqNu/1u'
  8. }
  9. }

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 storage commitSession function)

Note: If you need a hand, there’s a small example of how the whole basic flow goes in the session docs. Once you have that, you’ll want to use it in app/routes/login.tsx to set the session and redirect to the /jokes route.

app/utils/session.server.ts

  1. import bcrypt from "bcrypt";
  2. import {
  3. createCookieSessionStorage,
  4. redirect
  5. } from "remix";
  6. import { db } from "./db.server";
  7. type LoginForm = {
  8. username: string;
  9. password: string;
  10. };
  11. export async function login({
  12. username,
  13. password
  14. }: LoginForm) {
  15. const user = await db.user.findUnique({
  16. where: { username }
  17. });
  18. if (!user) return null;
  19. const isCorrectPassword = await bcrypt.compare(
  20. password,
  21. user.passwordHash
  22. );
  23. if (!isCorrectPassword) return null;
  24. return user;
  25. }
  26. const sessionSecret = process.env.SESSION_SECRET;
  27. if (!sessionSecret) {
  28. throw new Error("SESSION_SECRET must be set");
  29. }
  30. const storage = createCookieSessionStorage({
  31. cookie: {
  32. name: "RJ_session",
  33. // normally you want this to be `secure: true`
  34. // but that doesn't work on localhost for Safari
  35. // https://web.dev/when-to-use-local-https/
  36. secure: process.env.NODE_ENV === "production",
  37. secrets: [sessionSecret],
  38. sameSite: "lax",
  39. path: "/",
  40. maxAge: 60 * 60 * 24 * 30,
  41. httpOnly: true
  42. }
  43. });
  44. export async function createUserSession(
  45. userId: string,
  46. redirectTo: string
  47. ) {
  48. const session = await storage.getSession();
  49. session.set("userId", userId);
  50. return redirect(redirectTo, {
  51. headers: {
  52. "Set-Cookie": await storage.commitSession(session)
  53. }
  54. });
  55. }
  56. ``` app/routes/login.tsx

// … case “login”: { const user = await login({ username, password }); if (!user) { return { fields, formError: Username/Password combination is incorrect }; } return createUserSession(user.id, redirectTo); } // …

  1. 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 badies 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 (alternatively, during development we could use [dotenv](https://npm.im/dotenv) to load that when our app boots up).
  2. 💿 Update .env file with SESSION\_SECRET (with any value you like).
  3. 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:
  4. ![DevTools Network tab showing a "Set-Cookie" header on the POST response](/projects/remix-1.0-en/4ced0621e6e0c2f76d080052161d5376.png)
  5. 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.
  6. ![DevTools Application tab showing ](/projects/remix-1.0-en/50afca5dae9603623f17d4180d587467.png)
  7. 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)):
  8. ![Request headers showing the Cookie](/projects/remix-1.0-en/6fa89af444430aa23ae949c4231ad641.png)
  9. 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 `prisma.joke.create` call.
  10. Remember to check [the docs](https://remix.run/docs/en/v1.0.6/api/remix#sessions) to learn how to get the session from the request
  11. 💿 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(userId: string)`.
  12. app/utils/session.server.ts

import bcrypt from “bcrypt”; import { createCookieSessionStorage, redirect } from “remix”; 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 user; }

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 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) } }); }

  1. Did you notice in my example that we're `throw`ing a `Response`?!
  2. 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!
  3. We'll cover this more in the error handling sections later.
  4. You may also notice that our solution makes use of the `login` route's `redirectTo` feature we had earlier.
  5. 💿 Now update `app/routes/jokes/new.tsx` to use that function to get the userId and pass it to the `prisma.joke.create` call.
  6. app/routes/jokes/new.tsx

import type { ActionFunction } from “remix”; import { useActionData, redirect } from “remix”; import { db } from “~/utils/db.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 < 2) { return That joke's name is too short; } }

type ActionData = { formError?: string; fieldErrors?: { name: string | undefined; content: string | undefined; }; fields?: { name: string; content: string; }; };

export const action: ActionFunction = async ({ request }): Promise => { const userId = await requireUserId(request); const form = await request.formData(); const name = form.get(“name”); const content = form.get(“content”); if ( typeof name !== “string” || typeof content !== “string” ) { return { formError: Form not submitted correctly. }; }

const fieldErrors = { name: validateJokeName(name), content: validateJokeContent(content) }; const fields = { name, content }; if (Object.values(fieldErrors).some(Boolean)) { return { fieldErrors, fields }; }

const joke = await db.joke.create({ data: { …fields, jokesterId: userId } }); return redirect(/jokes/${joke.id}); };

export default function NewJokeRoute() { const actionData = useActionData< ActionData | undefined

();

return (

Add your own hilarious joke

{actionData?.fieldErrors?.name ? ( ) : null}
{actionData?.fieldErrors?.content ? ( ) : null}
); }

  1. 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.
  2. ### Build Logout Action
  3. 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.
  4. 💿 Update `app/utils/session.server.ts` to add a `getUser` function that gets the user from prisma and a `logout` function that uses [destroySession](https://remix.run/docs/en/v1.0.6/api/remix#using-sessions) to log the user out.
  5. app/utils/session.server.ts

import bcrypt from “bcrypt”; import { createCookieSessionStorage, redirect } from “remix”; 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 user; }

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 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; }

try { const user = await db.user.findUnique({ where: { id: userId } }); return user; } catch { throw logout(request); } }

export async function logout(request: Request) { const session = await storage.getSession( request.headers.get(“Cookie”) ); 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) } }); }

  1. 💿 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.
  2. app/routes/jokes.tsx

import { User } from “@prisma/client”; import { Link, LinksFunction, LoaderFunction, useLoaderData } from “remix”; import { Outlet } from “remix”; import { db } from “~/utils/db.server”; import { getUser } from “~/utils/session.server”; import stylesUrl from “../styles/jokes.css”;

export const links: LinksFunction = () => { return [ { rel: “stylesheet”, href: stylesUrl } ]; };

type LoaderData = { user: User | null; jokeListItems: Array<{ id: string; name: string }>; };

export const loader: LoaderFunction = async ({ request }) => { const jokeListItems = await db.joke.findMany({ take: 5, orderBy: { createdAt: “desc” }, select: { id: true, name: true } }); const user = await getUser(request);

const data: LoaderData = { jokeListItems, user }; return data; };

export default function JokesRoute() { const data = useLoaderData();

return (

🤪 J🤪KES

{data.user ? (
{Hi ${data.user.username}}
) : ( Login )}
Get a random joke

Here are a few more jokes to check out:

    {data.jokeListItems.map(joke => (
  • {joke.name}
  • ))}
Add your own
); }

import type { ActionFunction, LoaderFunction } from “remix”; import { redirect } from “remix”; import { logout } from “~/utils/session.server”;

export const action: ActionFunction = async ({ request }) => { return logout(request); };

export const loader: LoaderFunction = async () => { return redirect(“/“); };

  1. 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.
  2. 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 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

  1. 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.
  2. Terrific, now our app looks like this:
  3. ![Jokes page nice and designed](/projects/remix-1.0-en/78a1b128997357c5283128b4351834c2.png)
  4. ![New Joke form designed](/projects/remix-1.0-en/175d0d4dbb6996eb61c20828d1721c21.png)
  5. ### User Registration
  6. 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.
  7. 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.
  8. 💿 Update both `app/utils/session.server.ts` and `app/routes/login.tsx` to handle user registration.
  9. app/utils/session.server.ts

import bcrypt from “bcrypt”; import { createCookieSessionStorage, redirect } from “remix”; import { db } from “./db.server”;

type LoginForm = { username: string; password: string; };

export async function register({ username, password }: LoginForm) { const passwordHash = await bcrypt.hash(password, 10); return db.user.create({ data: { username, passwordHash } }); }

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 user; }

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 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; }

try { const user = await db.user.findUnique({ where: { id: userId } }); return user; } catch { throw logout(request); } }

export async function logout(request: Request) { const session = await storage.getSession( request.headers.get(“Cookie”) ); 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 { ActionFunction, LinksFunction } from “remix”; import { useActionData, useSearchParams, Link } from “remix”; import { db } from “~/utils/db.server”; import { createUserSession, login, register } from “~/utils/session.server”; import stylesUrl from “../styles/login.css”;

export const links: LinksFunction = () => { return [{ rel: “stylesheet”, href: stylesUrl }]; };

function validateUsername(username: unknown) { if (typeof username !== “string” || username.length < 3) { return Usernames must be at least 3 characters long; } }

function validatePassword(password: unknown) { if (typeof password !== “string” || password.length < 6) { return Passwords must be at least 6 characters long; } }

type ActionData = { formError?: string; fieldErrors?: { username: string | undefined; password: string | undefined; }; fields?: { loginType: string; username: string; password: string; }; };

export const action: ActionFunction = async ({ request }): Promise => { const form = await request.formData(); const loginType = form.get(“loginType”); const username = form.get(“username”); const password = form.get(“password”); const redirectTo = form.get(“redirectTo”) || “/jokes”; if ( typeof loginType !== “string” || typeof username !== “string” || typeof password !== “string” || typeof redirectTo !== “string” ) { return { formError: Form not submitted correctly. }; }

const fields = { loginType, username, password }; const fieldErrors = { username: validateUsername(username), password: validatePassword(password) }; if (Object.values(fieldErrors).some(Boolean)) return { fieldErrors, fields };

switch (loginType) { case “login”: { const user = await login({ username, password }); if (!user) { return { 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 { fields, formError: User with username ${username} already exists }; } const user = await register({ username, password }); if (!user) { return { fields, formError: Something went wrong trying to create a new user. }; } return createUserSession(user.id, redirectTo); } default: { return { fields, formError: Login type invalid }; } } };

export default function Login() { const actionData = useActionData< ActionData | undefined

(); const [searchParams] = useSearchParams(); return (

Login

<form method=”post” aria-describedby={ actionData?.formError ? “form-error-message” : undefined }

  1. <input
  2. type="hidden"
  3. name="redirectTo"
  4. value={
  5. searchParams.get("redirectTo") ?? undefined
  6. }
  7. />
  8. <fieldset>
  9. <legend className="sr-only">
  10. Login or Register?
  11. </legend>
  12. <label>
  13. <input
  14. type="radio"
  15. name="loginType"
  16. value="login"
  17. defaultChecked={
  18. !actionData?.fields?.loginType ||
  19. actionData?.fields?.loginType === "login"
  20. }
  21. />{" "}
  22. Login
  23. </label>
  24. <label>
  25. <input
  26. type="radio"
  27. name="loginType"
  28. value="register"
  29. defaultChecked={
  30. actionData?.fields?.loginType ===
  31. "register"
  32. }
  33. />{" "}
  34. Register
  35. </label>
  36. </fieldset>
  37. <div>
  38. <label htmlFor="username-input">Username</label>
  39. <input
  40. type="text"
  41. id="username-input"
  42. name="username"
  43. defaultValue={actionData?.fields?.username}
  44. aria-invalid={Boolean(
  45. actionData?.fieldErrors?.username
  46. )}
  47. aria-describedby={
  48. actionData?.fieldErrors?.username
  49. ? "username-error"
  50. : undefined
  51. }
  52. />
  53. {actionData?.fieldErrors?.username ? (
  54. <p
  55. className="form-validation-error"
  56. role="alert"
  57. id="username-error"
  58. {actionData?.fieldErrors.username}
  59. </p>
  60. ) : null}
  61. </div>
  62. <div>
  63. <label htmlFor="password-input">Password</label>
  64. <input
  65. id="password-input"
  66. name="password"
  67. defaultValue={actionData?.fields?.password}
  68. type="password"
  69. aria-invalid={
  70. Boolean(
  71. actionData?.fieldErrors?.password
  72. ) || undefined
  73. }
  74. aria-describedby={
  75. actionData?.fieldErrors?.password
  76. ? "password-error"
  77. : undefined
  78. }
  79. />
  80. {actionData?.fieldErrors?.password ? (
  81. <p
  82. className="form-validation-error"
  83. role="alert"
  84. id="password-error"
  85. {actionData?.fieldErrors.password}
  86. </p>
  87. ) : null}
  88. </div>
  89. <div id="form-error-message">
  90. {actionData?.formError ? (
  91. <p
  92. className="form-validation-error"
  93. role="alert"
  94. {actionData?.formError}
  95. </p>
  96. ) : null}
  97. </div>
  98. <button type="submit" className="button">
  99. Submit
  100. </button>
  101. </form>
  102. </div>
  103. <div className="links">
  104. <ul>
  105. <li>
  106. <Link to="/">Home</Link>
  107. </li>
  108. <li>
  109. <Link to="/jokes">Jokes</Link>
  110. </li>
  111. </ul>
  112. </div>
  113. </div>

); } ```

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. With Remix, your route modules can export an ErrorBoundary component and it will be used. But it’s even cooler because it works on the server too! Not only that, but it’ll handle errors in loaders and actions too! Wowza! So let’s get to it!

We’re going to add four Error Boundaries 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

  1. import type { LinksFunction } from "remix";
  2. import { Links, LiveReload, Outlet } from "remix";
  3. import globalStylesUrl from "./styles/global.css";
  4. import globalMediumStylesUrl from "./styles/global-medium.css";
  5. import globalLargeStylesUrl from "./styles/global-large.css";
  6. export const links: LinksFunction = () => {
  7. return [
  8. {
  9. rel: "stylesheet",
  10. href: globalStylesUrl
  11. },
  12. {
  13. rel: "stylesheet",
  14. href: globalMediumStylesUrl,
  15. media: "print, (min-width: 640px)"
  16. },
  17. {
  18. rel: "stylesheet",
  19. href: globalLargeStylesUrl,
  20. media: "screen and (min-width: 1024px)"
  21. }
  22. ];
  23. };
  24. function Document({
  25. children,
  26. title = `Remix: So great, it's funny!`
  27. }: {
  28. children: React.ReactNode;
  29. title?: string;
  30. }) {
  31. return (
  32. <html lang="en">
  33. <head>
  34. <meta charSet="utf-8" />
  35. <title>Remix: So great, it's funny!</title>
  36. <Links />
  37. </head>
  38. <body>
  39. {children}
  40. {process.env.NODE_ENV === "development" ? (
  41. <LiveReload />
  42. ) : null}
  43. </body>
  44. </html>
  45. );
  46. }
  47. export default function App() {
  48. return (
  49. <Document>
  50. <Outlet />
  51. </Document>
  52. );
  53. }
  54. export function ErrorBoundary({ error }: { error: Error }) {
  55. return (
  56. <Document title="Uh-oh!">
  57. <div className="error-container">
  58. <h1>App Error</h1>
  59. <pre>{error.message}</pre>
  60. </div>
  61. </Document>
  62. );
  63. }
  64. ``` app/routes/jokes/$jokeId.tsx

// …

import { Link, useLoaderData, useParams } from “remix”;

// …

export function ErrorBoundary() { const { jokeId } = useParams(); return (

{There was an error loading joke by the id ${jokeId}. Sorry.}
); }

// …

export function ErrorBoundary() { return (

Something unexpected went wrong. Sorry about that.
); }

// …

export function ErrorBoundary() { return (

I did a whoopsies.
); }

  1. 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. Here's what I get:
  2. ![App error](/projects/remix-1.0-en/ea6657465141c17c317780fe0790b3b2.png)
  3. ![Joke Page Error](/projects/remix-1.0-en/e0150313341b6c142caf554fc97b67c2.png)
  4. ![Joke Index Page Error](/projects/remix-1.0-en/7cefd7780f8068d9696403fa2abd4bf7.png)
  5. ![New Joke Page Error](/projects/remix-1.0-en/6eec2c3fc32e3cf97d6c9ec5223ff191.png)
  6. 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!
  7. ## Expected errors
  8. 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`).
  9. 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)).
  10. For client error responses, Remix offers something similar to Error Boundaries. It's called [Catch Boundaries](https://remix.run/docs/en/v1.0.6/api/conventions#catchboundary) and it works almost exactly the same. In this case, when 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 `CatchBoundary`. Just like the `useLoaderData` hook to get data from the `laoder` and the `useActionData` hook to get data from the `action`, the `CatchBoundary` gets its data from the `useCaught` hook. This will return the `Response` that was thrown.
  11. 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.
  12. `ErrorBoundary` and `CatchBoundary` allow 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.
  13. With that understanding, we're going to add a `CatchBoundary` component to the following routes:
  14. - `app/root.tsx` - Just as a last resort fallback.
  15. - `app/routes/jokes/$jokeId.tsx` - When a user tries to access a joke that doesn't exist (404).
  16. - `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.
  17. - `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.)
  18. 💿 Let's add these CatchBoundaries to the routes.
  19. app/root.tsx

import type { LinksFunction } from “remix”; import { Links, LiveReload, Outlet, useCatch } from “remix”;

import globalStylesUrl from “./styles/global.css”; import globalMediumStylesUrl from “./styles/global-medium.css”; import globalLargeStylesUrl from “./styles/global-large.css”;

export const links: LinksFunction = () => { return [ { 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! }: { children: React.ReactNode; title?: string; }) { return ( {children} {process.env.NODE_ENV === “development” ? ( ) : null} ); }

export default function App() { return ( ); }

export function CatchBoundary() { const caught = useCatch();

return (

{caught.status} {caught.statusText}

); }

export function ErrorBoundary({ error }: { error: Error }) { return (

App Error

  1. {error.message}
); }

import type { LoaderFunction } from “remix”; import { Link, useLoaderData, useCatch, useParams } from “remix”; import type { Joke } from “@prisma/client”; import { db } from “~/utils/db.server”;

type LoaderData = { joke: Joke };

export const loader: LoaderFunction = async ({ params }) => { const joke = await db.joke.findUnique({ where: { id: params.jokeId } }); if (!joke) { throw new Response(“What a joke! Not found.”, { status: 404 }); } const data: LoaderData = { joke }; return data; };

export default function JokeRoute() { const data = useLoaderData();

return (

Here’s your hilarious joke:

{data.joke.content}

{data.joke.name} Permalink
); }

export function CatchBoundary() { const caught = useCatch(); const params = useParams(); if (caught.status === 404) { return (

Huh? What the heck is “{params.jokeId}”?
); } throw new Error(Unhandled error: ${caught.status}); }

export function ErrorBoundary() { const { jokeId } = useParams(); return (

{There was an error loading joke by the id ${jokeId}. Sorry.}
); }

import type { LoaderFunction } from “remix”; import { useLoaderData, Link, useCatch } from “remix”; import type { Joke } from “@prisma/client”; import { db } from “~/utils/db.server”;

type LoaderData = { randomJoke: Joke };

export const loader: LoaderFunction = async () => { const count = await db.joke.count(); const randomRowNumber = Math.floor(Math.random() * count); const [randomJoke] = await db.joke.findMany({ take: 1, skip: randomRowNumber }); if (!randomJoke) { throw new Response(“No random joke found”, { status: 404 }); } const data: LoaderData = { randomJoke }; return data; };

export default function JokesIndexRoute() { const data = useLoaderData();

return (

Here’s a random joke:

{data.randomJoke.content}

“{data.randomJoke.name}” Permalink
); }

export function CatchBoundary() { const caught = useCatch();

if (caught.status === 404) { return (

There are no jokes to display.
); } throw new Error( Unexpected caught response with status: ${caught.status} ); }

export function ErrorBoundary() { return (

I did a whoopsies.
); }

import type { ActionFunction, LoaderFunction } from “remix”; import { useActionData, redirect, useCatch, Link } from “remix”; import { db } from “~/utils/db.server”; import { requireUserId, getUserId } from “~/utils/session.server”;

export const loader: LoaderFunction = async ({ request }) => { const userId = await getUserId(request); if (!userId) { throw new Response(“Unauthorized”, { status: 401 }); } return {}; };

function validateJokeContent(content: string) { if (content.length < 10) { return That joke is too short; } }

function validateJokeName(name: string) { if (name.length < 2) { return That joke's name is too short; } }

type ActionData = { formError?: string; fieldErrors?: { name: string | undefined; content: string | undefined; }; fields?: { name: string; content: string; }; };

export const action: ActionFunction = async ({ request }): Promise => { const userId = await requireUserId(request); const form = await request.formData(); const name = form.get(“name”); const content = form.get(“content”); if ( typeof name !== “string” || typeof content !== “string” ) { return { formError: Form not submitted correctly. }; }

const fieldErrors = { name: validateJokeName(name), content: validateJokeContent(content) }; const fields = { name, content }; if (Object.values(fieldErrors).some(Boolean)) { return { fieldErrors, fields }; }

const joke = await db.joke.create({ data: { …fields, jokesterId: userId } }); return redirect(/jokes/${joke.id}); };

export default function NewJokeRoute() { const actionData = useActionData< ActionData | undefined

();

return (

Add your own hilarious joke

{actionData?.fieldErrors?.name ? ( ) : null}
{actionData?.fieldErrors?.content ? ( ) : null}
); }

export function CatchBoundary() { const caught = useCatch();

if (caught.status === 401) { return (

You must be logged in to create a joke.

Login
); } }

export function ErrorBoundary() { return (

Something unexpected went wrong. Sorry about that.
); }

  1. Here's what I've got with that:
  2. ![App 400 Bad Request](/projects/remix-1.0-en/d5663467247eb7aae88f0ef069cb546b.png)
  3. ![A 404 on the joke page](/projects/remix-1.0-en/a9016f5eda54439d2c0578ff869debb6.png)
  4. ![A 404 on the random joke page](/projects/remix-1.0-en/3183e32440b4255efe85372748643cc7.png)
  5. ![A 401 on the new joke page](/projects/remix-1.0-en/1a24ef77a6399bfa23ba0ca177e52adb.png)
  6. Awesome! We're ready to handle errors and it didn't complicate our happy path one bit! 🎉
  7. Oh, and don't you love how just like with the `ErrorBoundary`, it's all contextual? So the rest of the app continues to function just as well. Another point for user experience 💪
  8. You know what, while we're adding catch boundaries. 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 401 error in the catch boundary.
  9. 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:

  1. And then the `action` can determine whether the intention is to delete based on the `request.formData().get('_method')`.
  2. 💿 Add a delete capability to `app/routes/jokes/$jokeId.tsx` route.
  3. app/routes/jokes/$jokeId.tsx

import type { Joke } from “@prisma/client”; import { ActionFunction, LoaderFunction } from “remix”; import { Link, useLoaderData, useCatch, redirect, useParams } from “remix”; import { db } from “~/utils/db.server”; import { requireUserId } from “~/utils/session.server”;

type LoaderData = { joke: Joke };

export const loader: LoaderFunction = async ({ params }) => { const joke = await db.joke.findUnique({ where: { id: params.jokeId } }); if (!joke) { throw new Response(“What a joke! Not found.”, { status: 404 }); } const data: LoaderData = { joke }; return data; };

export const action: ActionFunction = async ({ request, params }) => { const form = await request.formData(); if (form.get(“_method”) === “delete”) { 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: 401 } ); } 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
); }

export function CatchBoundary() { const caught = useCatch(); const params = useParams(); switch (caught.status) { case 404: { return (

Huh? What the heck is {params.jokeId}?
); } case 401: { return (
Sorry, but {params.jokeId} is not your joke.
); } default: { throw new Error(Unhandled error: ${caught.status}); } } }

export function ErrorBoundary({ error }: { error: Error }) { console.error(error); const { jokeId } = useParams(); return (

{There was an error loading joke by the id ${jokeId}. Sorry.}
); }

  1. 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.
  2. app/routes/jokes/$jokeId.tsx

import { ActionFunction, LoaderFunction } from “remix”; import { Link, useLoaderData, useCatch, redirect, useParams } from “remix”; import type { Joke } from “@prisma/client”; import { db } from “~/utils/db.server”; import { getUserId, requireUserId } from “~/utils/session.server”;

type LoaderData = { joke: Joke; isOwner: boolean };

export const loader: LoaderFunction = async ({ request, params }) => { 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 }); } const data: LoaderData = { joke, isOwner: userId === joke.jokesterId }; return data; };

export const action: ActionFunction = async ({ request, params }) => { const form = await request.formData(); if (form.get(“_method”) === “delete”) { 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: 401 } ); } 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 CatchBoundary() { const caught = useCatch(); const params = useParams(); switch (caught.status) { case 404: { return (

Huh? What the heck is {params.jokeId}?
); } case 401: { return (
Sorry, but {params.jokeId} is not your joke.
); } default: { throw new Error(Unhandled error: ${caught.status}); } } }

export function ErrorBoundary({ error }: { error: Error }) { console.error(error);

const { jokeId } = useParams(); return (

{There was an error loading joke by the id ${jokeId}. Sorry.}
); }

  1. ## SEO with Meta tags
  2. 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.
  3. This is why Remix has the [meta](https://remix.run/docs/en/v1.0.6/api/conventions#meta) export. Why don't you go through and add a useful few meta tags to the following routes:
  4. - `app/routes/index.tsx`
  5. - `app/routes/login.tsx`
  6. - `app/routes/jokes/$jokeId.tsx` - (this one you can reference the joke's name in the title which is fun)
  7. 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](https://remix.run/docs/en/v1.0.6/api/remix#meta-links-scripts).
  8. 💿 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.
  9. app/root.tsx

import type { LinksFunction, MetaFunction } from “remix”; import { Links, LiveReload, Outlet, useCatch, Meta } from “remix”;

import globalStylesUrl from “./styles/global.css”; import globalMediumStylesUrl from “./styles/global-medium.css”; import globalLargeStylesUrl from “./styles/global-large.css”;

export const links: LinksFunction = () => { return [ { rel: “stylesheet”, href: globalStylesUrl }, { rel: “stylesheet”, href: globalMediumStylesUrl, media: “print, (min-width: 640px)” }, { rel: “stylesheet”, href: globalLargeStylesUrl, media: “screen and (min-width: 1024px)” } ]; };

export const meta: MetaFunction = () => { const description = Learn Remix and laugh at the same time!; return { description, keywords: “Remix,jokes”, “twitter:image”: “https://remix-jokes.lol/social.png“, “twitter:card”: “summary_large_image”, “twitter:creator”: “@remix_run”, “twitter:site”: “@remix_run”, “twitter:title”: “Remix Jokes”, “twitter:description”: description }; };

function Document({ children, title = Remix: So great, it's funny! }: { children: React.ReactNode; title?: string; }) { return ( {children} {process.env.NODE_ENV === “development” ? ( ) : null} ); }

export default function App() { return ( ); }

export function CatchBoundary() { const caught = useCatch();

return (

{caught.status} {caught.statusText}

); }

export function ErrorBoundary({ error }: { error: Error }) { return (

App Error

  1. {error.message}
); }

import type { LinksFunction, MetaFunction } from “remix”; import { Link } from “remix”; import stylesUrl from “../styles/index.css”;

export const links: LinksFunction = () => { return [ { rel: “stylesheet”, href: stylesUrl } ]; };

export const meta: MetaFunction = () => { return { title: “Remix: So great, it’s funny!”, description: “Remix jokes app. Learn Remix and laugh at the same time!” }; };

export default function Index() { return (

Remix Jokes!

); }

import type { ActionFunction, LinksFunction, MetaFunction } from “remix”; import { useActionData, Link, useSearchParams } from “remix”; import { db } from “~/utils/db.server”; import { createUserSession, login, register } from “~/utils/session.server”; import stylesUrl from “../styles/login.css”;

export const links: LinksFunction = () => { return [{ rel: “stylesheet”, href: stylesUrl }]; };

export const meta: MetaFunction = () => { return { title: “Remix Jokes | Login”, description: “Login to submit your own jokes to Remix Jokes!” }; };

function validateUsername(username: unknown) { if (typeof username !== “string” || username.length < 3) { return Usernames must be at least 3 characters long; } }

function validatePassword(password: unknown) { if (typeof password !== “string” || password.length < 6) { return Passwords must be at least 6 characters long; } }

type ActionData = { formError?: string; fieldErrors?: { username: string | undefined; password: string | undefined; }; fields?: { loginType: string; username: string; password: string; }; };

export const action: ActionFunction = async ({ request }): Promise => { const form = await request.formData(); const loginType = form.get(“loginType”); const username = form.get(“username”); const password = form.get(“password”); const redirectTo = form.get(“redirectTo”) || “/jokes”; if ( typeof loginType !== “string” || typeof username !== “string” || typeof password !== “string” || typeof redirectTo !== “string” ) { return { formError: Form not submitted correctly. }; }

const fields = { loginType, username, password }; const fieldErrors = { username: validateUsername(username), password: validatePassword(password) }; if (Object.values(fieldErrors).some(Boolean)) return { fieldErrors, fields };

switch (loginType) { case “login”: { const user = await login({ username, password }); if (!user) { return { 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 { fields, formError: User with username ${username} already exists }; } const user = await register({ username, password }); if (!user) { return { fields, formError: Something went wrong trying to create a new user. }; } return createUserSession(user.id, redirectTo); } default: { return { fields, formError: Login type invalid }; } } };

export default function Login() { const actionData = useActionData< ActionData | undefined

(); const [searchParams] = useSearchParams(); return (

Login

<form method=”post” aria-describedby={ actionData?.formError ? “form-error-message” : undefined }

  1. <input
  2. type="hidden"
  3. name="redirectTo"
  4. value={
  5. searchParams.get("redirectTo") ?? undefined
  6. }
  7. />
  8. <fieldset>
  9. <legend className="sr-only">
  10. Login or Register?
  11. </legend>
  12. <label>
  13. <input
  14. type="radio"
  15. name="loginType"
  16. value="login"
  17. defaultChecked={
  18. !actionData?.fields?.loginType ||
  19. actionData?.fields?.loginType === "login"
  20. }
  21. />{" "}
  22. Login
  23. </label>
  24. <label>
  25. <input
  26. type="radio"
  27. name="loginType"
  28. value="register"
  29. defaultChecked={
  30. actionData?.fields?.loginType ===
  31. "register"
  32. }
  33. />{" "}
  34. Register
  35. </label>
  36. </fieldset>
  37. <div>
  38. <label htmlFor="username-input">Username</label>
  39. <input
  40. type="text"
  41. id="username-input"
  42. name="username"
  43. defaultValue={actionData?.fields?.username}
  44. aria-invalid={Boolean(
  45. actionData?.fieldErrors?.username
  46. )}
  47. aria-describedby={
  48. actionData?.fieldErrors?.username
  49. ? "username-error"
  50. : undefined
  51. }
  52. />
  53. {actionData?.fieldErrors?.username ? (
  54. <p
  55. className="form-validation-error"
  56. role="alert"
  57. id="username-error"
  58. {actionData?.fieldErrors.username}
  59. </p>
  60. ) : null}
  61. </div>
  62. <div>
  63. <label htmlFor="password-input">Password</label>
  64. <input
  65. id="password-input"
  66. name="password"
  67. defaultValue={actionData?.fields?.password}
  68. type="password"
  69. aria-invalid={
  70. Boolean(
  71. actionData?.fieldErrors?.password
  72. ) || undefined
  73. }
  74. aria-describedby={
  75. actionData?.fieldErrors?.password
  76. ? "password-error"
  77. : undefined
  78. }
  79. />
  80. {actionData?.fieldErrors?.password ? (
  81. <p
  82. className="form-validation-error"
  83. role="alert"
  84. id="password-error"
  85. {actionData?.fieldErrors.password}
  86. </p>
  87. ) : null}
  88. </div>
  89. <div id="form-error-message">
  90. {actionData?.formError ? (
  91. <p
  92. className="form-validation-error"
  93. role="alert"
  94. {actionData?.formError}
  95. </p>
  96. ) : null}
  97. </div>
  98. <button type="submit" className="button">
  99. Submit
  100. </button>
  101. </form>
  102. </div>
  103. <div className="links">
  104. <ul>
  105. <li>
  106. <Link to="/">Home</Link>
  107. </li>
  108. <li>
  109. <Link to="/jokes">Jokes</Link>
  110. </li>
  111. </ul>
  112. </div>
  113. </div>

); } ``` app/routes/jokes/$jokeId.tsx

  1. import type {
  2. LoaderFunction,
  3. ActionFunction,
  4. MetaFunction
  5. } from "remix";
  6. import {
  7. Link,
  8. useLoaderData,
  9. useCatch,
  10. redirect,
  11. useParams
  12. } from "remix";
  13. import type { Joke } from "@prisma/client";
  14. import { db } from "~/utils/db.server";
  15. import { requireUserId } from "~/utils/session.server";
  16. export const meta: MetaFunction = ({
  17. data
  18. }: {
  19. data: LoaderData | undefined;
  20. }) => {
  21. if (!data) {
  22. return {
  23. title: "No joke",
  24. description: "No joke found"
  25. };
  26. }
  27. return {
  28. title: `"${data.joke.name}" joke`,
  29. description: `Enjoy the "${data.joke.name}" joke and much more`
  30. };
  31. };
  32. type LoaderData = { joke: Joke };
  33. export const loader: LoaderFunction = async ({
  34. params
  35. }) => {
  36. const joke = await db.joke.findUnique({
  37. where: { id: params.jokeId }
  38. });
  39. if (!joke) {
  40. throw new Response("What a joke! Not found.", {
  41. status: 404
  42. });
  43. }
  44. const data: LoaderData = { joke };
  45. return data;
  46. };
  47. export const action: ActionFunction = async ({
  48. request,
  49. params
  50. }) => {
  51. const form = await request.formData();
  52. if (form.get("_method") === "delete") {
  53. const userId = await requireUserId(request);
  54. const joke = await db.joke.findUnique({
  55. where: { id: params.jokeId }
  56. });
  57. if (!joke) {
  58. throw new Response(
  59. "Can't delete what does not exist",
  60. { status: 404 }
  61. );
  62. }
  63. if (joke.jokesterId !== userId) {
  64. throw new Response(
  65. "Pssh, nice try. That's not your joke",
  66. {
  67. status: 401
  68. }
  69. );
  70. }
  71. await db.joke.delete({ where: { id: params.jokeId } });
  72. return redirect("/jokes");
  73. }
  74. };
  75. export default function JokeRoute() {
  76. const data = useLoaderData<LoaderData>();
  77. return (
  78. <div>
  79. <p>Here's your hilarious joke:</p>
  80. <p>{data.joke.content}</p>
  81. <Link to=".">{data.joke.name} Permalink</Link>
  82. <form method="post">
  83. <input
  84. type="hidden"
  85. name="_method"
  86. value="delete"
  87. />
  88. <button type="submit" className="button">
  89. Delete
  90. </button>
  91. </form>
  92. </div>
  93. );
  94. }
  95. export function CatchBoundary() {
  96. const caught = useCatch();
  97. const params = useParams();
  98. switch (caught.status) {
  99. case 404: {
  100. return (
  101. <div className="error-container">
  102. Huh? What the heck is {params.jokeId}?
  103. </div>
  104. );
  105. }
  106. case 401: {
  107. return (
  108. <div className="error-container">
  109. Sorry, but {params.jokeId} is not your joke.
  110. </div>
  111. );
  112. }
  113. default: {
  114. throw new Error(`Unhandled error: ${caught.status}`);
  115. }
  116. }
  117. }
  118. export function ErrorBoundary() {
  119. const { jokeId } = useParams();
  120. return (
  121. <div className="error-container">{`There was an error loading joke by the id ${jokeId}. Sorry.`}</div>
  122. );
  123. }

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 are for. I think it’d be cool to have an RSS feed of all our jokes. I think it would make sense to be at the URL /jokes.rss. For that to work, you’ll need to escape the . because that character has special meaning in Remix route filenames. Learn more about escaping special characters here.

For this one, you’ll probably want to at least peak at the example unless you want to go read up on the RSS spec 😅.

💿 Make a /jokes.rss route.

app/routes/jokes[.]rss.tsx

  1. import type { LoaderFunction } from "remix";
  2. import { db } from "~/utils/db.server";
  3. export const loader: LoaderFunction = async ({
  4. request
  5. }) => {
  6. const jokes = await db.joke.findMany({
  7. take: 100,
  8. orderBy: { createdAt: "desc" },
  9. include: { jokester: { select: { username: true } } }
  10. });
  11. const host =
  12. request.headers.get("X-Forwarded-Host") ??
  13. request.headers.get("host");
  14. if (!host) {
  15. throw new Error("Could not determine domain URL.");
  16. }
  17. const protocol = host.includes("localhost")
  18. ? "http"
  19. : "https";
  20. const domain = `${protocol}://${host}`;
  21. const jokesUrl = `${domain}/jokes`;
  22. const rssString = `
  23. <rss xmlns:blogChannel="${jokesUrl}" version="2.0">
  24. <channel>
  25. <title>Remix Jokes</title>
  26. <link>${jokesUrl}</link>
  27. <description>Some funny jokes</description>
  28. <language>en-us</language>
  29. <generator>Kody the Koala</generator>
  30. <ttl>40</ttl>
  31. ${jokes
  32. .map(joke =>
  33. `
  34. <item>
  35. <title>${joke.name}</title>
  36. <description>A funny joke called ${joke.name}</description>
  37. <author>${joke.jokester.username}</author>
  38. <pubDate>${joke.createdAt}</pubDate>
  39. <link>${jokesUrl}/${joke.id}</link>
  40. <guid>${jokesUrl}/${joke.id}</guid>
  41. </item>
  42. `.trim()
  43. )
  44. .join("\n")}
  45. </channel>
  46. </rss>
  47. `.trim();
  48. return new Response(rssString, {
  49. headers: {
  50. "Cache-Control": `public, max-age=${
  51. 60 * 10
  52. }, s-maxage=${60 * 60 * 24}`,
  53. "Content-Type": "application/xml",
  54. "Content-Length": String(Buffer.byteLength(rssString))
  55. }
  56. });
  57. };

XML document for RSS feed

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.

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

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 particulr 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 component component to load all the JavaScript files in app/root.tsx.

app/root.tsx

  1. import type { LinksFunction, MetaFunction } from "remix";
  2. import {
  3. Links,
  4. LiveReload,
  5. Outlet,
  6. useCatch,
  7. Meta,
  8. Scripts
  9. } from "remix";
  10. import globalStylesUrl from "./styles/global.css";
  11. import globalMediumStylesUrl from "./styles/global-medium.css";
  12. import globalLargeStylesUrl from "./styles/global-large.css";
  13. export const links: LinksFunction = () => {
  14. return [
  15. {
  16. rel: "stylesheet",
  17. href: globalStylesUrl
  18. },
  19. {
  20. rel: "stylesheet",
  21. href: globalMediumStylesUrl,
  22. media: "print, (min-width: 640px)"
  23. },
  24. {
  25. rel: "stylesheet",
  26. href: globalLargeStylesUrl,
  27. media: "screen and (min-width: 1024px)"
  28. }
  29. ];
  30. };
  31. export const meta: MetaFunction = () => {
  32. const description = `Learn Remix and laugh at the same time!`;
  33. return {
  34. description,
  35. keywords: "Remix,jokes",
  36. "twitter:image": "https://remix-jokes.lol/social.png",
  37. "twitter:card": "summary_large_image",
  38. "twitter:creator": "@remix_run",
  39. "twitter:site": "@remix_run",
  40. "twitter:title": "Remix Jokes",
  41. "twitter:description": description
  42. };
  43. };
  44. function Document({
  45. children,
  46. title = `Remix: So great, it's funny!`
  47. }: {
  48. children: React.ReactNode;
  49. title?: string;
  50. }) {
  51. return (
  52. <html lang="en">
  53. <head>
  54. <meta charSet="utf-8" />
  55. <Meta />
  56. <title>{title}</title>
  57. <Links />
  58. </head>
  59. <body>
  60. {children}
  61. <Scripts />
  62. {process.env.NODE_ENV === "development" ? (
  63. <LiveReload />
  64. ) : null}
  65. </body>
  66. </html>
  67. );
  68. }
  69. export default function App() {
  70. return (
  71. <Document>
  72. <Outlet />
  73. </Document>
  74. );
  75. }
  76. export function CatchBoundary() {
  77. const caught = useCatch();
  78. return (
  79. <Document
  80. title={`${caught.status} ${caught.statusText}`}
  81. >
  82. <div className="error-container">
  83. <h1>
  84. {caught.status} {caught.statusText}
  85. </h1>
  86. </div>
  87. </Document>
  88. );
  89. }
  90. export function ErrorBoundary({ error }: { error: Error }) {
  91. console.error(error);
  92. return (
  93. <Document title="Uh-oh!">
  94. <div className="error-container">
  95. <h1>App Error</h1>
  96. <pre>{error.message}</pre>
  97. </div>
  98. </Document>
  99. );
  100. }

Network tab showing JavaScript loaded

💿 Another thing we can do now is you can accept the error prop in all your ErrorBoundary components and console.error(error); and you’ll get even server-side errors logged in the browser’s console. 🤯

Browser console showing the log of a server-side error

Forms

Remix has its own

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.

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:

  1. <Link prefetch="intent" to="somewhere/neat">Somewhere Neat</Link>

💿 Add prefetch="intent" to the list of Joke links in app/routes/jokes.tsx.

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, 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 routes.

app/components/joke.tsx

  1. import { Link, Form } from "remix";
  2. import type { Joke } from "@prisma/client";
  3. export function JokeDisplay({
  4. joke,
  5. isOwner,
  6. canDelete = true
  7. }: {
  8. joke: Pick<Joke, "content" | "name">;
  9. isOwner: boolean;
  10. canDelete?: boolean;
  11. }) {
  12. return (
  13. <div>
  14. <p>Here's your hilarious joke:</p>
  15. <p>{joke.content}</p>
  16. <Link to=".">{joke.name} Permalink</Link>
  17. {isOwner ? (
  18. <Form method="post">
  19. <input
  20. type="hidden"
  21. name="_method"
  22. value="delete"
  23. />
  24. <button
  25. type="submit"
  26. className="button"
  27. disabled={!canDelete}
  28. >
  29. Delete
  30. </button>
  31. </Form>
  32. ) : null}
  33. </div>
  34. );
  35. }
  36. ``` app/routes/jokes/$jokeId.tsx

import type { LoaderFunction, ActionFunction, MetaFunction } from “remix”; import { Link, useLoaderData, useCatch, redirect, useParams } from “remix”; import type { Joke } from “@prisma/client”; import { db } from “~/utils/db.server”; import { getUserId, requireUserId } from “~/utils/session.server”; import { JokeDisplay } from “~/components/joke”;

export const meta: MetaFunction = ({ data }: { data: LoaderData | undefined; }) => { if (!data) { return { title: “No joke”, description: “No joke found” }; } return { title: "${data.joke.name}" joke, description: Enjoy the "${data.joke.name}" joke and much more }; };

type LoaderData = { joke: Joke; isOwner: boolean };

export const loader: LoaderFunction = async ({ request, params }) => { 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 }); } const data: LoaderData = { joke, isOwner: userId === joke.jokesterId }; return data; };

export const action: ActionFunction = async ({ request, params }) => { const form = await request.formData(); if (form.get(“_method”) === “delete”) { 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: 401 } ); } await db.joke.delete({ where: { id: params.jokeId } }); return redirect(“/jokes”); } };

export default function JokeRoute() { const data = useLoaderData();

return ( ); }

export function CatchBoundary() { const caught = useCatch(); const params = useParams(); switch (caught.status) { case 404: { return (

Huh? What the heck is {params.jokeId}?
); } case 401: { return (
Sorry, but {params.jokeId} is not your joke.
); } default: { throw new Error(Unhandled error: ${caught.status}); } } }

export function ErrorBoundary({ error }: { error: Error }) { console.error(error);

const { jokeId } = useParams(); return (

{There was an error loading joke by the id ${jokeId}. Sorry.}
); }

import type { ActionFunction, LoaderFunction } from “remix”; import { useActionData, redirect, useCatch, Link, Form, useTransition } from “remix”; import { JokeDisplay } from “~/components/joke”; import { db } from “~/utils/db.server”; import { requireUserId, getUserId } from “~/utils/session.server”;

export const loader: LoaderFunction = async ({ request }) => { const userId = await getUserId(request); if (!userId) { throw new Response(“Unauthorized”, { status: 401 }); } return {}; };

function validateJokeContent(content: string) { if (content.length < 10) { return That joke is too short; } }

function validateJokeName(name: string) { if (name.length < 2) { return That joke's name is too short; } }

type ActionData = { formError?: string; fieldErrors?: { name: string | undefined; content: string | undefined; }; fields?: { name: string; content: string; }; };

export const action: ActionFunction = async ({ request }): Promise => { const userId = await requireUserId(request); const form = await request.formData(); const name = form.get(“name”); const content = form.get(“content”); if ( typeof name !== “string” || typeof content !== “string” ) { return { formError: Form not submitted correctly. }; }

const fieldErrors = { name: validateJokeName(name), content: validateJokeContent(content) }; const fields = { name, content }; if (Object.values(fieldErrors).some(Boolean)) { return { fieldErrors, fields }; }

const joke = await db.joke.create({ data: { …fields, jokesterId: userId } }); return redirect(/jokes/${joke.id}); };

export default function NewJokeRoute() { const actionData = useActionData< ActionData | undefined

(); const transition = useTransition();

if (transition.submission) { const name = transition.submission.formData.get(“name”); const content = transition.submission.formData.get(“content”); if ( typeof name === “string” && typeof content === “string” && !validateJokeContent(content) && !validateJokeName(name) ) { return ( ); } }

return (

Add your own hilarious joke

{actionData?.fieldErrors?.name ? ( ) : null}
{actionData?.fieldErrors?.content ? ( ) : null}
); }

export function CatchBoundary() { const caught = useCatch();

if (caught.status === 401) { return (

You must be logged in to create a joke.

Login
); } }

export function ErrorBoundary({ error }: { error: Error }) { console.error(error);

return (

Something unexpected went wrong. Sorry about that.
); }

  1. 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.
  2. 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!
  3. Here's a demonstration of what that experience looks like:
  4. ## Deployment
  5. 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 (`bcrypt`), we're going to deploy to one of our favorite hosting providers: [Fly.io](https://fly.io).
  6. Note, deploying to fly with a sqlite database is going to cost a little bit of money: A couple bucks per month you have it running.
  7. 💿 Before proceeding, you're going to need to to [install fly](https://fly.io/docs/hands-on/installing/) and [sign up for an account](https://fly.io/docs/hands-on/sign-up/).
  8. 💿 Once you've done that, run this command from within your project directory:

fly launch

  1. 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 ? App Name (leave blank to use an auto-generated name): remix-jokes Automatically selected personal organization: Kent C. Dodds ? Select region: dfw (Dallas, Texas (US)) Created app remix-jokes in organization personal Created a 10GB 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

  1. You'll want to choose a different app name because I already took `remix-jokes` (sorry 🙃).
  2. 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.
  3. Fly also detected that this project is using sqlite with prisma and created a persistence volume for us (this is the part that costs money).
  4. We don't want to deploy right now because we have an environment variable we need to set! So choose "No".
  5. Fly generated a few files for us:
  6. - `fly.toml` - Fly-specific configuration
  7. - `Dockerfile` - Remix-specific Dockerfile for the app
  8. - `.dockerignore` - It just ignores `node_modules` because we'll run the installation as we build the image.
  9. 💿 Now set the `SESSION_SECRET` environment variable by running this command:

fly secrets set SESSION_SECRET=your-secret-here

  1. `your-secret-here` can be whatever you want. It's just a string that's used to encrypt the session cookie. Use a password generator if you like.
  2. 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.
  3. 💿 Run this command:

npx prisma migrate dev

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

  1. 💿 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 (3.5.0) to ./node_modules/@prisma/client in 52ms

  1. 💿 If you did get an error when running the seed, you can run it manually now:

npx prisma db seed

  1. With that done, you're ready to deploy.
  2. 💿 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 journy!