Strapi v4 Authentication with Remix
In this article, we'll create a Remix application with authentication and authorization using Strapi
In this article, we'll create a Remix application with authentication and authorization using Strapi.
Author: Miracle Onyenma
Authentication is an integral part of any application with users, but setting up a complete authentication and authorization workflow from scratch could be incredibly time-consuming and unproductive except for some unique cases.
With Strapi, we have access to multiple authentication providers like Google, Twitter, etc., enabling us to set up authenticated requests to our Headless CMS API to fetch data easily and perform actions only available to authenticated and authorized users. With Strapi authentication, we can quickly set up a robust authentication system for our application and focus on building.
Let's look at how we can set up a simple Remix application and implement user authorization and authentication with Strapi.
A Brief introduction to Headless CMS
A Content Management System (CMS) is a software or service that helps you create and manage content for your website and applications.
For a traditional CMS, the front-end of the website or application gets built into the CMS. The only customizations available are pre-made themes and custom code. WordPress, Joomla, and Drupal are good examples of a traditional CMS that merges the website front-end with the back-end.
Unlike a traditional CMS, a headless CMS gives you the freedom to build out your client or front-end, connecting it to the CMS via APIs. Also, with a headless CMS, the application frontend can be built using any technology, allowing multiple clients to connect and pull data from one CMS.
What is Strapi?
Strapi is leading JavaScript open-source headless CMS. Strapi makes it very easy to build custom APIs, REST or GraphQL, that can be consumed by any client or front-end framework of choice.
It sounds interesting, especially since we’ll be consuming our Strapi API and building out Authentication and Authorization with Remix.
Authentication in Strapi
Strapi uses token-based authentication to authenticate its users by providing a JWT token to a user on successful user registration and login. Strapi also supports multiple authentication providers like Auth0, Google, etc. We’ll be using the local auth in this tutorial.
What is Remix?
Remix is a full-stack web framework that focuses on the user interface and works back through web fundamentals to deliver a fast, sleek, and resilient user experience. Remix includes React Router, server-side rendering, TypeScript support, production server, and backend optimization.
Goal
At the end of this tutorial, we would have covered how to add authentication to our Remix application with Strapi.
Prerequisites
- Basic knowledge of JavaScript
- Basic knowledge of Remix
- A code editor like VSCode
- Node.js version (^12.22.0, ^14.17.0, or >=16.0.0). You can download Node.js from Node.js official site if you haven't already.
- npm 7 or greater installed
What We’re Building
We’ll build a simple Remix application where users can register, log in, and edit their profiles. Here’s the live example hosted on Netlify
Step 1: Set up Backend with Strapi
To kick off the creation process, we'll begin by setting up the backend with Strapi.
- First, we’ll create a new Strapi app. Navigate to the directory of your choice and run one of the commands below:
yarn create strapi-app profile-api
#or
npx create-strapi-app@latest profile-api
- Next, choose the installation type.
Quickstart
uses the default database SQLite and is recommended. Once the installation is complete, the Strapi admin dashboard should automatically open in your browser. - Fill out the form to create an admin account. This will allow us to access the admin dashboard.
Create Collection Types
Let's modify our collection type for Users. Navigate to CONTENT-TYPE BUILDER > COLLECTION TYPES > USER*.* Here, we’ll see the structure of the user type in Strapi.
We’re just going to add a few more fields here. Click on the + ADD ANOTHER FIELD button at the top right corner to add the following fields:
twitterUsername
- Text (Short Text) and under Advanced settings, select Unique field. ✅websiteUrl
- Text (Short text)title
- Text (Short Text) and under Advanced settings, Select Required field. ✅bio
- Text (Long Text)profilePic
- Media (Single media)color
- Enumeration: Values picked from Tailwind colors):Red
Orange
Amber
etc. Under Advanced settings, set Default Value toCyan
and enable Required field. ✅
slug
- UID: Attached field -username
Now, we should end up with something like this:
Click on SAVE. This will save the changes to the collection type and restart the server.
Configure Permissions for Public and Authenticated Users
Strapi is secure by default, so we won't be able to access any data from the API unless we set the permissions. To set the permissions,
- Navigate to SETTINGS > USERS & PERMISSIONS PLUGIN > ROLES.
- Go to PUBLIC and enable the following actions for the following under Users-permissions.
- USER
- ✅
Count
- ✅
find
- ✅
findOne
- ✅
We should have something like this:
- Click SAVE.
- Now, go to AUTHENTICATED and enable the following under User-permissions
- AUTH - ✅ Select All
- PERMISSIONS - ✅ Select All
USER ✅ - Select All
Click SAVE.
Also, we’ll quickly create a few user profiles for our application. To create users, navigate to CONTENT MANAGER > USER. Then, click on CREATE NEW ENTRY, fill out all the necessary info and save the entries.
Here are my users for example:
Step 2: Setting up the Remix application
To create our Remix frontend, run:
npx create-remix@latest
If this is your first time installing Remix, it’ll ask whether you want to install
create-remix@latest
. Entery
to install
Once the setup script runs, it'll ask you a few questions.
? Where would you like to create your app? remix-profiles
? What type of app do you want to create? Just the basics
? Where do you want to deploy? Choose Remix App Server if you're unsure; it's easy to change deployment targets. Remix App Server
? TypeScript or JavaScript? TypeScript
? Do you want me to run `npm install`? Yes
Here we call the app "remix-profiles", then choose "Just the basics" for the app type and for the deploy target, we choose "Remix App Server", we’ll also be using TypeScript for this project and let Remix run npm install
for us.
The "Remix App Server" is a full-featured Node.js server based on Express. It's the simplest option and we’ll go with it for this tutorial.
Once the npm install
is successful, we'll navigate to the remix-profiles
directory:
cd remix-jokes
Set up TailwindCSS
Install tailwindcss
, its peer dependencies, and concurrently
via npm, and then run the init command to generate our tailwind.config.js
file.
npm install tailwindcss postcss autoprefixer concurrently @tailwindcss/forms @tailwindcss/aspect-ratio
npx tailwindcss init
Now, configure ./tailwind.config.js
:
// ./tailwind.config.js
module.exports = {
content: [
"./app/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
corePlugins: {
aspectRatio: false,
},
plugins: [
require('@tailwindcss/forms'),
require('@tailwindcss/aspect-ratio')
],
}
Now, we have to update the scripts in our package.json
file to build both the development and production CSS.
// ./package.json
{
"scripts": {
"build": "npm run build:css && remix build",
"build:css": "tailwindcss -m -i ./styles/app.css -o app/styles/app.css",
"dev": "concurrently \"npm run dev:css\" \"remix dev\"",
"dev:css": "tailwindcss -w -i ./styles/app.css -o app/styles/app.css",
}
}
With that, we can add the @tailwind
directives for each of Tailwind’s layers to our css file. Create a new file ./styles/app.css
:
// ./styles/app.css/
@tailwind base;
@tailwind components;
@tailwind utilities;
To apply this to our application, we have to import the compiled ./app/styles/app.css
file into our project in our ./app/root.tsx
file:
// ./app/root.tsx
import type { MetaFunction, LinksFunction } from "@remix-run/node";
// import tatilwind styles
import styles from "./styles/app.css"
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
export const meta: MetaFunction = () => ({
charset: "utf-8",
title: "New Remix App",
viewport: "width=device-width,initial-scale=1",
});
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
};
export default function App() {
return (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}
Awesome!
Let’s take a quick look at our project structure at this point. It should look something like this:
remix-profiles
├─ .eslintrc
├─ .gitignore
├─ app
│ ├─ entry.client.tsx
│ ├─ entry.server.tsx
│ ├─ root.tsx
│ └─ routes
│ └─ index.tsx
├─ package-lock.json
├─ package.json
├─ public
│ └─ favicon.ico
├─ README.md
├─ remix.config.js
├─ styles
│ └─ app.css
├─ tailwind.config.js
└─ tsconfig.json
Now, let’s run our build process and start our application with:
npm run dev
This runs the dev scripts we added to package.json
and runs the Tailwind alongside Remix:
We should be greeted with this:
Alright! Let's get into the juicy stuff and build out our Remix application.
Note:
🚩 All the styles added to this application are in a single
./styles/app.css
file (not compiled) which you can access in the project's GitHub repository.🚩 I’ll be using TypeScript for this project, I’ve kept all the custom type declarations I created for this project in the
./app/utils/types.ts
file. You can get it from GitHub and use it to follow along if you’re working with TypeScript.
However, if you prefer to use JavaScript, you can ignore all that and also use .js
and .jsx
files instead.
Add Strapi URL to Environment Variable
Create an ./.env
file in the root of the project and add the following:
STRAPI_API_URL="http://localhost:1337/api"
STRAPI_URL="http://localhost:1337"
Create SiteHeader Component
Let’s add a nice and simple header with basic navigation to our application.
- Create a new file in
./app/components/SiteHeader.tsx
// ./app/components/SiteHeader.tsx
// import Remix's link component
import { Link } from "@remix-run/react";
// import type definitions
import { Profile } from "~/utils/types";
// component accepts `user` prop to determine if user is logged in
const SiteHeader = ({user} : {user?: Profile | undefined}) => {
return (
<header className="site-header">
<div className="wrapper">
<figure className="site-logo"><Link to="/"><h1>Profiles</h1></Link></figure>
<nav className="site-nav">
<ul className="links">
{/* show sign out link if user is logged in */}
{user?.id ?
<>
{/* link to user profile */}
<li>
<Link to={`/${user?.slug}`}> Hey, {user?.username}! </Link>
</li>
<li className="link"><Link to="/sign-out">Sign out</Link></li>
</> :
<>
{/* show sign in and register link if user is not logged in */}
<li className="link"><Link to="/sign-in">Sign In</Link></li>
<li className="link"><Link to="/register">Register</Link></li>
</>
}
</ul>
</nav>
</div>
</header>
);
};
export default SiteHeader;
- We want this to always be visible in the application, regardless of the route. So we simply add it to our
./app/routes/root.tsx
file:
// ./app/root.jsx
import type { MetaFunction, LinksFunction } from "@remix-run/node";
// import compiled styles
import styles from "./styles/app.css";
import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react";
// import site header component
import SiteHeader from "./components/SiteHeader";
// add site meta
export const meta: MetaFunction = () => ({
charset: "utf-8",
title: "Profiles | Find & connect with people",
viewport: "width=device-width,initial-scale=1",
});
// add links to site head
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
};
export default function App() {
return (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
<main className="site-main">
{/* place site header above app outlet */}
<SiteHeader />
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</main>
</body>
</html>
);
}
Create ProfileCard Component
We’ll create a ProfileCard
component that will be used to display the user information. Create a new file ./app/components/ProfileCard.tsx
:
// ./app/components/ProfileCard.tsx
import { Link } from "@remix-run/react";
// type definitions for Profile response
import { Profile } from "~/utils/types";
// strapi url from environment variables
const strapiUrl = `http://localhost:1337`;
// helper function to get image url for user
// we're also using https://ui-avatars.com api to generate images
// the function appends the image url returned
const getImgUrl = ({ url, username }: { url: string | undefined; username: string | "A+N" }) =>
url ? `${strapiUrl}${url}` : `https://ui-avatars.com/api/?name=${username?.replace(" ", "+")}&background=2563eb&color=fff`;
// component accepts `profile` prop which contains the user profile data and
// `preview` prop which indicates whether the card is used in a list or
// on its own in a dynamic page
const ProfileCard = ({ profile, preview }: { profile: Profile; preview: boolean }) => {
return (
<>
{/* add the .preview class if `preview` == true */}
<article className={`profile ${preview ? "preview" : ""}`}>
<div className="wrapper">
<div className="profile-pic-cont">
<figure className="profile-pic img-cont">
<img
src={getImgUrl({ url: profile.profilePic?.formats.small.url, username: profile.username })}
alt={`A photo of ${profile.username}`}
className="w-full"
/>
</figure>
</div>
<div className="profile-content">
<header className="profile-header ">
<h3 className="username">{profile.username}</h3>
{/* show twitter name if it exists */}
{profile.twitterUsername && (
<a href="https://twitter.com/miracleio" className="twitter link">
@{profile.twitterUsername}
</a>
)}
{/* show bio if it exists */}
{profile.bio && <p className="bio">{profile.bio}</p>}
</header>
<ul className="links">
{/* show title if it exists */}
{profile.title && (
<li className="w-icon">
<svg className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
<span> {profile.title} </span>
</li>
)}
{/* show website url if it exists */}
{profile.websiteUrl && (
<li className="w-icon">
<svg className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
/>
</svg>
<a href="http://miracleio.me" target="_blank" rel="noopener noreferrer" className="link">
{profile.websiteUrl}
</a>
</li>
)}
</ul>
{/* hide footer in preview mode */}
{!preview && (
<footer className="grow flex items-end justify-end pt-4">
{/* hide link if no slug is present for the user */}
{profile?.slug && (
<Link to={profile?.slug}>
<button className="cta w-icon">
<span>View profile</span>
<svg xmlns="http://www.w3.org/2000/svg" className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</button>
</Link>
)}
</footer>
)}
</div>
</div>
</article>
</>
);
};
export default ProfileCard;
Before this component can work, we have to get the profile data. We can easily do that by creating a module that deals with fetching, creating and updating profiles using the Strapi API.
Set up Server Module to Connect with Strapi API
Let’s set up a module that exports a getProfiles
and getProfileBySlug
function. Create ./app/models/profiles.server.ts
:
// ./app/models/profiles.server.tsx
// import types
import { Profile, ProfileData } from "~/utils/types"
// Strapi API URL from environment varaibles
const strapiApiUrl = process.env.STRAPI_API_URL
// function to fetch all profiles
export const getProfiles = async (): Promise<Array<Profile>> => {
const profiles = await fetch(`${strapiApiUrl}/users/?populate=profilePic`)
let response = await profiles.json()
return response
}
// function to get a single profile by it's slug
export const getProfileBySlug = async (slug: string | undefined): Promise<Profile> => {
const profile = await fetch(`${strapiApiUrl}/users?populate=profilePic&filters[slug]=${slug}`)
let response = await profile.json()
// since the request is a filter, it returns an array
// here we return the first itm in the array
// since the slug is unique, it'll only return one item
return response[0]
}
Now, on our index page, ./app/routes/index.tsx
, we’ll add the following:
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
// import profile card component
import ProfileCard from "~/components/ProfileCard";
// import get profiles function
import { getProfiles } from "~/models/profiles.server";
// loader data type definition
type Loaderdata = {
// this implies that the "profiles type is whatever type getProfiles resolves to"
profiles: Awaited<ReturnType<typeof getProfiles>>;
}
// loader for route
export const loader = async () => {
return json<Loaderdata>({
profiles: await getProfiles(),
});
};
export default function Index() {
const { profiles } = useLoaderData() as Loaderdata;
return (
<section className="site-section profiles-section">
<div className="wrapper">
<header className="section-header">
<h2 className="text-4xl">Explore profiles</h2>
<p>Find and connect with amazing people all over the world!</p>
</header>
{profiles.length > 0 ? (
<ul className="profiles-list">
{profiles.map((profile) => (
<li key={profile.id} className="profile-item">
<ProfileCard profile={profile} preview={false} />
</li>
))}
</ul>
) : (
<p>No profiles yet 🙂</p>
)}{" "}
</div>
</section>
);
}
Here, we create a loader
function that calls the getProfiles
function we created earlier and loads the response into our route. To use that data, we import useLoaderData
and call it within Index()
and obtain the profiles
data by destructuring.
We should have something like this:
Next, we’ll create a dynamic route to display individual profiles.
Create Dynamic Profile Routes
In the ./app/routes/$slug.tsx
file, we’ll use the loader params
to get the slug
from the route and run the getProfileBySlug()
function with the value to get the profile data.
// ./app/routes/$slug.tsx
import { json, LoaderFunction, ActionFunction, redirect } from "@remix-run/node";
import { useLoaderData, useActionData } from "@remix-run/react";
import { useEffect, useState } from "react";
import ProfileCard from "~/components/ProfileCard";
import { getProfileBySlug } from "~/models/profiles.server";
import { Profile } from "~/utils/types";
// type definition of Loader data
type Loaderdata = {
profile: Awaited<ReturnType<typeof getProfileBySlug>>;
};
// loader function to get posts by slug
export const loader: LoaderFunction = async ({ params }) => {
return json<Loaderdata>({
profile: await getProfileBySlug(params.slug),
});
};
const Profile = () => {
const { profile } = useLoaderData() as Loaderdata;
const errors = useActionData();
const [profileData, setprofileData] = useState(profile);
const [isEditing, setIsEditing] = useState(false);
return (
<section className="site-section">
<div className="wrapper flex items-center py-16 min-h-[calc(100vh-4rem)]">
<div className="profile-cont w-full max-w-5xl m-auto">
{profileData ? (
<>
{/* Profile card with `preview` = true */}
<ProfileCard profile={profileData} preview={true} />
{/* list of actions */}
<ul className="actions">
<li className="action">
<button className="cta w-icon">
<svg xmlns="http://www.w3.org/2000/svg" className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
/>
</svg>
<span>Share</span>
</button>
</li>
<li className="action">
<button onClick={() => setIsEditing(!isEditing)} className="cta w-icon">
{!isEditing ? (
<svg xmlns="http://www.w3.org/2000/svg" className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
<span>{!isEditing ? "Edit" : "Cancel"}</span>
</button>
</li>
</ul>
</>
) : (
<p className="text-center">Oops, that profile doesn't exist... yet</p>
)}
</div>
</div>
</section>
);
};
export default Profile;
Here, we also added two action buttons. The Edit button, however, is only going to be rendered when the user is signed in. We’ll get to that very soon. This is what the page should look like for this regular user:
Awesome. Now that we have the basics of the application. Let’s build out the authentication so that we can start creating profiles.
Step 3: Authentication in Remix
We’ll be using the traditional email and password authentication for our application. For Strapi, this means we’ll be using the local authentication provider. This provider is pretty straightforward to work with.
To log in, you need to make a POST request to /api/auth/local
with the body
object containing an identifier and password, as you see in this example from the docs. We could also easily use other providers if we wanted. Strapi makes that easy.
On Remix’s end, however, we’ll have to do a few things with Cookies to get authentication rolling. Strapi handles the user registration and authentication work. So all we need to do in Remix is keep the user logged in, using cookies to store user data, especially the user id and JWT.
Let’s get started by building the login functionality. To do that, we need to create a login route and a form for users to enter their details. We’ll create a reusable form component for this and call it <ProfileForm>
.
Create ProfileForm Component
This form will contain the fields for user login, registration, and updating profile information. To dynamically display fields for authentication (user registration and login) and edit a user profile, we will conditionally render the input fields.
Here’s an overview of how we’ll achieve that:
<Form>
{action != "login" && (
<>
{/* Profile registeration and update input fields */}
</>
)}
{action != "edit" && (
<>
{/* User login input fields */}
</>
)}
</Form>
With this we’ll be able to:
- For
"login"
action, display only login input fields likeemail
andpassword
- For
"edit"
action, only display **the profile fields likeusername
,bio
,website
, etc. - For
"create"
action, display both the login fields and the profile fields. This allows users to set fill in their data while creating the account.
This “dynamic” form is not crucial to our application, though. We’re just trying to create a reusable form component for all the use cases we currently have. We can as well, create separate forms for different actions.
To implement this, create a new file ./app/components/ProfileForm.tsx
:
// ./app/components/ProfileForm.tsx
import { Form, useTransition } from "@remix-run/react";
import { useEffect, useState } from "react";
// custom type declarations
import { Profile, ProfileFormProps } from "~/utils/types";
const ProfileForm = ({ profile, onModifyData, action, errors }: ProfileFormProps) => {
// get state of form
const transition = useTransition();
// state for user profile data
const [profileData, setProfileData] = useState(profile);
// state for user login information
const [authData, setAuthData] = useState({ email: "", password: "" });
// helper function to set profile data value
const updateField = (field: object) => setProfileData((value) => ({ ...value, ...field }));
// listen to changes to the profileData state
// run the onModifyData() function passing the profileData to it
// this will snd the data to the parent component
useEffect(() => {
// run function if `onModifyData` is passed to the component
if (onModifyData) {
// depending on the action passed to the form
// select which data to send to parent when modified
// when action == create, send both the profile data and auth data
if (action == "create") onModifyData({ ...profileData, ...authData });
// when action == login, send only auth data
else if (action == "login") onModifyData(authData);
// send profile data by default (when action == edit)
else onModifyData(profileData);
}
}, [profileData, authData]);
return (
<Form method={action == "edit" ? "put" : "post"} className="form">
<fieldset disabled={transition.state == "submitting"}>
<input value={profile?.id} type="hidden" name="id" required />
<div className="wrapper">
{action != "login" && (
// profile edit input forms
<>
<div className="form-group">
<div className="form-control">
<label htmlFor="username">Name</label>
<input
onChange={(e) => updateField({ username: e.target.value })}
value={profileData?.username}
id="username"
name="username"
type="text"
className="form-input"
required
/>
{errors?.username ? <em className="text-red-600">{errors.username}</em> : null}
</div>
<div className="form-control">
<label htmlFor="twitterUsername">Twitter username</label>
<input
onChange={(e) => updateField({ twitterUsername: e.target.value })}
value={profileData?.twitterUsername}
id="twitterUsername"
name="twitterUsername"
type="text"
className="form-input"
placeholder="Without the @"
/>
</div>
</div>
<div className="form-control">
<label htmlFor="bio">Bio</label>
<textarea
onChange={(e) => updateField({ bio: e.target.value })}
value={profileData?.bio}
name="bio"
id="bio"
cols={30}
rows={3}
className="form-textarea"
></textarea>
</div>
<div className="form-group">
<div className="form-control">
<label htmlFor="job-title">Job title</label>
<input
onChange={(e) => updateField({ title: e.target.value })}
value={profileData?.title}
id="job-title"
name="job-title"
type="text"
className="form-input"
/>
{errors?.title ? <em className="text-red-600">{errors.title}</em> : null}
</div>
<div className="form-control">
<label htmlFor="website">Website link</label>
<input
onChange={(e) => updateField({ websiteUrl: e.target.value })}
value={profileData?.websiteUrl}
id="website"
name="website"
type="url"
className="form-input"
/>
</div>
</div>
</>
)}
{action != "edit" && (
// user auth input forms
<>
<div className="form-control">
<label htmlFor="job-title">Email</label>
<input
onChange={(e) => setAuthData((data) => ({ ...data, email: e.target.value }))}
value={authData.email}
id="email"
name="email"
type="email"
className="form-input"
required
/>
{errors?.email ? <em className="text-red-600">{errors.email}</em> : null}
</div>
<div className="form-control">
<label htmlFor="job-title">Password</label>
<input
onChange={(e) => setAuthData((data) => ({ ...data, password: e.target.value }))}
value={authData.password}
id="password"
name="password"
type="password"
className="form-input"
/>
{errors?.password ? <em className="text-red-600">{errors.password}</em> : null}
</div>
{errors?.ValidationError ? <em className="text-red-600">{errors.ValidationError}</em> : null}
{errors?.ApplicationError ? <em className="text-red-600">{errors.ApplicationError}</em> : null}
</>
)}
<div className="action-cont mt-4">
<button className="cta"> {transition.state == "submitting" ? "Submitting" : "Submit"} </button>
</div>
</div>
</fieldset>
</Form>
);
};
export default ProfileForm;
In this component, we have the following props:
profile
- Contains user profile information to fill in the form with.onModifyData
- Pass modified data to the parent depending on theaction
type.action
- determine the action of the formerrors
- errors passed to the form from the parent (after the form has been submitted)
Next, we initialize and assign useTransition()
to transition
that we’ll use to get the state of the form when it’s submitted. We also set up states - profileData
and authData
which we use useEffect()
to pass the state value to the parent component.
Finally, we return the template for the component and conditionally render the authentication input fields and other profile fields depending on the action type, as explained earlier.
Now that we have our form component ready, let’s start with building out the login functionality.
Create Sign-in Function
We’ll start by creating a function called signIn
which will POST the auth details to the Strapi authentication endpoint. In ./app/models/profiles.server.ts
, create a new function: signIn()
// ./app/models/profiles.server.ts
// import types
import { LoginActionData, LoginResponse, Profile, ProfileData } from "~/utils/types"
// ...
// function to sign in
export const signIn = async (data: LoginActionData): Promise<LoginResponse> => {
// make POST request to Strapi Auth URL
const profile = await fetch(`${strapiApiUrl}/auth/local`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(data)
})
let response = await profile.json()
// return login response
return response
}
This function sends a login request and returns the user data if the details sent in the body
match. The next thing we need to do is save the user data to the session.
Save User Session with CreateUserSession
In app/utils/session.server.ts
, we’ll write a createUserSession
function that accepts a user ID and a route to redirect to. It should do the following:
- create a new session (via the cookie storage
getSession
function) - set the
userId
field on the session - redirect to the given route setting the
Set-Cookie
header (via the cookie storagecommitSession
function)
To do this, create a new file: ./app/utils/session.server.ts
// ./app/utils/session.server.ts
import { createCookieSessionStorage, redirect } from "@remix-run/node";
import { LoginResponse } from "./types";
// initialize createCookieSession
const { getSession, commitSession, destroySession } = createCookieSessionStorage({
cookie: {
name: "userSession",
// 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",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 30,
httpOnly: true,
}
})
// fucntion to save user data to session
export const createUserSession = async (userData: LoginResponse, redirectTo: string) => {
const session = await getSession()
session.set("userData", userData);
console.log({ session });
return redirect(redirectTo, {
headers: {
"Set-Cookie": await commitSession(session)
}
})
}
Great. Now, we can create our login page and use our <ProfileForm>
component.
Create a new file ./app/routes/sign-in.tsx
:
// ./app/routes/sign-in.tsx
import { ActionFunction, json, redirect } from "@remix-run/node";
import { useActionData } from "@remix-run/react";
import ProfileForm from "~/components/ProfileForm";
import { signIn } from "~/models/profiles.server";
import { createUserSession } from "~/utils/session.server";
import { LoginErrorResponse, LoginActionData } from "~/utils/types";
export const action: ActionFunction = async ({ request }) => {
try {
// get request form data
const formData = await request.formData();
// get form values
const identifier = formData.get("email");
const password = formData.get("password");
// error object
// each error property is assigned null if it has a value
const errors: LoginActionData = {
identifier: identifier ? null : "Email is required",
password: password ? null : "Password is required",
};
// return true if any property in the error object has a value
const hasErrors = Object.values(errors).some((errorMessage) => errorMessage);
// throw the errors object if any error
if (hasErrors) throw errors;
// sign in user with identifier and password
let { jwt, user, error } = await signIn({ identifier, password });
// throw strapi error message if strapi returns an error
if (error) throw { [error.name]: error.message };
// create user session
return createUserSession({ jwt, user }, "/");
} catch (error) {
// return error response
return json<LoginErrorResponse>(error);
}
};
const Login = () => {
const errors = useActionData();
return (
<section className="site-section profiles-section">
<div className="wrapper">
<header className="section-header">
<h2 className="text-4xl">Sign in </h2>
<p>You have to log in to edit your profile</p>
</header>
{/* set form action to `login` and pass errors if any */}
<ProfileForm action="login" errors={errors} />
</div>
</section>
);
};
export default Login;
Here, we have a action
function which gets the identifier
and password value using formData
after the form is submitted and passes the values to signIn()
. If there are no errors, the action
function creates a session with the user data by returning createUserSession()
.
If there are errors, we throw
the error and return it in the catch
block. The errors are then automatically displayed on the form since we pass it as props to <ProfileForm>
.
Now, if we sign in using the email and password of the users we created earlier in Strapi, the login request will be sent and if successful, the session will be created. You can view the cookies in the application tab of devtools.
Now, all requests made will contain the cookies in the Headers
:
Also, the <ProfileForm>
components can handle errors passed to it. This shows a ValidationError
returned by Strapi when the user inputs an incorrect password.
Awesome. Now, we need to get the user data from the session so the user knows their signed in.
Get User Data from Cookies Session
To get the user information from the session, we’ll create a few more functions: getUserSession(request)
, getUserData(request)
and logout()
in ./app/utils/session.server.ts
.
// ./app/utils/session.server.ts
// ...
// get cookies from request
const getUserSession = (request: Request) => {
return getSession(request.headers.get("Cookie"))
}
// function to get user data from session
export const getUserData = async (request: Request): Promise<LoginResponse | null> => {
const session = await getUserSession(request)
const userData = session.get("userData")
console.log({userData});
if(!userData) return null
return userData
}
// function to remove user data from session, logging user out
export const logout = async (request: Request) => {
const session = await getUserSession(request);
return redirect("/sign-in", {
headers: {
"Set-Cookie": await destroySession(session)
}
})
}
What we need to do know is to let the user know that they are signed in by showing the user name and hiding the “login” and “register” links in the site header. To do that, we’ll create a loader function in ./app/root.jsx
to get the user data from the session and pass it to the <SiteHeader>
component.
// ./app/root.jsx
// ...
import { getUserData } from "./utils/session.server";
type LoaderData = {
userData: Awaited<ReturnType<typeof getUserData>>;
};
// loader function to get and return userdata
export const loader: LoaderFunction = async ({ request }) => {
return json<LoaderData>({
userData: await getUserData(request),
});
};
export default function App() {
const { userData } = useLoaderData() as LoaderData;
return (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
<main className="site-main">
{/* place site header above app outlet, pass user data as props */}
<SiteHeader user={userData?.user} />
{/* ... */}
</main>
</body>
</html>
);
}
Remember that the component conditionally displays the “Sign In”, “Register”, and “Sign out” links depending on the user data passed to the component. Now that we’ve passed the user data, we should get something like this:
Build the Log-Out Functionality
First thing we’ll do is modify our <SiteHeader>
component in ./app/components/SiteHeader.tsx
. We’ll replace the Sign out
link with a <Form>
like this:
// ./app/components/SiteHeader.tsx
// import Remix's link component
import { Form, Link, useTransition } from "@remix-run/react";
// import type definitions
import { Profile } from "~/utils/types";
// component accepts `user` prop to determine if user is logged in
const SiteHeader = ({user} : {user?: Profile | undefined}) => {
const transition = useTransition()
return (
<header className="site-header">
<div className="wrapper">
<figure className="site-logo"><Link to="/"><h1>Profiles</h1></Link></figure>
<nav className="site-nav">
<ul className="links">
{/* show sign out link if user is logged in */}
{user?.id ?
<>
{/* link to user profile */}
<li>
<Link to={`/${user?.slug}`}> Hey, {user?.username}! </Link>
</li>
{/* Form component to send POST request to the sign out route */}
<Form action="/sign-out" method="post" className="link">
<button type="submit" disabled={transition.state != "idle"} >
{transition.state == "idle" ? "Sign Out" : "Loading..."}
</button>
</Form>
</> :
<>
{/* show sign in and register link if user is not logged in */}
{/* ... */}
</>
}
</ul>
</nav>
</div>
</header>
);
};
export default SiteHeader;
Then, we’ll create a ./app/routes/sign-out.tsx
route and enter the following code:
// ./app/routes/sign-out.tsx
import { ActionFunction, LoaderFunction, redirect } from "@remix-run/node";
import { logout } from "~/utils/session.server";
// action to get the /sign-out request action from the sign out form
export const action: ActionFunction = async ({ request }) => {
return logout(request);
};
// loader to redirect to "/"
export const loader: LoaderFunction = async () => {
return redirect("/");
};
Now, if we click on the sign out button. It submits the form with action=``"``/sign-out``"
, which is handled by the action
function in ./app/routes/sign-out.tsx
. Then, the loader in the sign-out page redirects the user to “/” by default when the user visits that route.
Now, let’s work on user registration.
User Registration
This is very similar to what we did for login. First, we create the register()
function in ./app/models/profiles.server.ts
:
// ./app/models/profiles.server.ts
// import types
import slugify from "~/utils/slugify"
import { LoginActionData, LoginResponse, Profile, ProfileData, RegisterActionData } from "~/utils/types"
// ...
// function to register user
export const register = async (data: RegisterActionData): Promise<LoginResponse> => {
// generate slug from username
let slug = slugify(data.username?.toString())
data.slug = slug
// make POST request to Strapi Register Auth URL
const profile = await fetch(`${strapiApiUrl}/auth/local/register`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(data)
})
// get response from request
let response = await profile.json()
// return register response
return response
}
Now, create a new file ./app/routes/register.jsx
for the /register
route:
// ./app/routes/register.tsx
import { ActionFunction, json } from "@remix-run/node";
import { useActionData } from "@remix-run/react";
import ProfileForm from "~/components/ProfileForm";
import { register } from "~/models/profiles.server";
import { createUserSession } from "~/utils/session.server";
import { ErrorResponse, RegisterActionData } from "~/utils/types";
export const action: ActionFunction = async ({ request }) => {
try {
// get request form data
const formData = await request.formData();
// get form input values
const email = formData.get("email");
const password = formData.get("password");
const username = formData.get("username");
const title = formData.get("job-title");
const twitterUsername = formData.get("twitterUsername");
const bio = formData.get("bio");
const websiteUrl = formData.get("website");
const errors: RegisterActionData = {
email: email ? null : "Email is required",
password: password ? null : "Password is required",
username: username ? null : "Username is required",
title: title ? null : "Job title is required",
};
const hasErrors = Object.values(errors).some((errorMessage) => errorMessage);
if (hasErrors) throw errors;
console.log({ email, password, username, title, twitterUsername, bio, websiteUrl });
// function to register user with user details
const { jwt, user, error } = await register({ email, password, username, title, twitterUsername, bio, websiteUrl });
console.log({ jwt, user, error });
// throw strapi error message if strapi returns an error
if (error) throw { [error.name]: error.message };
// create user session
return createUserSession({ jwt, user }, "/");
} catch (error) {
// return error response
return json(error);
}
};
const Register = () => {
const errors = useActionData();
console.log({ errors });
return (
<section className="site-section profiles-section">
<div className="wrapper">
<header className="section-header">
<h2 className="text-4xl">Register</h2>
<p>Create a new profile</p>
</header>
{/* set form action to `login` and pass errors if any */}
<ProfileForm action="create" errors={errors} />
</div>
</section>
);
};
export default Register;
Here’s what we should have now:
Now, that we can register users and login, let’s allow users to edit their profiles once logged in.
Add Reset Password Functionality
We need to configure Strapi. Let’s install nodemailer
to send emails to users. Go to back to the Strapi project folder, stop the server and install the Strapi Nodemailer provider:
npm install @strapi/provider-email-nodemailer --save
Now, create a new file ./config/plugins.js
module.exports = ({ env }) => ({
email: {
config: {
provider: 'nodemailer',
providerOptions: {
host: env('SMTP_HOST', 'smtp.gmail.com'),
port: env('SMTP_PORT', 465),
auth: {
user: env('GMAIL_USER'),
pass: env('GMAIL_PASSWORD'),
},
// ... any custom nodemailer options
},
settings: {
defaultFrom: 'threepointo.dev@gmail.com',
defaultReplyTo: 'threepointo.dev@gmail.com',
},
},
},
});
I’ll be using Gmail for this example; you can use any email provider of your choice. You can find instructions on the Strapi Documentattion.
Add the environment variables in the ./.env
file:
SMTP_HOST=smtp.gmail.com
SMTP_PORT=465
GMAIL_USER=threepointo.dev@gmail.com
GMAIL_PASSWORD=<generated-pass>
You can find out more on how to generate Gmail passwords that work with Nodemailer.
Start the server:
yarn develop
In the Strapi admin dashboard, navigate to SETTINGS > USERS & PERMISSIONS PLUGIN > ROLES > PUBLIC > USERS-PERMISSIONS, enable the forgotPassword
and resetPassword
actions.
We can also modify the email template for reset password in Strapi. Navigate to:
Next, we’ll head back to our Remix project and add new functions for forgot and reset password.
Add 'Forgot Password' Functionality
Create a new function sendResetMail
in ./app/models/profiles.server.ts
:
// ./app/models/profiles.server.ts
// ...
// function to send password reset email
export const sendResetMail = async (email: string | File | null | undefined) => {
const response = await (await fetch(`${strapiApiUrl}/auth/forgot-password`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email })
})).json()
return response
}
Now, create a forgot password page, create a new file ./app/routes/forgot-password
:
import { ActionFunction, json } from "@remix-run/node";
import { Form, useActionData, useTransition } from "@remix-run/react";
import { sendResetMail } from "~/models/profiles.server";
// action function to get form values and run reset mail function
export const action: ActionFunction = async ({ request }) => {
const formData = await request.formData();
const email = formData.get("email");
const response = await sendResetMail(email);
return json(response);
};
const ForgotPass = () => {
const transition = useTransition();
const data = useActionData();
return (
<section className="site-section profiles-section">
<div className="wrapper">
<header className="section-header">
<h2 className="text-4xl">Forgot password</h2>
<p>Click the button below to send the reset link to your registerd email</p>
</header>
<Form method="post" className="form">
<div className="wrapper">
<p>{data?.ok ? "Link sent! Check your mail. Can't find it in the inbox? Check Spam" : ""}</p>
<div className="form-control">
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" className="form-input" required />
</div>
<div className="action-cont mt-4">
<button className="cta"> {transition.state == "submitting" ? "Sending" : "Send link"} </button>
</div>
</div>
</Form>
</div>
</section>
);
};
export default ForgotPass;
Here’s what the page looks like:
Add 'Reset Password' Functionality
First, create a new resetPass
function in ./app/models/profiles.session.ts
// ./app/models/profiles.server.ts
// ...
// function to reset password
export const resetPass = async ({ password, passwordConfirmation, code }: { password: File | string | null | undefined, passwordConfirmation: File | string | null | undefined, code: File | string | null | undefined }) => {
const response = await (await fetch(`${strapiApiUrl}/auth/reset-password`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
password,
passwordConfirmation,
code
})
})).json()
return response
}
This function sends a request to /api/auth/reset-password
with the password, confirmation and code
which is sent to the user’s mail. Create a new reset password page to send the request with the password and code, ./app/routes/reset-password.tsx
// ./app/routes/reset-password.tsx
import { ActionFunction, json, LoaderFunction, redirect } from "@remix-run/node";
import { Form, useActionData, useLoaderData, useTransition } from "@remix-run/react";
import { resetPass } from "~/models/profiles.server";
type LoaderData = {
code: string | undefined;
};
// get code from URL parameters
export const loader: LoaderFunction = async ({ request }) => {
const url = new URL(request.url);
const code = url.searchParams.get("code");
// take user to homepage if there's no code in the url
if (!code) return redirect("/");
return json<LoaderData>({
code: code,
});
};
// get password and code and send reset password request
export const action: ActionFunction = async ({ request }) => {
const formData = await request.formData();
const code = formData.get("code");
const password = formData.get("password");
const passwordConfirmation = formData.get("confirmPassword");
const response = await resetPass({ password, passwordConfirmation, code });
// return error is passwords don't match
if (password != passwordConfirmation) return json({ confirmPassword: "Passwords should match" });
return json(response);
};
const ResetPass = () => {
const transition = useTransition();
const error = useActionData();
const { code } = useLoaderData() as LoaderData;
return (
<section className="site-section profiles-section">
<div className="wrapper">
<header className="section-header">
<h2 className="text-4xl">Reset password</h2>
<p>Enter your new password</p>
</header>
<Form method="post" className="form">
<input value={code} type="hidden" id="code" name="code" required />
<div className="wrapper">
<div className="form-control">
<label htmlFor="job-title">Password</label>
<input id="password" name="password" type="password" className="form-input" required />
</div>
<div className="form-control">
<label htmlFor="job-title">Confirm password</label>
<input id="confirmPassword" name="confirmPassword" type="password" className="form-input" required />
{error?.confirmPassword ? <em className="text-red-600">{error.confirmPassword}</em> : null}
</div>
<div className="action-cont mt-4">
<button className="cta"> {transition.state == "submitting" ? "Sending" : "Reset password"} </button>
</div>
</div>
</Form>
</div>
</section>
);
};
export default ResetPass;
See it in action:
Step 4: Add 'Edit Profile' Functionality for Authenticated Users
First, we create a new updateProfile()
function which accepts the user input and JWT token
as arguments. Back in ./app/models/profiles.server.ts
add the updateProfile()
function:
// ./app/models/profiles.server.ts
// import types
import slugify from "~/utils/slugify"
import { LoginActionData, LoginResponse, Profile, ProfileData, RegisterActionData } from "~/utils/types"
// ...
// function to update a profile
export const updateProfile = async (data: ProfileData, token: string | undefined): Promise<Profile> => {
// get id from data
const { id } = data
// PUT request to update data
const profile = await fetch(`${strapiApiUrl}/users/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
// set the auth token to the user's jwt
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(data)
})
let response = await profile.json()
return response
}
Here, we send a request to update the user data with the Authorization
set in the headers
. We’ll pass the token
to the updateProfile
function which will be obtained from the user session.
Back in our ./app/routes/$slug.tsx
page, we need an action to call this function and pass the necessary arguments. We’ll add our <ProfileForm>
component and set the action to "``edit``"
. This form will only be rendered if the signed in user data is the same as the user data on the current profile route. We’ll also show the edit button and the <ProfileForm>
if the profile id is equal to the signed in user and add an action
function to handle the form submission and validation.
// ./app/routes/$slug.tsx
import { json, LoaderFunction, ActionFunction, redirect } from "@remix-run/node";
import { useLoaderData, useActionData } from "@remix-run/react";
import { useEffect, useState } from "react";
import { updateProfile } from "~/models/profiles.server";
import { getProfileBySlug } from "~/models/profiles.server";
import { getUserData } from "~/utils/session.server";
import { Profile } from "~/utils/types";
import ProfileCard from "~/components/ProfileCard";
import ProfileForm from "~/components/ProfileForm";
// type definition of Loader data
type Loaderdata = {
userData: Awaited<ReturnType<typeof getUserData>>;
profile: Awaited<ReturnType<typeof getProfileBySlug>>;
};
// action data type
type EditActionData =
| {
id: string | null;
username: string | null;
title: string | null;
}
| undefined;
// loader function to get posts by slug
export const loader: LoaderFunction = async ({ params, request }) => {
return json<Loaderdata>({
userData: await getUserData(request),
profile: await getProfileBySlug(params.slug),
});
};
// action to handle form submission
export const action: ActionFunction = async ({ request }) => {
// get user data
const data = await getUserData(request)
// get request form data
const formData = await request.formData();
// get form values
const id = formData.get("id");
const username = formData.get("username");
const twitterUsername = formData.get("twitterUsername");
const bio = formData.get("bio");
const title = formData.get("job-title");
const websiteUrl = formData.get("website");
// error object
// each error property is assigned null if it has a value
const errors: EditActionData = {
id: id ? null : "Id is required",
username: username ? null : "username is required",
title: title ? null : "title is required",
};
// return true if any property in the error object has a value
const hasErrors = Object.values(errors).some((errorMessage) => errorMessage);
// return the error object
if (hasErrors) return json<EditActionData>(errors);
// run the update profile function
// pass the user jwt to the function
await updateProfile({ id, username, twitterUsername, bio, title, websiteUrl }, data?.jwt);
// redirect users to home page
return null;
};
const Profile = () => {
const { profile, userData } = useLoaderData() as Loaderdata;
const errors = useActionData();
const [profileData, setprofileData] = useState(profile);
const [isEditing, setIsEditing] = useState(false);
console.log({ userData, profile });
return (
<section className="site-section">
<div className="wrapper flex items-center py-16 min-h-[calc(100vh-4rem)]">
<div className="profile-cont w-full max-w-5xl m-auto">
{profileData ? (
<>
{/* Profile card with `preview` = true */}
<ProfileCard profile={profileData} preview={true} />
{/* list of actions */}
<ul className="actions">
<li className="action">
<button className="cta w-icon">
<svg xmlns="http://www.w3.org/2000/svg" className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
/>
</svg>
<span>Share</span>
</button>
</li>
{userData?.user?.id == profile.id && (
<li className="action">
<button onClick={() => setIsEditing(!isEditing)} className="cta w-icon">
{!isEditing ? (
<svg xmlns="http://www.w3.org/2000/svg" className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
<span>{!isEditing ? "Edit" : "Cancel"}</span>
</button>
</li>
)}
</ul>
</>
) : (
<p className="text-center">Oops, that profile doesn't exist... yet</p>
)}
{/* display dynamic form component when user clicks on edit */}
{userData?.user?.id == profile?.id && isEditing && (
<ProfileForm errors={errors} profile={profile} action={"edit"} onModifyData={(value: Profile) => setprofileData(value)} />
)}
</div>
</div>
</section>
);
};
export default Profile;
Now, when we’re logged in as a particular user, we can edit that user data as shown here:
Conclusion
So far, we have seen how we can build a Remix application with authentication using Strapi as a Headless CMS. Let’s summarize what we’ve been able to achieve so far.
- We created and set up Strapi, configured the User collection type, and modified permissions for public and authenticated users.
- We created a new Remix application with Tailwind for styling.
- In Strapi, we used the local provider (email and password) for authentication.
- In Remix, we used cookies to store the user data and JWT, allowing us to make authenticated requests to Strapi.
- We added forgot-password and reset-password functionalities by configuring Strapi Email plugin.
I’m sure you’ve been able to pick up one or two new things from this tutorial. If you’re stuck somewhere, the Strapi and Remix application source code are available on GitHub and listed in the resources section.
Resources
Here are a few articles I think will be helpful:
- Building a Jokes app with Remix (Authentication)
- Build a simple blog with Remix
- Implementing authentication in Remix applications with Supabase
- Authentication, Roles and Permissions in Strapi
- Registration and Login in Strapi with VueJS
As promised, the code for the Remix frontend and Strapi backend is available on GitHub:
Also, here’s the live example hosted on Netlify.
Happy Coding!