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 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.
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.
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.
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.
At the end of this tutorial, we would have covered how to add authentication to our Remix application with Strapi.
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
To kick off the creation process, we'll begin by setting up the backend with Strapi.
yarn create strapi-app profile-api
#or
npx create-strapi-app@latest profile-api
Quickstart
uses the default database SQLite and is recommended. Once the installation is complete, the Strapi admin dashboard should automatically open in your browser. 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 to Cyan
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.
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, 1. Navigate to SETTINGS > USERS & PERMISSIONS PLUGIN > ROLES. 2. Go to PUBLIC and enable the following actions for the following under Users-permissions.
Count
find
findOne
We should have something like this:
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:
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.
1? Where would you like to create your app? remix-profiles
2? What type of app do you want to create? Just the basics
3? Where do you want to deploy? Choose Remix App Server if you're unsure; it's easy to change deployment targets. Remix App Server
4? TypeScript or JavaScript? TypeScript
5? 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
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
:
1 // ./tailwind.config.js
2
3 module.exports = {
4 content: [
5 "./app/**/*.{js,ts,jsx,tsx}",
6 ],
7 theme: {
8 extend: {},
9 },
10 corePlugins: {
11 aspectRatio: false,
12 },
13 plugins: [
14 require('@tailwindcss/forms'),
15 require('@tailwindcss/aspect-ratio')
16 ],
17 }
Now, we have to update the scripts in our package.json
file to build both the development and production CSS.
1 // ./package.json
2
3 {
4 "scripts": {
5 "build": "npm run build:css && remix build",
6 "build:css": "tailwindcss -m -i ./styles/app.css -o app/styles/app.css",
7 "dev": "concurrently \"npm run dev:css\" \"remix dev\"",
8 "dev:css": "tailwindcss -w -i ./styles/app.css -o app/styles/app.css",
9 }
10 }
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
:
1 // ./styles/app.css/
2
3 @tailwind base;
4 @tailwind components;
5 @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:
1 // ./app/root.tsx
2
3 import type { MetaFunction, LinksFunction } from "@remix-run/node";
4
5 // import tatilwind styles
6 import styles from "./styles/app.css"
7 import {
8 Links,
9 LiveReload,
10 Meta,
11 Outlet,
12 Scripts,
13 ScrollRestoration,
14 } from "@remix-run/react";
15 export const meta: MetaFunction = () => ({
16 charset: "utf-8",
17 title: "New Remix App",
18 viewport: "width=device-width,initial-scale=1",
19 });
20 export const links: LinksFunction = () => {
21 return [{ rel: "stylesheet", href: styles }];
22 };
23
24 export default function App() {
25 return (
26 <html lang="en">
27 <head>
28 <Meta />
29 <Links />
30 </head>
31 <body>
32 <Outlet />
33 <ScrollRestoration />
34 <Scripts />
35 <LiveReload />
36 </body>
37 </html>
38 );
39 }
Awesome!
Let’s take a quick look at our project structure at this point. It should look something like this:
1remix-profiles
2├─ .eslintrc
3├─ .gitignore
4├─ app
5│ ├─ entry.client.tsx
6│ ├─ entry.server.tsx
7│ ├─ root.tsx
8│ └─ routes
9│ └─ index.tsx
10├─ package-lock.json
11├─ package.json
12├─ public
13│ └─ favicon.ico
14├─ README.md
15├─ remix.config.js
16├─ styles
17│ └─ app.css
18├─ tailwind.config.js
19└─ 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:
./styles/app.css
file (not compiled) which you can access in the project's GitHub repository.
./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.
Create an ./.env
file in the root of the project and add the following:
1STRAPI_API_URL="http://localhost:1337/api"
2STRAPI_URL="http://localhost:1337"
Let’s add a nice and simple header with basic navigation to our application.
1. Create a new file in ./app/components/SiteHeader.tsx
1 // ./app/components/SiteHeader.tsx
2
3 // import Remix's link component
4 import { Link } from "@remix-run/react";
5
6 // import type definitions
7 import { Profile } from "~/utils/types";
8
9 // component accepts `user` prop to determine if user is logged in
10 const SiteHeader = ({user} : {user?: Profile | undefined}) => {
11 return (
12 <header className="site-header">
13 <div className="wrapper">
14 <figure className="site-logo"><Link to="/"><h1>Profiles</h1></Link></figure>
15 <nav className="site-nav">
16 <ul className="links">
17 {/* show sign out link if user is logged in */}
18 {user?.id ?
19 <>
20 {/* link to user profile */}
21 <li>
22 <Link to={`/${user?.slug}`}> Hey, {user?.username}! </Link>
23 </li>
24 <li className="link"><Link to="/sign-out">Sign out</Link></li>
25 </> :
26 <>
27 {/* show sign in and register link if user is not logged in */}
28 <li className="link"><Link to="/sign-in">Sign In</Link></li>
29 <li className="link"><Link to="/register">Register</Link></li>
30 </>
31 }
32 </ul>
33 </nav>
34 </div>
35 </header>
36 );
37 };
38 export default SiteHeader;
./app/routes/root.tsx
file:1 // ./app/root.jsx
2 import type { MetaFunction, LinksFunction } from "@remix-run/node";
3
4 // import compiled styles
5 import styles from "./styles/app.css";
6 import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react";
7
8 // import site header component
9 import SiteHeader from "./components/SiteHeader";
10
11 // add site meta
12 export const meta: MetaFunction = () => ({
13 charset: "utf-8",
14 title: "Profiles | Find & connect with people",
15 viewport: "width=device-width,initial-scale=1",
16 });
17
18 // add links to site head
19 export const links: LinksFunction = () => {
20 return [{ rel: "stylesheet", href: styles }];
21 };
22
23 export default function App() {
24 return (
25 <html lang="en">
26 <head>
27 <Meta />
28 <Links />
29 </head>
30 <body>
31 <main className="site-main">
32 {/* place site header above app outlet */}
33 <SiteHeader />
34 <Outlet />
35 <ScrollRestoration />
36 <Scripts />
37 <LiveReload />
38 </main>
39 </body>
40 </html>
41 );
42 }
We’ll create a ProfileCard
component that will be used to display the user information. Create a new file ./app/components/ProfileCard.tsx
:
1 // ./app/components/ProfileCard.tsx
2
3 import { Link } from "@remix-run/react";
4
5 // type definitions for Profile response
6 import { Profile } from "~/utils/types";
7
8 // strapi url from environment variables
9 const strapiUrl = `http://localhost:1337`;
10
11 // helper function to get image url for user
12 // we're also using https://ui-avatars.com api to generate images
13 // the function appends the image url returned
14 const getImgUrl = ({ url, username }: { url: string | undefined; username: string | "A+N" }) =>
15 url ? `${strapiUrl}${url}` : `https://ui-avatars.com/api/?name=${username?.replace(" ", "+")}&background=2563eb&color=fff`;
16
17 // component accepts `profile` prop which contains the user profile data and
18 // `preview` prop which indicates whether the card is used in a list or
19 // on its own in a dynamic page
20 const ProfileCard = ({ profile, preview }: { profile: Profile; preview: boolean }) => {
21 return (
22 <>
23 {/* add the .preview class if `preview` == true */}
24 <article className={`profile ${preview ? "preview" : ""}`}>
25 <div className="wrapper">
26 <div className="profile-pic-cont">
27 <figure className="profile-pic img-cont">
28 <img
29 src={getImgUrl({ url: profile.profilePic?.formats.small.url, username: profile.username })}
30 alt={`A photo of ${profile.username}`}
31 className="w-full"
32 />
33 </figure>
34 </div>
35 <div className="profile-content">
36 <header className="profile-header ">
37 <h3 className="username">{profile.username}</h3>
38 {/* show twitter name if it exists */}
39 {profile.twitterUsername && (
40 <a href="https://twitter.com/miracleio" className="twitter link">
41 @{profile.twitterUsername}
42 </a>
43 )}
44 {/* show bio if it exists */}
45 {profile.bio && <p className="bio">{profile.bio}</p>}
46 </header>
47 <ul className="links">
48 {/* show title if it exists */}
49 {profile.title && (
50 <li className="w-icon">
51 <svg className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
52 <path
53 strokeLinecap="round"
54 strokeLinejoin="round"
55 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"
56 />
57 </svg>
58 <span> {profile.title} </span>
59 </li>
60 )}
61 {/* show website url if it exists */}
62 {profile.websiteUrl && (
63 <li className="w-icon">
64 <svg className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
65 <path
66 strokeLinecap="round"
67 strokeLinejoin="round"
68 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"
69 />
70 </svg>
71 <a href="http://miracleio.me" target="_blank" rel="noopener noreferrer" className="link">
72 {profile.websiteUrl}
73 </a>
74 </li>
75 )}
76 </ul>
77 {/* hide footer in preview mode */}
78 {!preview && (
79 <footer className="grow flex items-end justify-end pt-4">
80 {/* hide link if no slug is present for the user */}
81 {profile?.slug && (
82 <Link to={profile?.slug}>
83 <button className="cta w-icon">
84 <span>View profile</span>
85 <svg xmlns="http://www.w3.org/2000/svg" className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
86 <path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
87 </svg>
88 </button>
89 </Link>
90 )}
91 </footer>
92 )}
93 </div>
94 </div>
95 </article>
96 </>
97 );
98 };
99 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.
Let’s set up a module that exports a getProfiles
and getProfileBySlug
function. Create ./app/models/profiles.server.ts
:
1 // ./app/models/profiles.server.tsx
2
3 // import types
4 import { Profile, ProfileData } from "~/utils/types"
5
6 // Strapi API URL from environment varaibles
7 const strapiApiUrl = process.env.STRAPI_API_URL
8
9
10 // function to fetch all profiles
11 export const getProfiles = async (): Promise<Array<Profile>> => {
12 const profiles = await fetch(`${strapiApiUrl}/users/?populate=profilePic`)
13 let response = await profiles.json()
14
15 return response
16 }
17
18 // function to get a single profile by it's slug
19 export const getProfileBySlug = async (slug: string | undefined): Promise<Profile> => {
20 const profile = await fetch(`${strapiApiUrl}/users?populate=profilePic&filters[slug]=${slug}`)
21 let response = await profile.json()
22
23 // since the request is a filter, it returns an array
24 // here we return the first itm in the array
25 // since the slug is unique, it'll only return one item
26 return response[0]
27 }
Now, on our index page, ./app/routes/index.tsx
, we’ll add the following:
1 import { json } from "@remix-run/node";
2 import { useLoaderData } from "@remix-run/react";
3
4 // import profile card component
5 import ProfileCard from "~/components/ProfileCard";
6
7 // import get profiles function
8 import { getProfiles } from "~/models/profiles.server";
9
10 // loader data type definition
11 type Loaderdata = {
12 // this implies that the "profiles type is whatever type getProfiles resolves to"
13 profiles: Awaited<ReturnType<typeof getProfiles>>;
14 }
15
16 // loader for route
17 export const loader = async () => {
18 return json<Loaderdata>({
19 profiles: await getProfiles(),
20 });
21 };
22
23 export default function Index() {
24 const { profiles } = useLoaderData() as Loaderdata;
25 return (
26 <section className="site-section profiles-section">
27 <div className="wrapper">
28 <header className="section-header">
29 <h2 className="text-4xl">Explore profiles</h2>
30 <p>Find and connect with amazing people all over the world!</p>
31 </header>
32 {profiles.length > 0 ? (
33 <ul className="profiles-list">
34 {profiles.map((profile) => (
35 <li key={profile.id} className="profile-item">
36 <ProfileCard profile={profile} preview={false} />
37 </li>
38 ))}
39 </ul>
40 ) : (
41 <p>No profiles yet 🙂</p>
42 )}{" "}
43 </div>
44 </section>
45 );
46 }
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.
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.
1 // ./app/routes/$slug.tsx
2
3 import { json, LoaderFunction, ActionFunction, redirect } from "@remix-run/node";
4 import { useLoaderData, useActionData } from "@remix-run/react";
5 import { useEffect, useState } from "react";
6 import ProfileCard from "~/components/ProfileCard";
7 import { getProfileBySlug } from "~/models/profiles.server";
8 import { Profile } from "~/utils/types";
9
10 // type definition of Loader data
11 type Loaderdata = {
12 profile: Awaited<ReturnType<typeof getProfileBySlug>>;
13 };
14
15 // loader function to get posts by slug
16 export const loader: LoaderFunction = async ({ params }) => {
17 return json<Loaderdata>({
18 profile: await getProfileBySlug(params.slug),
19 });
20 };
21
22 const Profile = () => {
23 const { profile } = useLoaderData() as Loaderdata;
24 const errors = useActionData();
25 const [profileData, setprofileData] = useState(profile);
26 const [isEditing, setIsEditing] = useState(false);
27
28 return (
29 <section className="site-section">
30 <div className="wrapper flex items-center py-16 min-h-[calc(100vh-4rem)]">
31 <div className="profile-cont w-full max-w-5xl m-auto">
32 {profileData ? (
33 <>
34 {/* Profile card with `preview` = true */}
35 <ProfileCard profile={profileData} preview={true} />
36 {/* list of actions */}
37 <ul className="actions">
38 <li className="action">
39 <button className="cta w-icon">
40 <svg xmlns="http://www.w3.org/2000/svg" className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
41 <path
42 strokeLinecap="round"
43 strokeLinejoin="round"
44 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"
45 />
46 </svg>
47 <span>Share</span>
48 </button>
49 </li>
50 <li className="action">
51 <button onClick={() => setIsEditing(!isEditing)} className="cta w-icon">
52 {!isEditing ? (
53 <svg xmlns="http://www.w3.org/2000/svg" className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
54 <path
55 strokeLinecap="round"
56 strokeLinejoin="round"
57 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"
58 />
59 </svg>
60 ) : (
61 <svg xmlns="http://www.w3.org/2000/svg" className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
62 <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" />
63 </svg>
64 )}
65 <span>{!isEditing ? "Edit" : "Cancel"}</span>
66 </button>
67 </li>
68 </ul>
69 </>
70 ) : (
71 <p className="text-center">Oops, that profile doesn't exist... yet</p>
72 )}
73 </div>
74 </div>
75 </section>
76 );
77 };
78 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.
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>
.
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:
1 <Form>
2 {action != "login" && (
3 <>
4 {/* Profile registeration and update input fields */}
5 </>
6 )}
7 {action != "edit" && (
8 <>
9 {/* User login input fields */}
10 </>
11 )}
12 </Form>
With this we’ll be able to:
"login"
action, display only login input fields like email
and password
"edit"
action, only display ****the profile fields like username
, bio
, website
, etc. "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
:
1 // ./app/components/ProfileForm.tsx
2 import { Form, useTransition } from "@remix-run/react";
3 import { useEffect, useState } from "react";
4 // custom type declarations
5 import { Profile, ProfileFormProps } from "~/utils/types";
6 const ProfileForm = ({ profile, onModifyData, action, errors }: ProfileFormProps) => {
7 // get state of form
8 const transition = useTransition();
9 // state for user profile data
10 const [profileData, setProfileData] = useState(profile);
11 // state for user login information
12 const [authData, setAuthData] = useState({ email: "", password: "" });
13 // helper function to set profile data value
14 const updateField = (field: object) => setProfileData((value) => ({ ...value, ...field }));
15 // listen to changes to the profileData state
16 // run the onModifyData() function passing the profileData to it
17 // this will snd the data to the parent component
18 useEffect(() => {
19 // run function if `onModifyData` is passed to the component
20 if (onModifyData) {
21 // depending on the action passed to the form
22 // select which data to send to parent when modified
23 // when action == create, send both the profile data and auth data
24 if (action == "create") onModifyData({ ...profileData, ...authData });
25 // when action == login, send only auth data
26 else if (action == "login") onModifyData(authData);
27 // send profile data by default (when action == edit)
28 else onModifyData(profileData);
29 }
30 }, [profileData, authData]);
31 return (
32 <Form method={action == "edit" ? "put" : "post"} className="form">
33 <fieldset disabled={transition.state == "submitting"}>
34 <input value={profile?.id} type="hidden" name="id" required />
35 <div className="wrapper">
36 {action != "login" && (
37 // profile edit input forms
38 <>
39 <div className="form-group">
40 <div className="form-control">
41 <label htmlFor="username">Name</label>
42 <input
43 onChange={(e) => updateField({ username: e.target.value })}
44 value={profileData?.username}
45 id="username"
46 name="username"
47 type="text"
48 className="form-input"
49 required
50 />
51 {errors?.username ? <em className="text-red-600">{errors.username}</em> : null}
52 </div>
53 <div className="form-control">
54 <label htmlFor="twitterUsername">Twitter username</label>
55 <input
56 onChange={(e) => updateField({ twitterUsername: e.target.value })}
57 value={profileData?.twitterUsername}
58 id="twitterUsername"
59 name="twitterUsername"
60 type="text"
61 className="form-input"
62 placeholder="Without the @"
63 />
64 </div>
65 </div>
66 <div className="form-control">
67 <label htmlFor="bio">Bio</label>
68 <textarea
69 onChange={(e) => updateField({ bio: e.target.value })}
70 value={profileData?.bio}
71 name="bio"
72 id="bio"
73 cols={30}
74 rows={3}
75 className="form-textarea"
76 ></textarea>
77 </div>
78 <div className="form-group">
79 <div className="form-control">
80 <label htmlFor="job-title">Job title</label>
81 <input
82 onChange={(e) => updateField({ title: e.target.value })}
83 value={profileData?.title}
84 id="job-title"
85 name="job-title"
86 type="text"
87 className="form-input"
88 />
89 {errors?.title ? <em className="text-red-600">{errors.title}</em> : null}
90 </div>
91 <div className="form-control">
92 <label htmlFor="website">Website link</label>
93 <input
94 onChange={(e) => updateField({ websiteUrl: e.target.value })}
95 value={profileData?.websiteUrl}
96 id="website"
97 name="website"
98 type="url"
99 className="form-input"
100 />
101 </div>
102 </div>
103 </>
104 )}
105 {action != "edit" && (
106 // user auth input forms
107 <>
108 <div className="form-control">
109 <label htmlFor="job-title">Email</label>
110 <input
111 onChange={(e) => setAuthData((data) => ({ ...data, email: e.target.value }))}
112 value={authData.email}
113 id="email"
114 name="email"
115 type="email"
116 className="form-input"
117 required
118 />
119 {errors?.email ? <em className="text-red-600">{errors.email}</em> : null}
120 </div>
121 <div className="form-control">
122 <label htmlFor="job-title">Password</label>
123 <input
124 onChange={(e) => setAuthData((data) => ({ ...data, password: e.target.value }))}
125 value={authData.password}
126 id="password"
127 name="password"
128 type="password"
129 className="form-input"
130 />
131 {errors?.password ? <em className="text-red-600">{errors.password}</em> : null}
132 </div>
133 {errors?.ValidationError ? <em className="text-red-600">{errors.ValidationError}</em> : null}
134 {errors?.ApplicationError ? <em className="text-red-600">{errors.ApplicationError}</em> : null}
135 </>
136 )}
137 <div className="action-cont mt-4">
138 <button className="cta"> {transition.state == "submitting" ? "Submitting" : "Submit"} </button>
139 </div>
140 </div>
141 </fieldset>
142 </Form>
143 );
144 };
145 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 the action
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.
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()
1 // ./app/models/profiles.server.ts
2 // import types
3 import { LoginActionData, LoginResponse, Profile, ProfileData } from "~/utils/types"
4
5 // ...
6
7 // function to sign in
8 export const signIn = async (data: LoginActionData): Promise<LoginResponse> => {
9 // make POST request to Strapi Auth URL
10 const profile = await fetch(`${strapiApiUrl}/auth/local`, {
11 method: "POST",
12 headers: {
13 "Content-Type": "application/json"
14 },
15 body: JSON.stringify(data)
16 })
17 let response = await profile.json()
18
19 // return login response
20 return response
21 }
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.
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:
getSession
function)userId
field on the sessionSet-Cookie
header (via the cookie storage commitSession
function)To do this, create a new file: ./app/utils/session.server.ts
1 // ./app/utils/session.server.ts
2
3 import { createCookieSessionStorage, redirect } from "@remix-run/node";
4 import { LoginResponse } from "./types";
5 // initialize createCookieSession
6 const { getSession, commitSession, destroySession } = createCookieSessionStorage({
7 cookie: {
8 name: "userSession",
9 // normally you want this to be `secure: true`
10 // but that doesn't work on localhost for Safari
11 // https://web.dev/when-to-use-local-https/
12 secure: process.env.NODE_ENV === "production",
13 sameSite: "lax",
14 path: "/",
15 maxAge: 60 * 60 * 24 * 30,
16 httpOnly: true,
17 }
18 })
19 // fucntion to save user data to session
20 export const createUserSession = async (userData: LoginResponse, redirectTo: string) => {
21 const session = await getSession()
22 session.set("userData", userData);
23 console.log({ session });
24 return redirect(redirectTo, {
25 headers: {
26 "Set-Cookie": await commitSession(session)
27 }
28 })
29 }
Great. Now, we can create our login page and use our <ProfileForm>
component.
Create a new file ./app/routes/sign-in.tsx
:
1 // ./app/routes/sign-in.tsx
2
3 import { ActionFunction, json, redirect } from "@remix-run/node";
4 import { useActionData } from "@remix-run/react";
5 import ProfileForm from "~/components/ProfileForm";
6 import { signIn } from "~/models/profiles.server";
7 import { createUserSession } from "~/utils/session.server";
8 import { LoginErrorResponse, LoginActionData } from "~/utils/types";
9 export const action: ActionFunction = async ({ request }) => {
10 try {
11 // get request form data
12 const formData = await request.formData();
13 // get form values
14 const identifier = formData.get("email");
15 const password = formData.get("password");
16
17 // error object
18 // each error property is assigned null if it has a value
19 const errors: LoginActionData = {
20 identifier: identifier ? null : "Email is required",
21 password: password ? null : "Password is required",
22 };
23 // return true if any property in the error object has a value
24 const hasErrors = Object.values(errors).some((errorMessage) => errorMessage);
25
26 // throw the errors object if any error
27 if (hasErrors) throw errors;
28
29 // sign in user with identifier and password
30 let { jwt, user, error } = await signIn({ identifier, password });
31
32 // throw strapi error message if strapi returns an error
33 if (error) throw { [error.name]: error.message };
34 // create user session
35 return createUserSession({ jwt, user }, "/");
36 } catch (error) {
37 // return error response
38 return json<LoginErrorResponse>(error);
39 }
40 };
41 const Login = () => {
42 const errors = useActionData();
43 return (
44 <section className="site-section profiles-section">
45 <div className="wrapper">
46 <header className="section-header">
47 <h2 className="text-4xl">Sign in </h2>
48 <p>You have to log in to edit your profile</p>
49 </header>
50 {/* set form action to `login` and pass errors if any */}
51 <ProfileForm action="login" errors={errors} />
52 </div>
53 </section>
54 );
55 };
56 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.
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
.
1 // ./app/utils/session.server.ts
2 // ...
3
4 // get cookies from request
5 const getUserSession = (request: Request) => {
6 return getSession(request.headers.get("Cookie"))
7 }
8 // function to get user data from session
9 export const getUserData = async (request: Request): Promise<LoginResponse | null> => {
10 const session = await getUserSession(request)
11 const userData = session.get("userData")
12 console.log({userData});
13 if(!userData) return null
14 return userData
15 }
16
17 // function to remove user data from session, logging user out
18 export const logout = async (request: Request) => {
19 const session = await getUserSession(request);
20 return redirect("/sign-in", {
21 headers: {
22 "Set-Cookie": await destroySession(session)
23 }
24 })
25 }
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.
1 // ./app/root.jsx
2 // ...
3 import { getUserData } from "./utils/session.server";
4 type LoaderData = {
5 userData: Awaited<ReturnType<typeof getUserData>>;
6 };
7
8 // loader function to get and return userdata
9 export const loader: LoaderFunction = async ({ request }) => {
10 return json<LoaderData>({
11 userData: await getUserData(request),
12 });
13 };
14 export default function App() {
15 const { userData } = useLoaderData() as LoaderData;
16 return (
17 <html lang="en">
18 <head>
19 <Meta />
20 <Links />
21 </head>
22 <body>
23 <main className="site-main">
24 {/* place site header above app outlet, pass user data as props */}
25 <SiteHeader user={userData?.user} />
26 {/* ... */}
27 </main>
28 </body>
29 </html>
30 );
31 }
Remember that the
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:
1 // ./app/components/SiteHeader.tsx
2 // import Remix's link component
3 import { Form, Link, useTransition } from "@remix-run/react";
4 // import type definitions
5 import { Profile } from "~/utils/types";
6 // component accepts `user` prop to determine if user is logged in
7 const SiteHeader = ({user} : {user?: Profile | undefined}) => {
8 const transition = useTransition()
9 return (
10 <header className="site-header">
11 <div className="wrapper">
12 <figure className="site-logo"><Link to="/"><h1>Profiles</h1></Link></figure>
13 <nav className="site-nav">
14 <ul className="links">
15 {/* show sign out link if user is logged in */}
16 {user?.id ?
17 <>
18 {/* link to user profile */}
19 <li>
20 <Link to={`/${user?.slug}`}> Hey, {user?.username}! </Link>
21 </li>
22 {/* Form component to send POST request to the sign out route */}
23 <Form action="/sign-out" method="post" className="link">
24 <button type="submit" disabled={transition.state != "idle"} >
25 {transition.state == "idle" ? "Sign Out" : "Loading..."}
26 </button>
27 </Form>
28 </> :
29 <>
30 {/* show sign in and register link if user is not logged in */}
31 {/* ... */}
32 </>
33 }
34 </ul>
35 </nav>
36 </div>
37 </header>
38 );
39 };
40 export default SiteHeader;
Then, we’ll create a ./app/routes/sign-out.tsx
route and enter the following code:
1 // ./app/routes/sign-out.tsx
2 import { ActionFunction, LoaderFunction, redirect } from "@remix-run/node";
3 import { logout } from "~/utils/session.server";
4
5 // action to get the /sign-out request action from the sign out form
6 export const action: ActionFunction = async ({ request }) => {
7 return logout(request);
8 };
9
10 // loader to redirect to "/"
11 export const loader: LoaderFunction = async () => {
12 return redirect("/");
13 };
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.
This is very similar to what we did for login. First, we create the register()
function in ./app/models/profiles.server.ts
:
1 // ./app/models/profiles.server.ts
2 // import types
3 import slugify from "~/utils/slugify"
4 import { LoginActionData, LoginResponse, Profile, ProfileData, RegisterActionData } from "~/utils/types"
5
6 // ...
7
8 // function to register user
9 export const register = async (data: RegisterActionData): Promise<LoginResponse> => {
10 // generate slug from username
11 let slug = slugify(data.username?.toString())
12 data.slug = slug
13
14 // make POST request to Strapi Register Auth URL
15 const profile = await fetch(`${strapiApiUrl}/auth/local/register`, {
16 method: "POST",
17 headers: {
18 "Content-Type": "application/json"
19 },
20 body: JSON.stringify(data)
21 })
22
23 // get response from request
24 let response = await profile.json()
25
26 // return register response
27 return response
28 }
Now, create a new file ./app/routes/register.jsx
for the /register
route:
1 // ./app/routes/register.tsx
2 import { ActionFunction, json } from "@remix-run/node";
3 import { useActionData } from "@remix-run/react";
4 import ProfileForm from "~/components/ProfileForm";
5 import { register } from "~/models/profiles.server";
6 import { createUserSession } from "~/utils/session.server";
7 import { ErrorResponse, RegisterActionData } from "~/utils/types";
8 export const action: ActionFunction = async ({ request }) => {
9 try {
10 // get request form data
11 const formData = await request.formData();
12 // get form input values
13 const email = formData.get("email");
14 const password = formData.get("password");
15 const username = formData.get("username");
16 const title = formData.get("job-title");
17 const twitterUsername = formData.get("twitterUsername");
18 const bio = formData.get("bio");
19 const websiteUrl = formData.get("website");
20 const errors: RegisterActionData = {
21 email: email ? null : "Email is required",
22 password: password ? null : "Password is required",
23 username: username ? null : "Username is required",
24 title: title ? null : "Job title is required",
25 };
26 const hasErrors = Object.values(errors).some((errorMessage) => errorMessage);
27 if (hasErrors) throw errors;
28 console.log({ email, password, username, title, twitterUsername, bio, websiteUrl });
29 // function to register user with user details
30 const { jwt, user, error } = await register({ email, password, username, title, twitterUsername, bio, websiteUrl });
31 console.log({ jwt, user, error });
32 // throw strapi error message if strapi returns an error
33 if (error) throw { [error.name]: error.message };
34 // create user session
35 return createUserSession({ jwt, user }, "/");
36 } catch (error) {
37 // return error response
38 return json(error);
39 }
40 };
41 const Register = () => {
42 const errors = useActionData();
43 console.log({ errors });
44 return (
45 <section className="site-section profiles-section">
46 <div className="wrapper">
47 <header className="section-header">
48 <h2 className="text-4xl">Register</h2>
49 <p>Create a new profile</p>
50 </header>
51 {/* set form action to `login` and pass errors if any */}
52 <ProfileForm action="create" errors={errors} />
53 </div>
54 </section>
55 );
56 };
57 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.
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
1 module.exports = ({ env }) => ({
2 email: {
3 config: {
4 provider: 'nodemailer',
5 providerOptions: {
6 host: env('SMTP_HOST', 'smtp.gmail.com'),
7 port: env('SMTP_PORT', 465),
8 auth: {
9 user: env('GMAIL_USER'),
10 pass: env('GMAIL_PASSWORD'),
11 },
12 // ... any custom nodemailer options
13 },
14 settings: {
15 defaultFrom: 'threepointo.dev@gmail.com',
16 defaultReplyTo: 'threepointo.dev@gmail.com',
17 },
18 },
19 },
20 });
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:
1SMTP_HOST=smtp.gmail.com
2SMTP_PORT=465
3GMAIL_USER=threepointo.dev@gmail.com
4GMAIL_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.
Create a new function sendResetMail
in ./app/models/profiles.server.ts
:
1 // ./app/models/profiles.server.ts
2 // ...
3
4 // function to send password reset email
5 export const sendResetMail = async (email: string | File | null | undefined) => {
6 const response = await (await fetch(`${strapiApiUrl}/auth/forgot-password`, {
7 method: "POST",
8 headers: {
9 "Content-Type": "application/json",
10 },
11 body: JSON.stringify({ email })
12 })).json()
13 return response
14 }
Now, create a forgot password page, create a new file ./app/routes/forgot-password
:
1 import { ActionFunction, json } from "@remix-run/node";
2 import { Form, useActionData, useTransition } from "@remix-run/react";
3 import { sendResetMail } from "~/models/profiles.server";
4
5 // action function to get form values and run reset mail function
6 export const action: ActionFunction = async ({ request }) => {
7 const formData = await request.formData();
8 const email = formData.get("email");
9 const response = await sendResetMail(email);
10 return json(response);
11 };
12 const ForgotPass = () => {
13 const transition = useTransition();
14 const data = useActionData();
15 return (
16 <section className="site-section profiles-section">
17 <div className="wrapper">
18 <header className="section-header">
19 <h2 className="text-4xl">Forgot password</h2>
20 <p>Click the button below to send the reset link to your registerd email</p>
21 </header>
22 <Form method="post" className="form">
23 <div className="wrapper">
24 <p>{data?.ok ? "Link sent! Check your mail. Can't find it in the inbox? Check Spam" : ""}</p>
25 <div className="form-control">
26 <label htmlFor="email">Email</label>
27 <input id="email" name="email" type="email" className="form-input" required />
28 </div>
29 <div className="action-cont mt-4">
30 <button className="cta"> {transition.state == "submitting" ? "Sending" : "Send link"} </button>
31 </div>
32 </div>
33 </Form>
34 </div>
35 </section>
36 );
37 };
38 export default ForgotPass;
Here’s what the page looks like:
First, create a new resetPass
function in ./app/models/profiles.session.ts
1 // ./app/models/profiles.server.ts
2 // ...
3
4 // function to reset password
5 export const resetPass = async ({ password, passwordConfirmation, code }: { password: File | string | null | undefined, passwordConfirmation: File | string | null | undefined, code: File | string | null | undefined }) => {
6 const response = await (await fetch(`${strapiApiUrl}/auth/reset-password`, {
7 method: "POST",
8 headers: {
9 "Content-Type": "application/json",
10 },
11 body: JSON.stringify({
12 password,
13 passwordConfirmation,
14 code
15 })
16 })).json()
17 return response
18 }
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
1 // ./app/routes/reset-password.tsx
2
3 import { ActionFunction, json, LoaderFunction, redirect } from "@remix-run/node";
4 import { Form, useActionData, useLoaderData, useTransition } from "@remix-run/react";
5 import { resetPass } from "~/models/profiles.server";
6 type LoaderData = {
7 code: string | undefined;
8 };
9 // get code from URL parameters
10 export const loader: LoaderFunction = async ({ request }) => {
11 const url = new URL(request.url);
12 const code = url.searchParams.get("code");
13 // take user to homepage if there's no code in the url
14 if (!code) return redirect("/");
15 return json<LoaderData>({
16 code: code,
17 });
18 };
19 // get password and code and send reset password request
20 export const action: ActionFunction = async ({ request }) => {
21 const formData = await request.formData();
22 const code = formData.get("code");
23 const password = formData.get("password");
24 const passwordConfirmation = formData.get("confirmPassword");
25 const response = await resetPass({ password, passwordConfirmation, code });
26 // return error is passwords don't match
27 if (password != passwordConfirmation) return json({ confirmPassword: "Passwords should match" });
28 return json(response);
29 };
30 const ResetPass = () => {
31 const transition = useTransition();
32 const error = useActionData();
33 const { code } = useLoaderData() as LoaderData;
34 return (
35 <section className="site-section profiles-section">
36 <div className="wrapper">
37 <header className="section-header">
38 <h2 className="text-4xl">Reset password</h2>
39 <p>Enter your new password</p>
40 </header>
41 <Form method="post" className="form">
42 <input value={code} type="hidden" id="code" name="code" required />
43 <div className="wrapper">
44 <div className="form-control">
45 <label htmlFor="job-title">Password</label>
46 <input id="password" name="password" type="password" className="form-input" required />
47 </div>
48 <div className="form-control">
49 <label htmlFor="job-title">Confirm password</label>
50 <input id="confirmPassword" name="confirmPassword" type="password" className="form-input" required />
51 {error?.confirmPassword ? <em className="text-red-600">{error.confirmPassword}</em> : null}
52 </div>
53 <div className="action-cont mt-4">
54 <button className="cta"> {transition.state == "submitting" ? "Sending" : "Reset password"} </button>
55 </div>
56 </div>
57 </Form>
58 </div>
59 </section>
60 );
61 };
62 export default ResetPass;
See it in action:
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:
1 // ./app/models/profiles.server.ts
2 // import types
3 import slugify from "~/utils/slugify"
4 import { LoginActionData, LoginResponse, Profile, ProfileData, RegisterActionData } from "~/utils/types"
5
6 // ...
7
8 // function to update a profile
9 export const updateProfile = async (data: ProfileData, token: string | undefined): Promise<Profile> => {
10 // get id from data
11 const { id } = data
12 // PUT request to update data
13 const profile = await fetch(`${strapiApiUrl}/users/${id}`, {
14 method: "PUT",
15 headers: {
16 "Content-Type": "application/json",
17 // set the auth token to the user's jwt
18 Authorization: `Bearer ${token}`,
19 },
20 body: JSON.stringify(data)
21 })
22 let response = await profile.json()
23 return response
24 }
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.
1 // ./app/routes/$slug.tsx
2 import { json, LoaderFunction, ActionFunction, redirect } from "@remix-run/node";
3 import { useLoaderData, useActionData } from "@remix-run/react";
4 import { useEffect, useState } from "react";
5 import { updateProfile } from "~/models/profiles.server";
6 import { getProfileBySlug } from "~/models/profiles.server";
7 import { getUserData } from "~/utils/session.server";
8 import { Profile } from "~/utils/types";
9 import ProfileCard from "~/components/ProfileCard";
10 import ProfileForm from "~/components/ProfileForm";
11 // type definition of Loader data
12 type Loaderdata = {
13 userData: Awaited<ReturnType<typeof getUserData>>;
14 profile: Awaited<ReturnType<typeof getProfileBySlug>>;
15 };
16 // action data type
17 type EditActionData =
18 | {
19 id: string | null;
20 username: string | null;
21 title: string | null;
22 }
23 | undefined;
24 // loader function to get posts by slug
25 export const loader: LoaderFunction = async ({ params, request }) => {
26 return json<Loaderdata>({
27 userData: await getUserData(request),
28 profile: await getProfileBySlug(params.slug),
29 });
30 };
31 // action to handle form submission
32 export const action: ActionFunction = async ({ request }) => {
33 // get user data
34 const data = await getUserData(request)
35 // get request form data
36 const formData = await request.formData();
37 // get form values
38 const id = formData.get("id");
39 const username = formData.get("username");
40 const twitterUsername = formData.get("twitterUsername");
41 const bio = formData.get("bio");
42 const title = formData.get("job-title");
43 const websiteUrl = formData.get("website");
44
45 // error object
46 // each error property is assigned null if it has a value
47 const errors: EditActionData = {
48 id: id ? null : "Id is required",
49 username: username ? null : "username is required",
50 title: title ? null : "title is required",
51 };
52 // return true if any property in the error object has a value
53 const hasErrors = Object.values(errors).some((errorMessage) => errorMessage);
54 // return the error object
55 if (hasErrors) return json<EditActionData>(errors);
56 // run the update profile function
57 // pass the user jwt to the function
58 await updateProfile({ id, username, twitterUsername, bio, title, websiteUrl }, data?.jwt);
59 // redirect users to home page
60 return null;
61 };
62 const Profile = () => {
63 const { profile, userData } = useLoaderData() as Loaderdata;
64 const errors = useActionData();
65 const [profileData, setprofileData] = useState(profile);
66 const [isEditing, setIsEditing] = useState(false);
67 console.log({ userData, profile });
68
69 return (
70 <section className="site-section">
71 <div className="wrapper flex items-center py-16 min-h-[calc(100vh-4rem)]">
72 <div className="profile-cont w-full max-w-5xl m-auto">
73 {profileData ? (
74 <>
75 {/* Profile card with `preview` = true */}
76 <ProfileCard profile={profileData} preview={true} />
77 {/* list of actions */}
78 <ul className="actions">
79 <li className="action">
80 <button className="cta w-icon">
81 <svg xmlns="http://www.w3.org/2000/svg" className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
82 <path
83 strokeLinecap="round"
84 strokeLinejoin="round"
85 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"
86 />
87 </svg>
88 <span>Share</span>
89 </button>
90 </li>
91 {userData?.user?.id == profile.id && (
92 <li className="action">
93 <button onClick={() => setIsEditing(!isEditing)} className="cta w-icon">
94 {!isEditing ? (
95 <svg xmlns="http://www.w3.org/2000/svg" className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
96 <path
97 strokeLinecap="round"
98 strokeLinejoin="round"
99 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"
100 />
101 </svg>
102 ) : (
103 <svg xmlns="http://www.w3.org/2000/svg" className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
104 <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" />
105 </svg>
106 )}
107 <span>{!isEditing ? "Edit" : "Cancel"}</span>
108 </button>
109 </li>
110 )}
111 </ul>
112 </>
113 ) : (
114 <p className="text-center">Oops, that profile doesn't exist... yet</p>
115 )}
116 {/* display dynamic form component when user clicks on edit */}
117 {userData?.user?.id == profile?.id && isEditing && (
118 <ProfileForm errors={errors} profile={profile} action={"edit"} onModifyData={(value: Profile) => setprofileData(value)} />
119 )}
120 </div>
121 </div>
122 </section>
123 );
124 };
125 export default Profile;
Now, when we’re logged in as a particular user, we can edit that user data as shown here:
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.
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.
Here are a few articles I think will be helpful:
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!