Building Auth with Clerk: Tips And Tricks
In May 2024 I officially launched This Is Not A Drill! (TINAD) a web-application user-alerting service. Because this is a service meant for enterprise use, it's critical to offer account and team management out of the box. But building authorization walls, account management panels, and team support is a pain. Even if you use some off-the-shelf "SaaS in a box" paid solutions, you're likely going to need to support processes like password resets or magic links, blessed mail exchangers, etc. Who wants to deal with that? Not me.
After reviewing several manual options and even implementing basic Google Oauth in React myself, I decided to look into vendor support. I winnowed the vendor list down to two: Kinde.com and Clerk.com. Kinde has a nice dashboard design, but ultimately, the rapid support I got from Clerk's engineers on some of the trickier aspects of my build, plus the more engineer-oriented documentation, won me over to Clerk.
I got all my desired functionality going with Clerk, but not without a few hiccups! To save you time (and spare the Clerk engineers' Discord), here's an account of how I solved some of these bugbears. We will cover only these tricky, more advanced topics:
- Styling your login and signup pages the way you want
- Styling Clerk account panel components
- Instant login without a password or Oauth (useful for a demo site)
- Adding users to a team in the simplest manner possible
This is not a tutorial covering Clerk basics and defaults. For those, you can consult Clerk documentation or simply ask ChatGPT.
Caveats and Disclaimers
- This article was written June 13, 2024. If you are reading this long after that day, this info may be out-dated. Check the Clerk documentation for the latest info.
- This article assumes you are building a site with React (or NextJs) and typescript. YMMV.
- All the code for This Is Not A Drill! is open-source and can be found in GitHub.
Styling Login & Signup Pages
The screenshot below shows the pre-built account portal pages that Clerk offers. You can use these for sign up/in, but there's not a lot of customization you can do to them (e.g. see this one). So you might want to create your own pages and just include the relevant Clerk auth component inside them.
Note that, even if you just use their components, unless you're on a paid plan, you'll still have their co-branding showing (as I do). Ain't no free lunch, pal.
Let's look at TINAD's Login and Signup components. They both share a common layout component, AuthLayout
. (The AuthLayout is pure tsx
that simply includes Clerk's SignIn
or SignUp
components as children.)
In the below code for signing in, we import useUser
and SignIn
methods from clerk-react
.
- The
isSignedIn
method fromuseUser
is called to determine the user's logged in status. If the user is already logged in, redirect to the home page (or the demo dashboard's home page) withreact-router-dom
'sNavigate
component. Otherwise, pass a ClerkSignin
component into theAuthLayout
. - We set the
signUpUrl
property, so the Clerk component can render a link to the sign up page, if the user arrives at our login page and doesn't yet have an account. - We pass some visual tweaks for the SignIn component as an
appearance
property.
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useUser, SignIn } from "@clerk/clerk-react";
import AuthLayout from './AuthLayout';
const LoginComponent = () => {
const { isSignedIn } = useUser();
// Redirect authenticated users back to the dashboard
// if they somehow navigated to login.
if (isSignedIn) {
return <Navigate to="/" replace />;
} else if (import.meta.env.VITE_IS_DEMO_SITE === 'true') {
// If not logged in and this is the demo site,
// send users to the special demo login page.
return <Navigate to="/demo-dashboard" replace />;
}
return (
<>
<AuthLayout imageUrl="/ThisIsNotADrill1.png" >
<SignIn
appearance={{
variables: {
colorPrimary: "#E66118",
colorTextOnPrimaryBackground: "white",
colorText: "white",
colorBackground: "#E66118",
}
}}
signUpUrl="/sign-up"
/>
</AuthLayout>
</>
);
}
export default LoginComponent;
The appearance
property in Clerk's SignIn
component was used to style the button and text colors on the form elements for the email address. In addition, I was able to control the width aspect of the Clerk signin/signup components in the CSS for the AuthLayout itself, using the :global()
CSS function applied to the Clerk classes directly.
:global(.cl-signIn-root),
:global(.cl-signIn-start),
:global(.cl-signUp-start),
:global(.cl-signIn-password),
:global(.cl-signIn-alternativeMethods),
:global(.cl-signIn-forgotPasswordMethods),
:global(.cl-signIn-havingTrouble) {
max-width: 370px;
}
:global(.cl-footerActionLink) {
font-weight: 900;
}
Here's how the final TINAD SignIn page looks:
The SignUp
component is more or less the same as the SignIn
component, but as you can see we add a Mantine Card as an additional child to the AuthLayout
. This flexibility allows us to add contextual help, styled according to our site's own style sheets.
And here's how the final TINAD Signup Page looks.
Styling Clerk's Account Panels
Clerk's Account Panels are pretty comprehensive, and it would be painful to try to recreate all their functionality yourself. That said, you may wish to hide some of their functions and make sure that the individual tabs can be navigated to on separate URLs without a page refresh.
We use the Clerk UserProfile
component to render the user settings panels.
It is possible to add tabs with sub-components to this panel. For instance, I wish to display TINAD's TOS and Billing components as parts of this Clerk component. I can do that with this tsx
:
import { UserButton, UserProfile } from "@clerk/clerk-react"
import classes from './css/Settings.module.css';
import { IconArrowLeft, IconReceipt, IconEdit, IconCopy } from '@tabler/icons-react';
import { TermsOfService } from "./TermsOfService";
import { Billing } from "./Billing";
const AccountPanel = () => {
useEffect(() => {
if (import.meta.env.VITE_IS_DEMO_SITE === 'true') {
const styleLink = document.createElement('style');
styleLink.type = 'text/css';
styleLink.innerHTML = '.cl-profilePage__security, .cl-profileSection__emailAddresses { display: none; }';
document.head.appendChild(styleLink);
}
}, []);
return (
<div className={classes.account} >
<UserProfile path="virtual">
<UserProfile.Page label="Terms of Service" labelIcon={<IconCopy />} url="tos">
<TermsOfService />
</UserProfile.Page>
<UserProfile.Page label="Billing" labelIcon={<IconCopy />} url="billing">
<Billing />
</UserProfile.Page>
</UserProfile>
</div>
);
}
export default AccountPanel;
Note that theUserProfile
parameterpath="virtual"
is critical to make sure you do not navigate away from your page when accessing the subcomponents for TOS and Billing.
In addition, note the CSS injection with the useEffect()
call. This is the only way I've found to hide certain components for the demo site, where I don't want casual users to inspect and possibly change the demo user's email address, etc. (Of course, this isn't really secure since the DOM element is just hidden, but it's all right for demo.)
Zero-Auth Login
Security using Clerk is pretty solid. But there's one use case where you may not want to force someone to sign in or sign up: application demos. Using the technique below, you can enable one-click login to a demo app without even making a user type a demo name and password.
Backend Token Acquisition
Your backend (you do have a backend don't you? 😀) can acquire a token that can be sent to the front end, which can then call Clerk's SDK to create a session/login for a specific account.
Acquiring the ticket in the backend executes in the code below. (Note that the backend secret can be acquired from the Clerk dashboard under "API keys".)
const signinTicketUrl = 'https://api.clerk.com/v1/sign_in_tokens';
const backendSecret = process.env.CLERK_DEMO_BACKEND_SECRET;
const headers = {
'Authorization': `Bearer ${backendSecret}`,
'Content-type': 'application/json'
};
const body = JSON.stringify({
'user_id': process.env.CLERK_DEMO_USER_ID,
'expires_in_seconds' : 3600,
});
console.log(`headers: ${JSON.stringify(headers,null,2)}`);
console.log(`body: ${body}`);
const response = await fetch(signinTicketUrl, {
method:'POST',
headers,
body,
});
if (!response.ok) {
throw new Error(`Failed to fetch signin ticket data from Clerk: ${response.statusText}`);
} else {
const data = await response.json();
const ticket = data.token;
console.log('Got ticket:', ticket);
return ticket ? ticket : null;
}
} catch(error) {
console.log(`Failed to fetch signin ticket from Clerk at all: ${error}`);
return null;
}
}
Clerk's documentation for acquiring signin tickets is a little hard to follow. Click the link, but in your head, change sign-up
to sign-in
throughout.
Front-End Session Acquisition
In the front end for the TINAD demo dashboard, after we get our ticket from our backend, we pass it to Clerk's signIn
method (acquired from the useSignin
context). Note that in the below code, we open a new browser tab upon successfully signing in as the demo user. This is not strictly necessary but seems to work better.
The VITE_CLERK_DEMO_USER_ID
environment variable contains the Clerk user id for the shared demo user, acquired from the Clerk dashboard. Because the backend call may take a little time we make sure to await
our backend ticket acquisition process, and the front end as well must await
Clerk's signIn.create()
session creation call.
import { useUser, useSignIn, SignIn } from "@clerk/clerk-react";
const DemoLoginComponent = () => {
const { isSignedIn } = useUser();
const { signIn, isLoaded } = useSignIn();
const { getSigninTicket } = useSettings(); // my backend context
const doSignIn = async ():Promise<boolean> => {
// get a signin ticket from the TINAD backend
const clerkTicket = await getSigninTicket(import.meta.env.VITE_CLERK_DEMO_USER_ID);
setLoginButtonTitle('Working on it!...');
try {
const response = await signIn.create({
strategy: "ticket",
ticket: clerkTicket as string,
});
if (response.status !== 'complete') {
throw new Error (`HTTP error! status: ${response.status}`);
setLoginButtonTitle(defaultButtonTitle);
} else {
window.open(demoPanelsUrl, '_blank');
window.close();
return true;
}
} catch(error) {
if ((error as ClerkSigninError).errors) {
const errorCode = (error as ClerkSigninError).errors[0].code;
console.log(`doSignin error: ${errorCode}`);
if (errorCode === 'session_exists') {
console.log('session exists');
logRocketIdentifyDemoUser();
window.open(demoPanelsUrl, '_blank');
window.close();
return true;
} else {
setLoginButtonTitle(defaultButtonTitle);
}
}
}
return false;
}
For this demo user, I turned off the ability to delete their own account, in the TINAD dashboard, as I don't want anybody who's using the demo site deleting that account and inconveniencing the next customer.
Building a simple team in Clerk
In Clerk, you can create multiple teams, and users can be on different teams simultaneously, with varying privileges. This gets complicated fast. You may need to reach for Clerk's team switching widgets to allow a single signed-in user to switch which team they're acting under.
In my application's case, first off, I decided to do away with the terminology "organization" and just use "team" to be synonymous with Clerk's "organization" concept. What's more, if you are invited to a team (organization), then you be added added to and can only belong to that one team (organization). This meant I could avoid worrying about the "team switching" ("organization switching") UX aspects.
If somehow a user signs up directly, and then is invited to a team later on (at the same email address), my application just errors out. That is fine, because the primary use case for me will be user signs up, user creates team, user invites teammates.
Setting up a team (organization)
In TINAD, when a user signs in for the first time, we check their Clerk account to see if they already belong to a Clerk organization. If they do, then that's because they were invited by another user that organization. If they don't, it means they're a brand new user that came in directly to the app rather than via an invite to a team. In this case, we create a Clerk organization (aka their TINAD team) for them immediately. (Code is here.)
const { createOrganization, setActive } = useOrganizationList();
...
if (createOrganization !== undefined) {
try {
// Clerk call to create a clerk Organization for this new user
const clerkOrganization = await createOrganization({name: organizationName});
clerkOrganizationId = clerkOrganization.id;
if (clerkOrganization) {
outcomes.createdClerkOrg = true;
}
} catch(error) {
console.error(`Unable to create clerk organization for user ${user.id} with error ${error}`);
}
}
Users can later rename that team using the configuration screen, and invite more members to it.
Renaming the team
Renaming the team (Clerk organization) requires first that we get the Clerk's active session user
object, and then get its (presumably single) organization object, and call update()
on it. The below technique is probably not the most reliable way to accomplish this and it won't work if you allow multiple organization memberships per user. Again, for my use case this was fine. (If anybody at Clerk cares to clarify how to do this better, I'm all ears).
import { useUser, useOrganization,
useOrganizationList,
OrganizationList } from "@clerk/clerk-react";
.
.
.
const { createOrganization } = useOrganizationList();
const { isLoaded } = useOrganization();
const { user } = useUser();
if (!user) {
return null;
}
const setTeamNameAtClerk = async () => {
const name = teamName;
if (teamExists) {
organization = user.organizationMemberships[0].organization;
await organization.update({ name });
} else if (createOrganization !== undefined) {
try {
organization = await createOrganization({name});
setTeamExists(true);
} catch (error) {
console.log('Unable to create a team, please try again later. (' + error + ')');
}
}
};
Managing the Team (Organization)
The ManageTeam
component uses Clerk's useUser
and useOrganization
to determine if there have been any invitations sent to teammates yet. If there have been, the InvitationsManager
will render a table of invitations sent out, plus a button/form to add a new member to the team.
ManageTeam
also renders the MembersManager
, which uses useOrganization
to get the current members of the team (those who have accepted invitations).
const { memberships } = useOrganization({
memberships: {
infinite: true,
keepPreviousData: true,
}
});
Note the infinite
parameter. I want to retrieve all the members (on the lowest Clerk plan, this is limited to 5 anyway).
Also note, when the user removes a member, we not only have to call Clerk's membership.destroy()
, we also have to call memberships.revalidate?.()
. If we don't, the context provided by Clerk may not update my table of current memberships. This gotcha had me struggling for half a day or so. The same is true if we change a membership's role (member to admin or vice versa). (The membership is one record in the members array returned by the context, as shown above.)
This is also the case for invitations. If you send or revoke an invitation, you will not get updated context back from the Clerk context until you call invitations.revalidate?.()
. Please note as well this is an async call, so you must await
it.
You can see this at work in the InvitationsManager
and MembersManager
components. Note also that if you want to resend or revoke an invitation, you must also call revalidate?.()
or again, the context will not update and your on-screen display will appear not to show the latest invitations status until you reload the entire page.
You can review the all working components for user account management in TINAD here.
Conclusion
Clerk.com is a solid service, and their support for indie developers is also great. Their docs are very good. I wrote this blog post to just fill in a few gaps that got me stuck, but I expect these details will eventually land in their docs.
Building a real enterprise-grade SaaS is challenging. Don't do auth yourself, unless you're a masochist. Clerk will save you a bunch of headache, and their engineers seem to enjoy masochism– so leave user management to them.
I hope you found this article useful. If you did, please repost it so other hackers can share.
Want to learn more about This Is Not A Drill! ? Just head on over to our home page.
Comments ()