Building Dynamic Web Applications with Remix.js, Drizzle ORM and Tailwind
Francisco Mendes
Posted on May 2, 2023
What you will learn
I hope this article helps you create a server-side rendered application using Remix. In addition to giving some tips on how to analyze the routes to have a safer approach, handle forms more easily and make a CRUD in a database.
What does this article cover
We will cover several aspects, including:
- Tailwind Configuration
- Configure a type safe helper for safe route usage
- Form validation
- Data modeling
- Create, Read, Update and Delete (CRUD) operations
Prerequisites
Before starting the article, it is recommended that you have knowledge of React, Remix, Tailwind and a prior knowledge of nested routes and ORM's.
Creating the Project
To initialize a project in Remix we execute the following command:
npx create-remix@latest remizzle
cd remizzle
We start the dev server with the following command:
npm run dev
Set up Tailwind
We are going to use the Just the basics
type, with a deployment target of Remix App Server
and we are going to use TypeScript
in the application.
Next, let's configure Tailwind in our project, starting with the installation:
npm install -D tailwindcss
npx tailwindcss init
Then we add the following in the remix.config.js
file:
module.exports = {
future: {
unstable_tailwind: true,
},
};
We also make the following changes to tailwind.config.js
:
module.exports = {
content: ["./app/**/*.{ts,tsx}"],
// ...
};
Next, we create a file called tailwind.css
inside the app/
folder with the following:
@tailwind base;
@tailwind components;
@tailwind utilities;
To make app development easier, let's take advantage of the pre-styled components of the daisyUI library, starting by installing the dependency:
npm install daisyui
Then we add the library to the list of plugins in the tailwind.config.js
file in which we can also define which theme we want to use, as follows:
module.exports = {
// ...
plugins: [require("daisyui")],
daisyui: {
themes: ["dracula"],
},
};
Now, in the root.tsx
file inside the app/
folder, import the css file and add it to the links
variable:
// @/app/root.tsx
import type { LinksFunction } from "@remix-run/node";
// ...
import stylesheet from "~/tailwind.css";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: stylesheet },
];
// ...
Set up Routes Gen
Remix uses file based routing, in which we define our application's routes through files that are created inside the routes/
folder, with nested routes and URL parameters in the mix.
But in fact we don't have any type safety when we are navigating to a certain route or if we are missing some parameter, for that same reason we will take advantage of Routes Gen in this app.
First install the necessary dependencies:
npm install routes-gen @routes-gen/remix --dev
In package.json
we create the following script:
{
// ...
"scripts": {
// ...
"routes:gen": "routes-gen -d @routes-gen/remix"
},
// ...
}
When we run the command, it will create the routes.d.ts
file inside the app/
folder with the data type declarations taking into account the application's routes. Remembering that the command is this:
npm run routes:gen
Set up Drizzle ORM
To make this article accessible to more people, we are going to use the SQLite database and install the following dependencies:
npm install drizzle-orm better-sqlite3
npm install -D drizzle-kit @types/better-sqlite3
The next step will be to create the config.server.ts
file in a folder called db/
inside the app/
folder, with the following:
// @/app/db/config.server.ts
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import Database from "better-sqlite3";
const sqlite = new Database("sqlite.db");
export const db = drizzle(sqlite);
migrate(db, { migrationsFolder: "./app/db/migrations" });
As you may have noticed in the code block above, we start by defining the database, then we use the SQLite driver and export its instance (db
).
Shortly afterwards we used the migrator
function to carry out automatic migrations in our database taking into account what is generated inside the app/db/migrations/
folder.
Then we define our database tables in the schema.server.ts
file, also inside the db/
folder, taking advantage of drizzle primitives and exporting the schema that was created:
// @/app/db/schema.server.ts
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
export const dogs = sqliteTable("dogs", {
id: integer("id").primaryKey(),
name: text("name").notNull(),
breed: text("breed").notNull(),
});
The last step in this section would be to create the following script in package.json
:
{
// ...
"scripts": {
// ...
"db:migrations": "drizzle-kit generate:sqlite --out ./app/db/migrations --schema ./app/db/schema.server.ts"
},
// ...
}
Running the above command will generate migrations inside the app/db/migrations
folder that can be applied to the database. With this we can go to the next section.
Modify and Build the Main Routes
In this section we will make some changes to existing pages, but we will also create new pages and new reusable components.
Layout Page
First of all, let's create a file called dogs.tsx
inside the routes/
folder that will be the layout of our application, with the following:
// @/app/routes/dogs.tsx
import { type V2_MetaFunction } from "@remix-run/node";
import { Outlet } from "@remix-run/react";
export const meta: V2_MetaFunction = () => {
return [{ title: "Dog List Page" }];
};
export default function DogsLayout() {
return (
<div className="m-12">
<Outlet />
</div>
);
}
In the example above we have a JSX component that returns only one Outlet
and this will be responsible for rendering each of our app's nested routes, whose path starts with /dogs
.
App Index Page Redirect
Next, we go to the main route of our application to remove the JSX content, because we want a redirection from the /
route to the /dogs
route.
In the _index.tsx
file, we do the following:
// @/app/routes/_index.tsx
import { redirect } from "@remix-run/node";
import { route } from "routes-gen";
export const loader = () => redirect(route("/dogs"));
In order to have type safety with the routes, I recommend running the routes:gen
command frequently to have the routes data types updated to take advantage of intellisense.
Create-page
In this section we will create a reusable component called Input
but first we need to install the following dependencies:
npm install remix-validated-form zod @remix-validated-form/with-zod
remix-validated-form
is the form validation library that we are going to use, it is very simple, intuitive and has a collection of utility functions to help us deal with forms.
Then we create the following component inside the app/components/
folder:
// @/app/components/Input.tsx
import type { FC, InputHTMLAttributes } from "react";
import { useField } from "remix-validated-form";
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
name: string;
label: string;
}
export const Input: FC<InputProps> = ({ name, label, ...rest }) => {
const { error, getInputProps } = useField(name);
return (
<span className="flex flex-col">
<label htmlFor={name} className="mb-3">
{label}
</label>
<input
className="input input-bordered w-full max-w-xs"
{...rest}
{...getInputProps({ id: name })}
/>
{error && <span className="label-text-alt mt-3">{error}</span>}
</span>
);
};
With this component we can move on to creating the page responsible for inserting a new doggo. The file to be created in the routes/
folder is dogs.create.tsx
with the following content:
// @/app/routes/dogs.create.tsx
import type { DataFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { withZod } from "@remix-validated-form/with-zod";
import { ValidatedForm, validationError } from "remix-validated-form";
import { route } from "routes-gen";
import { z } from "zod";
import { Input } from "~/components/Input";
import { db } from "~/db/config.server";
import { dogs } from "~/db/schema.server";
const validator = withZod(
z.object({
name: z.string().min(1).max(34),
breed: z.string().min(1).max(34),
})
);
export const loader = () => {
return json({
defaultValues: {
name: "",
breed: "",
},
});
};
export const action = async ({ request }: DataFunctionArgs) => {
const fieldValues = await validator.validate(await request.formData());
if (fieldValues.error) return validationError(fieldValues.error);
db.insert(dogs).values(fieldValues.data).run();
return redirect(route("/dogs"));
};
export default function DogInsertion() {
const { defaultValues } = useLoaderData<typeof loader>();
return (
<div>
<div className="mb-8">
<h2 className="mb-2 text-xl">Add a new doggo</h2>
<p className="text-gray-600">Listen, every doggo is a good boy/girl.</p>
</div>
<ValidatedForm
className="space-y-6"
method="POST"
validator={validator}
defaultValues={defaultValues}
>
<Input name="name" label="Name" placeholder="Your doggo's name..." />
<Input name="breed" label="Breed" placeholder="Your doggo's breed..." />
<button className="btn btn-accent" type="submit">
Submit
</button>
</ValidatedForm>
</div>
);
}
In the code block above, we started by defining the validator
variable taking into account the validation schema of the form that needs to be filled in.
Then, using the loader
function, we return the default values of the form and with the action
function, we obtain the data that were entered by the form and validate them.
If the data is valid, we insert the new doggo and redirect the user, otherwise we deal with validation errors.
Navigation
With the main components created, we can work on the Navbar
which will help the user navigate between the list and insertion pages. So that it can later be used in root.tsx
.
// @/app/components/Navbar.tsx
import { Link } from "@remix-run/react";
import { route } from "routes-gen";
export const Navbar = () => (
<nav className="navbar bg-base-300 rounded-xl mt-4">
<div className="flex w-full items-center">
<ul className="menu menu-horizontal px-1 space-x-4">
<li>
<Link to={route("/dogs")}>Dog List</Link>
</li>
<li>
<Link to={route("/dogs/create")}>New Dog</Link>
</li>
</ul>
</div>
</nav>
);
As mentioned earlier, the next step is to import this newly created component into root.tsx
:
// @/app/root.tsx
// ...
import { Navbar } from "./components/Navbar";
// ...
export default function App() {
return (
<html lang="en">
{/* ... */}
<body>
<div className="container mx-auto">
<Navbar /> {/* 👈 added this line */}
<Outlet />
</div>
{/* ... */}
</body>
</html>
);
}
Create the remaining routes
The effort in this section involves creating each of the remaining routes of our application, taking advantage of everything that has been done so far.
Details Page
The purpose of this route is to display the data of a specific doggo taking into account the dogId
parameter in the route. Let's take advantage of the loader
function, in which we query the database and then return them so that they can be consumed in the UI using the useLoaderData
hook.
// @/app/routes/dogs.$dogId.details.tsx
import { json, type LoaderArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { eq } from "drizzle-orm/expressions";
import type { RouteParams } from "routes-gen";
import { db } from "~/db/config.server";
import { dogs } from "~/db/schema.server";
export const loader = ({ params }: LoaderArgs) => {
const { dogId } = params as RouteParams["/dogs/:dogId/details"];
const result = db
.select()
.from(dogs)
.where(eq(dogs.id, Number(dogId)))
.get();
return json(result);
};
export default function DogDetailsMain() {
const data = useLoaderData<typeof loader>();
return (
<div className="w-3/5 lg:w-2/5">
<h2 className="mb-6">Dog details:</h2>
<div className="mockup-code">
<pre data-prefix="~">
<code>Name: {data.name}</code>,
<code>Breed: {data.breed}</code>
</pre>
</div>
</div>
);
}
Remove-page
The purpose of this page is to allow the user to remove a specific doggo. To do so, we will take advantage of the action
function that will take the dogId
parameter into account and we will delete the doggo from the database, after which it must be redirected to the main route of the nested routes.
// @/app/routes/dogs.$dogId.remove.tsx
import type { ActionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { Form } from "@remix-run/react";
import { eq } from "drizzle-orm/expressions";
import { route, type RouteParams } from "routes-gen";
import { db } from "~/db/config.server";
import { dogs } from "~/db/schema.server";
export const action = ({ params }: ActionArgs) => {
const { dogId } = params as RouteParams["/dogs/:dogId/remove"];
db.delete(dogs)
.where(eq(dogs.id, Number(dogId)))
.run();
return redirect(route("/dogs"));
};
export default function DogDetailsRemove() {
return (
<div>
<label htmlFor="my-modal-6" className="btn btn-wide">
Delete
</label>
<input type="checkbox" id="my-modal-6" className="modal-toggle" />
<div className="modal modal-bottom sm:modal-middle">
<div className="modal-box">
<h3 className="font-bold text-lg">Delete Database Entry</h3>
<p className="py-4">
Are you sure you want to delete this database entry? This action is
irreversible.
</p>
<div className="modal-action">
<Form method="POST">
<input type="hidden" name="_method" value="delete" />
<button className="btn">Yes</button>
</Form>
</div>
</div>
</div>
</div>
);
}
Update-page
The purpose of this route is to display data for a specific doggo taking into account the dogId
parameter in the route. Using the loader
we will obtain a doggo existing in the database and we will return it as JSON so that we have a previous filling of the form. As soon as changes are made, they are submitted and the record in the database is updated using an action
.
// @/app/routes/dogs.$dogId.update.tsx
import type { DataFunctionArgs, LoaderArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { withZod } from "@remix-validated-form/with-zod";
import { ValidatedForm, validationError } from "remix-validated-form";
import type { RouteParams } from "routes-gen";
import { route } from "routes-gen";
import { eq } from "drizzle-orm/expressions";
import { z } from "zod";
import { Input } from "~/components/Input";
import { db } from "~/db/config.server";
import { dogs } from "~/db/schema.server";
const validator = withZod(
z.object({
name: z.string().min(1).max(34),
breed: z.string().min(1).max(34),
})
);
export const loader = ({ params }: LoaderArgs) => {
const { dogId } = params as RouteParams["/dogs/:dogId/update"];
const { id, ...rest } = db
.select()
.from(dogs)
.where(eq(dogs.id, Number(dogId)))
.get();
return json({ defaultValues: rest });
};
export const action = async ({ request, params }: DataFunctionArgs) => {
const fieldValues = await validator.validate(await request.formData());
const { dogId } = params as RouteParams["/dogs/:dogId/update"];
if (fieldValues.error) {
return validationError(fieldValues.error);
}
db.update(dogs)
.set(fieldValues.data)
.where(eq(dogs.id, Number(dogId)))
.run();
return redirect(route("/dogs"));
};
export default function DogDetailsUpdate() {
const { defaultValues } = useLoaderData<typeof loader>();
return (
<div>
<h2 className="mb-6 text-xl">Update doggo entry</h2>
<ValidatedForm
className="space-y-6"
method="POST"
validator={validator}
defaultValues={defaultValues}
>
<Input name="name" label="Name" placeholder="Your doggo's name..." />
<Input name="breed" label="Breed" placeholder="Your doggo's breed..." />
<button className="btn btn-accent" type="submit">
Update
</button>
</ValidatedForm>
</div>
);
}
Dog Details Layout
Now that we have each of the pages that make it possible to mutate the data in the database taking into account a specific doggo, we need to create navigation between the different pages that were created (details, remove and update).
Again, let's use the route
primitive from routes-gen
to autocomplete the editor to ensure that we define the necessary parameters for each of the routes.
Obviously we create each of the navigation items taking into account the created routes and use the Outlet
primitive to render each of the nested routes.
// @/app/routes/dogs.$dogId.tsx
import type { V2_MetaFunction } from "@remix-run/react";
import { Link, Outlet, useParams } from "@remix-run/react";
import type { RouteParams } from "routes-gen";
import { route } from "routes-gen";
export const meta: V2_MetaFunction = () => {
return [{ title: "Dog Details Page" }];
};
export default function DogDetails() {
const { dogId } = useParams<RouteParams["/dogs/:dogId"]>();
return (
<div className="flex flex-row space-x-12">
<ul className="menu bg-base-100 w-56 rounded-box">
<li>
<Link
to={route("/dogs/:dogId/details", {
dogId: dogId ?? "",
})}
>
Details
</Link>
</li>
<li>
<Link
to={route("/dogs/:dogId/update", {
dogId: dogId ?? "",
})}
>
Update
</Link>
</li>
<li className="text-red-600">
<Link
to={route("/dogs/:dogId/remove", {
dogId: dogId ?? "",
})}
>
Remove
</Link>
</li>
</ul>
<Outlet />
</div>
);
}
Main Route
If you've followed the article so far, we've already created each of the routes to do the CRUD of the application, the only thing missing is to list the dogs that were added through the app in the database. To do so, let's create the following dogs._index.tsx
:
// @/app/routes/dogs.$dogId.tsx
import { json } from "@remix-run/node";
import { Link, useLoaderData } from "@remix-run/react";
import { route } from "routes-gen";
import { db } from "~/db/config.server";
import { dogs } from "~/db/schema.server";
export const loader = () => {
const result = db.select().from(dogs).all();
return json(result);
};
export default function DogList() {
const data = useLoaderData<typeof loader>();
return (
<div className="overflow-x-auto">
<table className="table table-zebra w-full">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Breed</th>
</tr>
</thead>
<tbody>
{data?.map((item) => (
<tr key={item.id}>
<th>
<Link
to={route("/dogs/:dogId/details", {
dogId: item.id.toString(),
})}
>
<kbd className="kbd">{item.id}</kbd>
</Link>
</th>
<td>{item.name}</td>
<td>{item.breed}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
As you may have noticed in the code block above, we get all the data from the dogs
table in the loader
and return them so that we can render each of the table's rows on the page, as well as have a link to navigate to the pages of a doggo in detail.
Conclusion
I hope you found this article helpful, whether you're using the information in an existing project or just giving it a try for fun.
Please let me know if you notice any mistakes in the article by leaving a comment. And, if you'd like to see the source code for this article, you can find it on the github repository linked below.
Posted on May 2, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.