Remix: React Framework part 2. Prisma and SQLite
File structure
src/
├── app/
│ ├── routes/
│ │ ├── auth/
│ │ │ ├── login.jsx
│ │ │ └── logout.jsx
│ │ ├── posts/
│ │ │ ├── $postId.jsx
│ │ │ ├── index.jsx
│ │ │ └── new.jsx
│ │ ├── index.jsx
│ │ └── post.jsx
│ ├── styles/
│ │ └── globals.css
│ ├── utils/
│ │ ├── db.server.ts
│ │ └── session.server.ts
│ ├── entry.client.jsx
│ ├── entry.server.jsx
│ └── root.jsx
├── prisma/
│ ├── dev.db
│ ├── schema.prisma
│ └── seed.js
├── .env
├── jsconfig.json
├── package.lock.json
├── package.json
├── README.md
└── remix.config.js
Post validation
On src/app/routes/posts/new.js, let’s bring from remix useActionData and json:
import { Link, redirect, useActionData, json } from "remix";
import { db } from "~/utils/db.server";
function validateTitle(title) {
if (typeof title !== "string" || title.length < 3) {
return "Title should be at least 3 characters long";
}
}
function validateBody(body) {
if (typeof body !== "string" || body.length < 10) {
return "Body should be at least 10 characters long";
}
}
export const action = async ({ request }) => {
const form = await request.formData();
const title = form.get("title");
const body = form.get("body");
const fields = { title, body };
const fieldErrors = {
title: validateTitle(title),
body: validateBody(body),
};
if (Object.values(fieldErrors).some(Boolean)) {
console.log(fieldErrors);
return json({ fieldErrors, fields }, { status: 400 });
}
const post = await db.post.create({
data: fields,
});
return redirect(`/posts/${post.id}`);
};
function NewPost() {
const action = useActionData();
return (
<>
<div className="page-header">
<h1>New Post</h1>
<Link to="/posts" className="btn btn-reverse">
Back
</Link>
</div>
<div className="page-content">
<form method="POST">
<div className="form-control">
<label htmlFor="title">Title</label>
<input
type="text"
name="title"
id="title"
defaultValue={action?.fields?.title}
/>
<div className="error">
<p>{action?.fieldErrors?.title && action?.fieldErrors?.title}</p>
</div>
</div>
<div className="form-control">
<label htmlFor="body">Post Body</label>
<textarea
type="text"
name="body"
id="body"
defaultValue={action?.fieldErrors?.body}
/>
<div className="error">
<p>{action?.fieldErrors?.body && action?.fieldErrors?.body}</p>
</div>
</div>
<button type="submit" className="btn btn-block">
Add Post
</button>
</form>
</div>
</>
);
}
/*
export function ErrorBoundary({ error }) {
console.log(error);
return (
<div>
<h1>Error</h1>
<p>{error.message}</p>
</div>
);
}
*/
export default NewPost;
We basically added this in our server:
const fieldErrors = {
title: validateTitle(title),
body: validateBody(body),
};
if (Object.values(fieldErrors).some(Boolean)) {
console.log(fieldErrors);
return json({ fieldErrors, fields }, { status: 400 });
}
Which validates the length of our data inside the forms.
Updating Prisma Model
Inside src/prisma/schema.prisma we need to add a model for our users:
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
posts Post[]
}
model Post {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
title String
body String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
What we’re saying is that the Post id should have a relation with User id.
The onDelete: Cascade is for when we delete a Post, it will also delete the relationship with the User.
Updating seed file
Inside src/prisma/seed.js we need to add a default user to our application:
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
async function seed() {
const defaultUser = await prisma.user.create({
data: {
username: "Michael",
// Hash for password - twixrox
passwordHash:
"$2b$10$K7L1OJ45/4Y2nIvhRVpCe.FSmhDdWoXehVzJptJ/op0lSsvqNu/1u",
},
});
await Promise.all(
getPosts().map((post) => {
const data = {
userId: { userId: defaultUser.id, ...post },
};
return prisma.post.create({
data,
});
})
);
}
seed();
function getPosts() {
return [
{
title: "JavaScript Performance Tips",
body: `We will look at 10 simple tips and tricks to increase the speed of your code when writing JS`,
},
{
title: "Tailwind vs. Bootstrap",
body: `Both Tailwind and Bootstrap are very popular CSS frameworks. In this article, we will compare them`,
},
{
title: "Writing Great Unit Tests",
body: `We will look at 10 simple tips and tricks on writing unit tests in JavaScript`,
},
{
title: "What Is New In PHP 8?",
body: `In this article we will look at some of the new features offered in version 8 of PHP`,
},
];
}
Remember to kill the terminal and use npx prisma db push to apply the changes on the DB. Next node prisma/seed and also remember you can use npx prisma studio for a cool UI to check the DB data.
Login and Registration Form
Add on src/app/root.jsx inside the nav container:
<li>
<Link to="/auth/login">Login</Link>
</li>
Inside our src/app/routes folder, lets create a folder called auth with login.jsx:
import { useActionData, json, redirect } from "remix";
import { db } from "~/utils/db.server";
function badRequest(data) {
return json(data, { status: 400 });
}
export const action = async ({ request }) => {
const form = await request.formData();
const loginType = form.get("loginType");
const username = form.get("username");
const password = form.get("password");
const fields = {
loginType,
username,
password,
};
const fieldErrors = {
username: validateUsername(username),
password: validatePassword(password),
};
if (Object.values(fieldErrors).some(Boolean)) {
console.log(fieldErrors);
return badRequest({ fieldErrors, fields });
}
};
const Login = () => {
const actionData = useActionData();
return (
<div className="auth-container">
<div className="page-header">
<h1>Login</h1>
</div>
<div className="page-content">
<form method="POST">
<fieldset>
<legend>Login or Register</legend>
<label>
<input
type="radio"
name="loginType"
value="login"
defaultChecked={
!actionData?.fields?.loginType ||
actionData?.fields?.loginType === "login"
}
/>{" "}
Login
</label>
<label>
<input type="radio" name="loginType" value="register" /> Register
</label>
</fieldset>
<div className="form-control">
<label htmlFor="username">Username</label>
<input
type="text"
name="username"
id="username"
defaultValue={actionData?.fields?.username}
/>
<div className="error">
{actionData?.fieldErrors?.username &&
actionData?.fieldErrors?.username}
</div>
</div>
<div className="form-control">
<label htmlFor="password">Password</label>
<input
type="password"
name="password"
id="password"
defaultValue={actionData?.fields?.password}
/>
<div className="error">
{actionData?.fieldErrors?.password &&
actionData?.fieldErrors?.password}
</div>
</div>
<button className="btn btn-block" type="submit">
Submit
</button>
</form>
</div>
</div>
);
};
export default Login;
We add a radioInput with two options, a username text input and a password input with the respective type and we round it up with a button submitting the data.
We create an action where we get the submitted data, validate it and then, depending on the login type, we create a session for the user.
User login session
Install npm i bcrypt so we can work with hashed passwords.
Inside our .env folder, let’s add a SESSION_SECRET variable:
SESSION_SECRET = "secret";
Inside utils folder we create a file src/app/utils/session.server.ts.
Inside we will create a login function that will check if the user submitted data is correct by checking if the username exists in the DB and checking with bcrypt, the hashed password.
Then we get our .env session variable and we create a cookie session storage where we store the cookie, then we create the user session using their userId.
import bcrypt from "bcrypt";
import { db } from "./db.server";
import { createCookieSessionStorage, redirect } from "remix";
// Login user
export async function login({ username, password }) {
const user = await db.user.findUnique({
where: {
username,
},
});
if (!user) return null;
// Check password
const isCorrectPassword = await bcrypt.compare(password, user.passwordHash);
if (!isCorrectPassword) return null;
return user;
}
// Get session secret
const sessionSecret = process.env.SESSION_SECRET;
if (!sessionSecret) {
throw new Error("No Session Secret");
}
// Create session storage
const storage = createCookieSessionStorage({
cookie: {
name: "remixblog_session",
secure: process.env.NODE_ENV === "production",
secrets: [sessionSecret],
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 60,
httpOnly: true,
},
});
// Create 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),
},
});
}
// Get user session
export function getUserSession(request: Request) {
return storage.getSession(request.headers.get("Cookie"));
}
// Get logged in user
export async function getUser(request: Request) {
const session = await getUserSession(request);
const userId = session.get("userId");
if (!userId || typeof userId !== "string") {
return null;
}
try {
const user = await db.user.findUnique({
where: {
id: userId,
},
});
} catch (error) {
return null;
}
}
Returning to our src/app/routes/auth/login.jsx, we add following code inside our action where we create the user session using the createUserSession function we just created:
switch (loginType) {
case "login": {
// Find user
const user = await login({ username, password });
// Check user
if (!user) {
return badRequest({
fields,
fieldErrors: {
username: "Invalid Credentials",
},
});
}
// Create user session
return createUserSession(user.id, "/posts");
}
case "register": {
// Check if user exists
// Create user
// Create user session
}
default: {
return badRequest({
fields,
formError: "Login type is not valid",
});
}
}
Adding logout functionality
Inside our root.jsx we bring the useLoaderData and getUser:
export const loader = async ({ request }) => {
const user = await getUser(request);
const data = {
user,
};
return data;
};
And inside our Layout function
function Layout({ children }) {
const { user } = useLoaderData();
return (
<>
<nav className="navbar">
<Link to="/" className="logo">
Remix
</Link>
<ul className="nav">
<li>
<Link to="/posts">Posts</Link>
</li>
{user ? (
<li>
<form action="/auth/logout" method="POST">
<button className="btn" type="submit">
Logout {user.username}
</button>
</form>
</li>
) : (
<li>
<Link to="/auth/login">Login</Link>
</li>
)}
<li>
<Link to="/auth/login">Login</Link>
</li>
</ul>
</nav>
<div className="">{children}</div>
</>
);
}
We get to add the logout button! Now let’s work on creating the route, but first let’s add the logout inside our session.server.ts file:
// Log out user and destroy session
export async function logout(request: Request) {
const session = await storage.getSession(request.headers.get("Cookie"));
return redirect("/auth/logout", {
headers: {
"Set-Cookie": await storage.destroySession(session),
},
});
}
Now inside our src/app/routes/auth/logout.jsx file:
import { redirect } from "remix";
import { logout } from "~/utils/session.server";
export const action = async ({ request }) => {
return logout(request);
};
export const loader = async () => {
return redirect("/");
};
We can now logout completely!
Registering a user
Inside our src/app/routes/auth/login.jsx, let’s work on the register case in the switch case:
case "register": {
// Check if user exists
const userExists = await db.user.findFirst({
where: {
username
}
})
if(userExists) {
return badRequest({
fields,
fieldErrors: {
username: `User ${username} already exists`
}
})
}
// Create user
const user = await register({ username, password })
if(!user) {
return badRequest({
fields,
formError: 'Something went wrong',
})
}
// Create user session
return createUserSession(user.id, "/posts");
}
Now inside our session.server.ts we need to create the data in our DB for the new user:
// Register new user
export async function register({ username, password }) {
const passwordHash = await bcrypt.hash(password, 10);
return db.user.create({
data: {
username,
passwordHash,
},
});
}
Add user to post
Inside src/app/routes/posts/new.jsx, we import getUser:
import { getUser } from "~/utils/session.server";
And inside our action we get the user and send the userId when create a post:
export const action = async ({ request }) => {
const form = await request.formData();
const title = form.get("title");
const body = form.get("body");
const user = await getUser(request);
const fields = { title, body };
const fieldErrors = {
title: validateTitle(title),
body: validateBody(body),
};
if (Object.values(fieldErrors).some(Boolean)) {
console.log(fieldErrors);
return badRequest({ fieldErrors, fields });
}
const post = await db.post.create({
data: {
...fields,
userId: user.id,
},
});
return redirect(`/posts/${post.id}`);
};
Open up npx prisma studio, create a post with a new user and check to see the username is there now!
Delete access control for every user
Inside src/app/routes/posts/$postId.jsx:
And inside our action we add a condition for the user to only being able to delete a post if it’s his:
export const action = async ({ request, params }) => {
const form = await request.formData();
if (form.get("_method") === "delete") {
const user = await getUser(request);
const post = await db.post.findUnique({
where: { id: params.postId },
});
if (!post) throw new Error("Post not found");
if (user && post.userId === user.id) {
await db.post.delete({ where: { id: params.postId } });
}
return redirect("/posts");
}
};
Inside our loader and post we can disable the Delete button:
export const loader = async ({ request, params }) => {
const user = await getUser(request);
const post = await db.post.findUnique({
where: { id: params.postId },
});
if (!post) throw new Error("Post not found");
const data = { post, user };
return data;
};
Now inside the Post function we can get the user id and compare it with the post id to enable/disable the delete button:
function Post() {
const { post, user } = useLoaderData();
return (
<div>
<div className="page-header">
<h1>{post.title}</h1>
<Link to="/posts" className="btn btn-reverse">
Back
</Link>
</div>
<div className="page-content">{post.body}</div>
<div className="page-footer">
{user.id === post.userId && (
<form method="POST">
<input type="hidden" name="_method" value="delete" />
<button className="btn btn-delete">Delete</button>
</form>
)}
</div>
</div>
);
}
Conclusion
We created a fully functional blog using Remix! We added sessions, authorization and authentication based on cookies. Working with Remix is an interesting experience compared to create-react-app since we skip a lot of routing configuration with react-router and the interest folder structure that enables the routing system to work automatically inside our routes folder.
See you on the next post.
Sincerely,
Eng. Adrian Beria