We're going to be short on words and quick on code in this quickstart. If you're looking to see what Remix is all about in 15 minutes, this is it.
This tutorial uses TypeScript. Remix can definitely be used without TypeScript. We feel most productive when writing TypeScript, but if you'd prefer to skip the TypeScript syntax, feel free to write your code in JavaScript.
Click this button to create a Gitpod workspace with the project set up and ready to run in VS Code or JetBrains either directly in the browser or on the desktop.
If you want to follow this tutorial locally on your own computer, it is important for you to have these things installed:
💿 Initialize a new Remix project. We'll call ours "blog-tutorial" but you can call it something else if you'd like.
npx create-remix@latest --template remix-run/indie-stack blog-tutorial
? Do you want me to run `npm install`? Yes
You can read more about the stacks available in the stacks docs.
We're using the Indie stack, which is a full application ready to deploy to fly.io. This includes development tools as well as production-ready authentication and persistence. Don't worry if you're unfamiliar with the tools used, we'll walk you through things as we go.
npx create-remix@latest
without the --template
flag. The generated project is much more minimal that way. However, some bits of the tutorial will be different for you and you'll have to configure things for deployment manually.
💿 Now, open the project that was generated in your preferred editor and check the instructions in the README.md
file. Feel free to read over this. We'll get to the deployment bit later in the tutorial.
💿 Let's start the dev server:
npm run dev
💿 Open up http://localhost:3000, the app should be running.
If you want, take a minute and poke around the UI a bit. Feel free to create an account and create/delete some notes to get an idea of what's available in the UI out of the box.
We're going to make a new route to render at the "/posts" URL. Before we do that, let's link to it.
💿 Add a link to posts in app/routes/_index.tsx
Go ahead and copy/paste this:
<div className="mx-auto mt-16 max-w-7xl text-center">
<Link
to="/posts"
className="text-xl text-blue-600 underline"
>
Blog Posts
</Link>
</div>
You can put it anywhere you like. I stuck it right above the icons of all the technologies used in the stack:
The Remix Indie stack has Tailwind CSS support pre-configured. If you'd prefer to not use Tailwind CSS, you're welcome to remove it and use something else. Learn more about your styling options with Remix in the styling guide.
Back in the browser go ahead and click the link. You should see a 404 page since we've not created this route yet. Let's create the route now:
💿 Create a new file at app/routes/posts._index.tsx
touch app/routes/posts._index.tsx
touch
is just a way for us to make it clear which files you should be creating.
We could have named it just posts.tsx
but we'll have another route soon, and it'll be nice to put them by each other. An index route will render at the parent's path (just like index.html
on a web server).
Now if you navigate to the /posts
route, you'll get an error indicating there's no way to handle the request. That's because we haven't done anything in that route yet! Let's add a component and export it as the default:
💿 Make the posts component
export default function Posts() {
return (
<main>
<h1>Posts</h1>
</main>
);
}
You might need to refresh the browser to see our new, bare-bones posts route.
Data loading is built into Remix.
If your web dev background is primarily in the last few years, you're probably used to creating two things here: an API route to provide data and a frontend component that consumes it. In Remix your frontend component is also its own API route, and it already knows how to talk to itself on the server from the browser. That is, you don't have to fetch it.
If your background is a bit farther back than that with MVC web frameworks like Rails, then you can think of your Remix routes as backend views using React for templating, but then they know how to seamlessly hydrate in the browser to add some flair instead of writing detached jQuery code to dress up the user interactions. It's progressive enhancement realized in its fullest. Additionally, your routes are their own controller.
So let's get to it and provide some data to our component.
💿 Make the posts route loader
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
export const loader = async () => {
return json({
posts: [
{
slug: "my-first-post",
title: "My First Post",
},
{
slug: "90s-mixtape",
title: "A Mixtape I Made Just For You",
},
],
});
};
export default function Posts() {
const { posts } = useLoaderData<typeof loader>();
return (
<main>
<h1>Posts</h1>
</main>
);
}
loader
functions are the backend "API" for their component, and it's already wired up for you through useLoaderData
. It's a little wild how blurry the line is between the client and the server in a Remix route. If you have your server and browser consoles both open, you'll note that they both logged our post data. That's because Remix rendered on the server to send a full HTML document like a traditional web framework, but it also hydrated in the client and logged there too.
💿 Render links to our posts
import { json } from "@remix-run/node";
import { Link, useLoaderData } from "@remix-run/react";
// ...
export default function Posts() {
const { posts } = useLoaderData<typeof loader>();
return (
<main>
<h1>Posts</h1>
<ul>
{posts.map((post) => (
<li key={post.slug}>
<Link
to={post.slug}
className="text-blue-600 underline"
>
{post.title}
</Link>
</li>
))}
</ul>
</main>
);
}
Hey, that's pretty cool. We get a pretty solid degree of type safety even over a network request because it's all defined in the same file. Unless the network blows up while Remix fetches the data, you've got type safety in this component and its API (remember, the component is already its own API route).
A solid practice is to create a module that deals with a particular concern. In our case it's going to be reading and writing posts. Let's set that up now and add a getPosts
export to our module.
💿 Create app/models/post.server.ts
touch app/models/post.server.ts
We're mostly going to copy/paste stuff from our route:
type Post = {
slug: string;
title: string;
};
export async function getPosts(): Promise<Array<Post>> {
return [
{
slug: "my-first-post",
title: "My First Post",
},
{
slug: "90s-mixtape",
title: "A Mixtape I Made Just For You",
},
];
}
Note that we're making the getPosts
function async
because even though it's not currently doing anything async it will soon!
💿 Update the posts route to use our new posts module:
import { json } from "@remix-run/node";
import { Link, useLoaderData } from "@remix-run/react";
import { getPosts } from "~/models/post.server";
export const loader = async () => {
return json({ posts: await getPosts() });
};
// ...
With the Indie Stack, we've got a SQLite database already set up and configured for us, so let's update our Database Schema to handle SQLite. We're using Prisma to interact with the database, so we'll update that schema and Prisma will take care of updating our database to match the schema for us (as well as generating and running the necessary SQL commands for the migration).
If you've never used Prisma before, don't worry, we'll walk you through it.
💿 First, we need to update our Prisma schema:
// Stick this at the bottom of that file:
model Post {
slug String @id
title String
markdown String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
💿 Let's generate a migration file for our schema changes, which will be required if you deploy your application rather than just running in dev mode locally. This will also update our local database and TypeScript definitions to match the schema change. We'll name the migration "create post model".
npx prisma migrate dev --name "create post model"
💿 Let's seed our database with a couple posts. Open prisma/seed.ts
and add this to the end of the seed functionality (right before the console.log
):
const posts = [
{
slug: "my-first-post",
title: "My First Post",
markdown: `
# This is my first post
Isn't it great?
`.trim(),
},
{
slug: "90s-mixtape",
title: "A Mixtape I Made Just For You",
markdown: `
# 90s Mixtape
- I wish (Skee-Lo)
- This Is How We Do It (Montell Jordan)
- Everlong (Foo Fighters)
- Ms. Jackson (Outkast)
- Interstate Love Song (Stone Temple Pilots)
- Killing Me Softly With His Song (Fugees, Ms. Lauryn Hill)
- Just a Friend (Biz Markie)
- The Man Who Sold The World (Nirvana)
- Semi-Charmed Life (Third Eye Blind)
- ...Baby One More Time (Britney Spears)
- Better Man (Pearl Jam)
- It's All Coming Back to Me Now (Céline Dion)
- This Kiss (Faith Hill)
- Fly Away (Lenny Kravits)
- Scar Tissue (Red Hot Chili Peppers)
- Santa Monica (Everclear)
- C'mon N' Ride it (Quad City DJ's)
`.trim(),
},
];
for (const post of posts) {
await prisma.post.upsert({
where: { slug: post.slug },
update: post,
create: post,
});
}
upsert
so you can run the seed script over and over without adding multiple versions of the same post every time.
Great, let's get those posts into the database with the seed script:
npx prisma db seed
💿 Now update the app/models/post.server.ts
file to read from the SQLite database:
import { prisma } from "~/db.server";
export async function getPosts() {
return prisma.post.findMany();
}
~/db.server
import is importing the file at app/db.server.ts
. The ~
is a fancy alias to the app
directory, so you don't have to worry about how many ../../
s to include in your import as you move files around.
💿 Now that the Prisma client has been updated, we will need to restart our server. So stop the dev server and start it back up again with npm run dev
.
With the server up and running again, you should be able to go to http://localhost:3000/posts
and the posts should still be there, but now they're coming from SQLite!
Now let's make a route to actually view the post. We want these URLs to work:
/posts/my-first-post
/posts/90s-mixtape
Instead of creating a route for every single one of our posts, we can use a "dynamic segment" in the url. Remix will parse and pass to us, so we can look up the post dynamically.
💿 Create a dynamic route at app/routes/posts.$slug.tsx
touch app/routes/posts.$slug.tsx
export default function PostSlug() {
return (
<main className="mx-auto max-w-4xl">
<h1 className="my-6 border-b-2 text-center text-3xl">
Some Post
</h1>
</main>
);
}
You can click one of your posts and should see the new page.
💿 Add a loader to access the params
import type { LoaderArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
export const loader = async ({ params }: LoaderArgs) => {
return json({ slug: params.slug });
};
export default function PostSlug() {
const { slug } = useLoaderData<typeof loader>();
return (
<main className="mx-auto max-w-4xl">
<h1 className="my-6 border-b-2 text-center text-3xl">
Some Post: {slug}
</h1>
</main>
);
}
The part of the filename attached to the $
becomes a named key on the params
object that comes into your loader. This is how we'll look up our blog post.
Now, let's actually get the post contents from the database by its slug.
💿 Add a getPost
function to our post module
import { prisma } from "~/db.server";
export async function getPosts() {
return prisma.post.findMany();
}
export async function getPost(slug: string) {
return prisma.post.findUnique({ where: { slug } });
}
💿 Use the new getPost
function in the route
import type { LoaderArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { getPost } from "~/models/post.server";
export const loader = async ({ params }: LoaderArgs) => {
const post = await getPost(params.slug);
return json({ post });
};
export default function PostSlug() {
const { post } = useLoaderData<typeof loader>();
return (
<main className="mx-auto max-w-4xl">
<h1 className="my-6 border-b-2 text-center text-3xl">
{post.title}
</h1>
</main>
);
}
Check that out! We're now pulling our posts from a data source instead of including it all in the browser as JavaScript.
Let's make TypeScript happy with our code:
import type { LoaderArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import invariant from "tiny-invariant";
import { getPost } from "~/models/post.server";
export const loader = async ({ params }: LoaderArgs) => {
invariant(params.slug, "params.slug is required");
const post = await getPost(params.slug);
invariant(post, `Post not found: ${params.slug}`);
return json({ post });
};
export default function PostSlug() {
const { post } = useLoaderData<typeof loader>();
return (
<main className="mx-auto max-w-4xl">
<h1 className="my-6 border-b-2 text-center text-3xl">
{post.title}
</h1>
</main>
);
}
Quick note on that invariant
for the params. Because params
comes from the URL, we can't be totally sure that params.slug
will be defined--maybe you change the name of the file to posts.$postId.ts
! It's a good practice to validate that stuff with invariant
, and it makes TypeScript happy too.
We also have an invariant for the post. We'll handle the 404
case better later. Keep going!
Now let's get that markdown parsed and rendered to HTML to the page. There are a lot of Markdown parsers, we'll use marked
for this tutorial because it's really easy to get working.
💿 Parse the markdown into HTML
npm add marked
# additionally, if using typescript
npm add @types/marked -D
Now that marked
has been installed, we will need to restart our server. So stop the dev server and start it back up again with npm run dev
.
import type { LoaderArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { marked } from "marked";
import invariant from "tiny-invariant";
import { getPost } from "~/models/post.server";
export const loader = async ({ params }: LoaderArgs) => {
invariant(params.slug, "params.slug is required");
const post = await getPost(params.slug);
invariant(post, `Post not found: ${params.slug}`);
const html = marked(post.markdown);
return json({ html, post });
};
export default function PostSlug() {
const { html, post } = useLoaderData<typeof loader>();
return (
<main className="mx-auto max-w-4xl">
<h1 className="my-6 border-b-2 text-center text-3xl">
{post.title}
</h1>
<div dangerouslySetInnerHTML={{ __html: html }} />
</main>
);
}
Holy smokes, you did it. You have a blog. Check it out! Next, we're going to make it easier to create new blog posts 📝
Right now, our blog posts just come from seeding the database. Not a real solution, so we need a way to create a new blog post in the database. We're going to be using actions for that.
Let's make a new "admin" section of the app.
💿 First, let's add a link to the admin section on the posts index route:
// ...
<Link to="admin" className="text-red-600 underline">
Admin
</Link>
// ...
Put that anywhere in the component. I stuck it right under the <h1>
.
to
prop is just "admin" and it linked to /posts/admin
? With Remix, you get relative links.
💿 Create an admin route at app/routes/posts.admin.tsx
:
touch app/routes/posts.admin.tsx
import { json } from "@remix-run/node";
import { Link, useLoaderData } from "@remix-run/react";
import { getPosts } from "~/models/post.server";
export const loader = async () => {
return json({ posts: await getPosts() });
};
export default function PostAdmin() {
const { posts } = useLoaderData<typeof loader>();
return (
<div className="mx-auto max-w-4xl">
<h1 className="my-6 mb-2 border-b-2 text-center text-3xl">
Blog Admin
</h1>
<div className="grid grid-cols-4 gap-6">
<nav className="col-span-4 md:col-span-1">
<ul>
{posts.map((post) => (
<li key={post.slug}>
<Link
to={post.slug}
className="text-blue-600 underline"
>
{post.title}
</Link>
</li>
))}
</ul>
</nav>
<main className="col-span-4 md:col-span-3">
...
</main>
</div>
</div>
);
}
You should recognize several of the things we're doing in there from what we've done so far. With that, you should have a decent looking page with the posts on the left and a placeholder on the right. Now, if you click on the Admin link, it'll take you to http://localhost:3000/posts/admin.
Let's fill in that placeholder with an index route for admin. Hang with us, we're introducing "nested routes" here where your route file nesting becomes UI component nesting.
💿 Create an index route for posts.admin.tsx
's child routes
touch app/routes/posts.admin._index.tsx
import { Link } from "@remix-run/react";
export default function AdminIndex() {
return (
<p>
<Link to="new" className="text-blue-600 underline">
Create a New Post
</Link>
</p>
);
}
If you refresh you're not going to see it yet. Every route that starts with app/routes/posts.admin.
can now render inside of app/routes/posts.admin.tsx
when their URL matches. You get to control which part of the posts.admin.tsx
layout the child routes render.
💿 Add an outlet to the admin page
import { json } from "@remix-run/node";
import {
Link,
Outlet,
useLoaderData,
} from "@remix-run/react";
import { getPosts } from "~/models/post.server";
export const loader = async () => {
return json({ posts: await getPosts() });
};
export default function PostAdmin() {
const { posts } = useLoaderData<typeof loader>();
return (
<div className="mx-auto max-w-4xl">
<h1 className="my-6 mb-2 border-b-2 text-center text-3xl">
Blog Admin
</h1>
<div className="grid grid-cols-4 gap-6">
<nav className="col-span-4 md:col-span-1">
<ul>
{posts.map((post) => (
<li key={post.slug}>
<Link
to={post.slug}
className="text-blue-600 underline"
>
{post.title}
</Link>
</li>
))}
</ul>
</nav>
<main className="col-span-4 md:col-span-3">
<Outlet />
</main>
</div>
</div>
);
}
Hang with us for a minute, index routes can be confusing at first. Just know that when the URL matches the parent route's path, the index will render inside the Outlet
.
Maybe this will help, let's add the /posts/admin/new
route and see what happens when we click the link.
💿 Create the app/routes/posts.admin.new.tsx
file
touch app/routes/posts.admin.new.tsx
export default function NewPost() {
return <h2>New Post</h2>;
}
Now click the link from the index route and watch the <Outlet/>
automatically swap out the index route for the "new" route!
We're going to get serious now. Let's build a form to create a new post in our new "new" route.
💿 Add a form to the new route
import { Form } from "@remix-run/react";
const inputClassName =
"w-full rounded border border-gray-500 px-2 py-1 text-lg";
export default function NewPost() {
return (
<Form method="post">
<p>
<label>
Post Title:{" "}
<input
type="text"
name="title"
className={inputClassName}
/>
</label>
</p>
<p>
<label>
Post Slug:{" "}
<input
type="text"
name="slug"
className={inputClassName}
/>
</label>
</p>
<p>
<label htmlFor="markdown">Markdown: </label>
<br />
<textarea
id="markdown"
rows={20}
name="markdown"
className={`${inputClassName} font-mono`}
/>
</p>
<p className="text-right">
<button
type="submit"
className="rounded bg-blue-500 py-2 px-4 text-white hover:bg-blue-600 focus:bg-blue-400 disabled:bg-blue-300"
>
Create Post
</button>
</p>
</Form>
);
}
If you love HTML like us, you should be getting pretty excited. If you've been doing a lot of <form onSubmit>
and <button onClick>
you're about to have your mind blown by HTML.
All you really need for a feature like this is a form to get data from the user and a backend action to handle it. And in Remix, that's all you have to do too.
Let's create the essential code that knows how to save a post first in our post.ts
module.
💿 Add createPost
anywhere inside of app/models/post.server.ts
// ...
export async function createPost(post) {
return prisma.post.create({ data: post });
}
💿 Call createPost
from the new post route's action
import type { ActionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { Form } from "@remix-run/react";
import { createPost } from "~/models/post.server";
export const action = async ({ request }: ActionArgs) => {
const formData = await request.formData();
const title = formData.get("title");
const slug = formData.get("slug");
const markdown = formData.get("markdown");
await createPost({ title, slug, markdown });
return redirect("/posts/admin");
};
// ...
That's it. Remix (and the browser) will take care of the rest. Click the submit button and watch the sidebar that lists our posts update automatically.
In HTML an input's name
attribute is sent over the network and available by the same name on the request's formData
. Oh, and don't forget, the request
and formData
objects are both straight out of the web specification. So if you want to learn more about either of them, head over to MDN!
TypeScript is mad again, let's add some types.
💿 Add the types to app/models/post.server.ts
// ...
import type { Post } from "@prisma/client";
// ...
export async function createPost(
post: Pick<Post, "slug" | "title" | "markdown">
) {
return prisma.post.create({ data: post });
}
Whether you're using TypeScript or not, we've got a problem when the user doesn't provide values on some of these fields (and TS is still mad about that call to createPost
).
Let's add some validation before we create the post.
💿 Validate if the form data contains what we need, and return the errors if not
import type { ActionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form } from "@remix-run/react";
import { createPost } from "~/models/post.server";
export const action = async ({ request }: ActionArgs) => {
const formData = await request.formData();
const title = formData.get("title");
const slug = formData.get("slug");
const markdown = formData.get("markdown");
const errors = {
title: title ? null : "Title is required",
slug: slug ? null : "Slug is required",
markdown: markdown ? null : "Markdown is required",
};
const hasErrors = Object.values(errors).some(
(errorMessage) => errorMessage
);
if (hasErrors) {
return json(errors);
}
await createPost({ title, slug, markdown });
return redirect("/posts/admin");
};
// ...
Notice we don't return a redirect this time, we actually return the errors. These errors are available to the component via useActionData
. It's just like useLoaderData
but the data comes from the action after a form POST.
💿 Add validation messages to the UI
import type { ActionArgs } from "@remix-run/node";
import { redirect, json } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
// ...
const inputClassName =
"w-full rounded border border-gray-500 px-2 py-1 text-lg";
export default function NewPost() {
const errors = useActionData<typeof action>();
return (
<Form method="post">
<p>
<label>
Post Title:{" "}
{errors?.title ? (
<em className="text-red-600">{errors.title}</em>
) : null}
<input type="text" name="title" className={inputClassName} />
</label>
</p>
<p>
<label>
Post Slug:{" "}
{errors?.slug ? (
<em className="text-red-600">{errors.slug}</em>
) : null}
<input type="text" name="slug" className={inputClassName} />
</label>
</p>
<p>
<label htmlFor="markdown">
Markdown:{" "}
{errors?.markdown ? (
<em className="text-red-600">
{errors.markdown}
</em>
) : null}
</label>
<br />
<textarea
id="markdown"
rows={20}
name="markdown"
className={`${inputClassName} font-mono`}
/>
</p>
<p className="text-right">
<button
type="submit"
className="rounded bg-blue-500 py-2 px-4 text-white hover:bg-blue-600 focus:bg-blue-400 disabled:bg-blue-300"
>
Create Post
</button>
</p>
</Form>
);
}
TypeScript is still mad, because someone could call our API with non-string values, so let's add some invariants to make it happy.
//...
import invariant from "tiny-invariant";
// ..
export const action = async ({ request }: ActionArgs) => {
// ...
invariant(
typeof title === "string",
"title must be a string"
);
invariant(
typeof slug === "string",
"slug must be a string"
);
invariant(
typeof markdown === "string",
"markdown must be a string"
);
await createPost({ title, slug, markdown });
return redirect("/posts/admin");
};
For some real fun, disable JavaScript in your dev tools and try it out. Because Remix is built on the fundamentals of HTTP and HTML, this whole thing works without JavaScript in the browser 🤯 But that's not the point. What's cool about it is that this means our UI is resilient to network issues. But we really like having JavaScript in the browser and there are a lot of cool things we can do when we've got it, so make sure to re-enable JavaScript before continuing, because we're going to need it to progressively enhance the user experience next.
Let's slow this down and add some "pending UI" to our form.
💿 Slow down our action with a fake delay
// ...
export const action = async ({ request }: ActionArgs) => {
// TODO: remove me
await new Promise((res) => setTimeout(res, 1000));
// ...
};
//...
💿 Add some pending UI with useNavigation
import type { ActionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import {
Form,
useActionData,
useNavigation,
} from "@remix-run/react";
// ..
export default function NewPost() {
const errors = useActionData<typeof action>();
const navigation = useNavigation();
const isCreating = Boolean(
navigation.state === "submitting"
);
return (
<Form method="post">
{/* ... */}
<p className="text-right">
<button
type="submit"
className="rounded bg-blue-500 py-2 px-4 text-white hover:bg-blue-600 focus:bg-blue-400 disabled:bg-blue-300"
disabled={isCreating}
>
{isCreating ? "Creating..." : "Create Post"}
</button>
</p>
</Form>
);
}
Tada! You just implemented JavaScript-enabled progressive enhancement! 🥳 With what we've done, the experience is better than what the browser can do by itself. Lots of apps use JavaScript to enable the experience (and a select few actually do require JavaScript to work), but we've got a working experience as a baseline and just used JavaScript to enhance it.
That's it for today! Here are some bits of homework to implement if you want to go deeper:
Update/Delete posts: make a posts.admin.$slug.tsx
page for your posts. This should open an edit page for the post that allows you to update the post or even delete it. The links are already there in the sidebar, but they return 404! Create a new route that reads the posts, and puts them into the fields. All the code you need is already in app/routes/posts.$slug.tsx
and app/routes/posts.admin.new.tsx
. You just gotta put it together.
Optimistic UI: You know how when you favorite a tweet, the heart goes red instantly and if the tweet is deleted it reverts back to empty? That's Optimistic UI: assume the request will succeed, and render what the user will see if it does. So your homework is to make it so when you hit "Create" it renders the post in the left nav and renders the "Create a New Post" link (or if you add update/delete do it for those too). You'll find this ends up being easier than you think even if it takes you a second to arrive there (and if you've implemented this pattern in the past, you'll find Remix makes this much easier). Learn more from the Optimistic UI guide.
Authenticated users only: Another cool bit of homework you could do is make it so only authenticated users can create posts. You've already got authentication all set up for you thanks to the Indie Stack. Tip: if you want to make it so you're the only one who can make posts, simply check the user's email in your loaders and actions and if it's not yours redirect them somewhere 😈
Customize the app: If you're happy with Tailwind CSS, keep it around, otherwise, check the styling guide to learn of other options. Remove the Notes
model and routes, etc. Whatever you want to make this thing yours.
Deploy the app: Check the README of your project. It has instructions you can follow to get your app deployed to Fly.io. Then you can actually start blogging!
We hope you love Remix! 💿 👋