Ian McPhail
Test the emoji game of all the movie buffs you know by building a Movie Emoji Quiz app with Remix, Fauna, and Clerk.
A fun challenge coming out of the modern smartphone world is to identify a movie based only on a sequence of emoji. Sometimes the emoji “spell out” the words in the title, while other times they identify key plot themes. Test the emoji game of all the movie buffs you know by building a Movie Emoji Quiz app.
In this tutorial, we will build the app using the Remix full-stack web framework, a Fauna database, and Clerk for authentication and user management. Remix is a relatively new, open-source framework for React that has been gaining traction. Fauna is a developer-friendly, cloud-based database platform that offers modern features, such as real-time streaming and GraphQL. Clerk, an authentication provider built for the modern web, has a first-party Remix authentication package and integrates with Fauna through its JWT templates feature.
A brief web search didn't come up with any existing Remix and Fauna tutorials. And then I found this tweet from Ryan Florence (@ryanflorence), the co-founder of Remix:
I really want to build some demos with FaunaDB (I love the direction they're going) and AWS DynamoDB (I mean, come on, it's solid) as well.
— Ryan Florence (@ryanflorence) April 6, 2021
That sold me on moving forward in building with this stack.
If you would like to skip ahead, you can see the completed codebase and demo here.
This tutorial makes the following assumptions...
npm
(≥ v7)The first step is to create a new application from the Clerk dashboard. We’ll name this application Movie Emoji Quiz and leave the default Password authentication strategy selected. We’re also going to choose Google and Twitter as the social login providers, but feel free to select whichever ones you and your friends use.
Click the Add application button and your Clerk development instance will be created.
The next step is to create the Remix project. Run the following command in your terminal:
npx create-remix movie-emoji-quiz
It may prompt you to install the create-remix package. Then respond with the following:
? What type of app do you want to create? Just the basics? Where do you want to deploy? Remix App Server? Do you want me to run `npm install`? Yes
It will then ask “TypeScript or JavaScript?”, we’re going with JavaScript on this one, but that’s up to your personal preference.
As the instructions state, change into the app directory with cd movie-emoji-quiz/
Now install the two dependencies we need for this application:
npm install @clerk/remix faunadb
Next, touch .env
to create an environment variables file and replace <YOUR_FRONTEND_API>
and <YOUR_CLERK_API_KEY>
with their respective values from your Clerk instance, which you can get from the API keys page.
.envCLERK_FRONTEND_API=<YOUR_FRONTEND_API>CLERK_API_KEY=<YOUR_CLERK_API_KEY>
Once those environment variables are set, spin up the Remix dev server:
npm run dev
To share authentication state with Remix routes, we need to make three modifications to the app/root.jsx
file:
rootAuthLoader
as loader
ClerkCatchBoundary
as CatchBoundary
ClerkApp
app/root.jsx1import { rootAuthLoader } from "@clerk/remix/ssr.server";2import { ClerkApp, ClerkCatchBoundary } from "@clerk/remix";34export const loader = args => rootAuthLoader(args); /* 1 */5export const CatchBoundary = ClerkCatchBoundary(); /* 2 */67function App() {8return <html lang="en">{/*...*/}</html>;9}1011export default ClerkApp(App); /* 3 */
Clerk supports Remix SSR out-of-the-box.
From the Fauna dashboard, create a new database. We named ours emoji-movie-quiz and chose the Classic Region Group.
We only need to create one Collection for this application. You can do so either in the Dashboard UI or in the interactive query shell.
To do it in the shell, type the following:
CreateCollection({ name: "challenge" })
Then press the Run query button. If it was successful, you should see similar output.
We’re going to seed the database with a few examples to get started. If you’ve never used the Fauna Query Language (FQL) before, the syntax may look a little funny.
1Map(2[3{ emoji: "🔕🐑🐑🐑", title: "Silence of the Lambs" },4{ emoji: "💍💍💍💍⚰️", title: "Four Weddings and a Funeral" },5{ emoji: "🏝🏐", title: "Castaway" },6{ emoji: "👽📞🏡", title: "E.T." },7{ emoji: "👂👀👃👅✋6️⃣", title: "The Sixth Sense" }8],9Lambda("data", Create(Collection("challenge"), { data: Var("data") } ))10)
This will map over the examples array and create a new document with challenge data for each item in the Challenge collection.
To learn more about FQL, check out the Fauna docs and this handy cheat sheet.
You can navigate to the Collections tab to validate that the data is all set. You can even make edits directly from the user interface if you prefer.
While we’re here, let’s navigate over to the Functions tab and write a couple FQL functions we will need later.
Click the New Function button, name it getChallenges
, and then paste the following function body:
1Query(2Lambda(3[],4Map(5Paginate(Documents(Collection("challenge"))),6Lambda(7"challenge",8Let(9{10challengeRef: Get(Var("challenge")),11data: Select("data", Var("challengeRef")),12refId: Select(["ref", "id"], Var("challengeRef"))13},14Merge(Var("data"), { id: Var("refId") })15)16)17)18)19)
This function will get all the challenges and include the unique document ID inside each data object.
You can test out the functionality in the Shell by running:
Call("getChallenges")
The second function we’re going to define is called getChallengeById
and will take the unique document ID parameter and return the respective challenge data or null
if it doesn’t exist.
1Query(2Lambda(3"id",4Let(5{6challengeRef: Ref(Collection("challenge"), Var("id")),7exists: Exists(Var("challengeRef")),8challenge: If(Var("exists"), Get(Var("challengeRef")), null)9},10Select("data", Var("challenge"), null)11)12)13)
That’s all of the custom functions we’ll need here.
Although Fauna offers built-in identity and basic password authentication, it requires that you manage the user data yourself and does not provide features like prebuilt UI components, <UserProfile />
access, and other auth strategies such as OAuth social login and magic links. Clerk provides these features and more without the hassle of managing your own user and identity service.
The Clerk integration with Fauna enables you to authenticate queries to your Fauna database using a JSON Web Token (JWT) created with a JWT template.
From your Clerk dashboard, navigate to the JWT Templates screen. Click the New template button and choose the Fauna template.
Take note of the default template name of fauna (as this will come up later). You can leave the default settings and optionally add your own custom claims using convenient shortcodes.
While keeping this tab open, go back to the Fauna dashboard and navigate to the Security page.
Click on the Roles tab and then create a New Custom Role. Name this role user
and give it Read and Create access to the challenge
collection as well as Call permission for both getChallenges
and getChallengeById
functions.
If everything looks correct, click Save to create the user role.
Next, click on the Providers tab and click on the New Access Provider button.
Enter Clerk
as the name to identify this access provider.
We’re going to play a little pattycake back-and-forth between Fauna and Clerk, but bear with me and we’ll get through it together.
aud
claim.After those fields have been set, you can save both the Clerk JWT template and Fauna access provider. Whew! That was fun.
Now we can get back into building our application. Open the Remix project in your code editor of choice.
In Remix, app/root.jsx
wraps your entire application in both server and browser contexts.
Clerk requires a few modifications to this file so the authentication state can be shared with your Remix routes. First, add these imports to app/root.jsx
:
import { rootAuthLoader } from '@clerk/remix/ssr.server';import { ClerkApp, ClerkCatchBoundary } from '@clerk/remix';
Second, export rootAuthLoader
as loader
taking in the args parameter.
export const loader = (args) => rootAuthLoader(args);
Next, we need to export the ClerkCatchBoundary
as CatchBoundary
to handle expired authentication tokens. If you want your own custom boundary, you can pass it in as the first argument.
export const CatchBoundary = ClerkCatchBoundary();
And finally, we need to wrap the default export with the ClerkApp
higher-order component.
export default ClerkApp(function App() {return (<html lang="en">{/*[...]*/}</html>);});
That’s all that’s needed to install and configure Clerk for authentication. The next step will include adding sign in functionality.
We’re going to use the Clerk hosted <SignIn />
component to render the sign in form.
Update app/routes/index.jsx
with the following:
app/routes/index.jsx1import { SignIn } from '@clerk/remix';2import styles from '~/styles/index.css';34export const links = () => {5return [{ rel: 'stylesheet', href: styles }];6};78export default function Index() {9return (10<div>11<main>12<div className="content">13<SignIn />14</div>15</main>16</div>17);18}
We’re using Remix’s route styles functionality to dynamically add a stylesheet to this route.
Create a styles
directory inside of the app
folder and save the following as index.css
:
app/styles/index.css
1@font-face {2font-family: "color-emoji";3src: local("Apple Color Emoji"),4local("Segoe UI Emoji"),5local("Segoe UI Symbol"),6local("Noto Color Emoji");7}89:root {10--font-body: "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif, color-emoji;11--color-error: #c10500;12--color-success: #15750b;13}1415body {16margin: 0;17font-family: var(--font-body);18}1920header {21position: relative;22z-index: 1;23display: flex;24align-items: center;25justify-content: space-between;26padding: 10px 20px;27background-color: #fd890f;28box-shadow: 0 2px 2px 1px rgba(0, 0, 0, 0.2);29}3031.actions {32display: flex;33align-items: center;34}3536.actions > a {37margin: 0 20px;38color: #fff;39font-size: 14px;40text-decoration: none;41}4243.actions > a:hover,44.actions > a:focus {45color: rgba(255, 255, 255, 0.8);46}4748.logo {49color: #fff;50font-size: 22px;51font-weight: 600;52}5354main {55display: flex;56height: calc(100vh - 56px);57}5859aside {60width: 280px;61height: 100%;62overflow-y: scroll;63flex-shrink: 0;64background-color: #F5F5F4;65border-right: 1px solid #D8D8D4;66}6768aside h2 {69margin: 20px 20px 10px;70font-size: 18px;71}7273aside ul {74list-style: none;75padding: 0;76}7778aside li {79font-size: 28px;80}8182aside li:not(:last-child) {83border-bottom: 1px solid #D8D8D4;84}8586aside li > a {87display: block;88padding: 8px 20px;89text-decoration: none;90}9192aside li > a:hover,93aside li > a:focus,94aside li > a.active {95background-color: #ECECEA;96}9798.content {99width: 100%;100height: calc(100vh - 40px);101padding: 40px 20px 0;102background-color: #e5e6e4;103text-align: center;104}105106.content h1 {107margin-bottom: 8px;108}109110@font-face {111font-family: "color-emoji";112src: local("Apple Color Emoji"),113local("Segoe UI Emoji"),114local("Segoe UI Symbol"),115local("Noto Color Emoji");116}117118:root {119--font-body: "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif, color-emoji;120--color-error: #c10500;121--color-success: #15750b;122}123124body {125margin: 0;126font-family: var(--font-body);127}128129header {130position: relative;131z-index: 1;132display: flex;133align-items: center;134justify-content: space-between;135padding: 10px 20px;136background-color: #fd890f;137box-shadow: 0 2px 2px 1px rgba(0, 0, 0, 0.2);138}139140.actions {141display: flex;142align-items: center;143}144145.actions > a {146margin: 0 20px;147color: #fff;148font-size: 14px;149text-decoration: none;150}151152.actions > a:hover,153.actions > a:focus {154color: rgba(255, 255, 255, 0.8);155}156157.logo {158color: #fff;159font-size: 22px;160font-weight: 600;161}162163main {164display: flex;165height: calc(100vh - 56px);166}167168aside {169width: 280px;170height: 100%;171overflow-y: scroll;172flex-shrink: 0;173background-color: #F5F5F4;174border-right: 1px solid #D8D8D4;175}176177aside h2 {178margin: 20px 20px 10px;179font-size: 18px;180}181182aside ul {183list-style: none;184padding: 0;185}186187aside li {188font-size: 28px;189}190191aside li:not(:last-child) {192border-bottom: 1px solid #D8D8D4;193}194195aside li > a {196display: block;197padding: 8px 20px;198text-decoration: none;199}200201aside li > a:hover,202aside li > a:focus,203aside li > a.active {204background-color: #ECECEA;205}206207.content {208width: 100%;209padding: 40px 20px 0;210background-color: #e5e6e4;211text-align: center;212}213214.content h1 {215margin-bottom: 8px;216}217218.emoji {219display: block;220font-size: 80px;221line-height: 1.2;222margin: 20px auto 10px;223}224225.error > p {226color: var(--color-error);227font-size: 18px;228}
Your app should now look something like:
If you see a blank screen, you may be already signed in. We will handle that case momentarily.
Inside of the app
directory, create a folder called components
and a file called header.jsx
.
Drop in the following code:
app/components/header.jsx1import { SignedIn, UserButton } from '@clerk/remix';23export default function Header() {4return (5<header>6<span className="logo">Movie Emoji Quiz</span>7<SignedIn>8<UserButton />9</SignedIn>10</header>11);12}
Here we’re making use of the <SignedIn>
control flow component and the <UserButton />
which will allow us to edit our profile and sign out.
Go back to app/routes/index.jsx
and import the <Header />
component and add it just above the <main>
element:
app/routes/index.jsx1import Header from '../components/header';23export default function Index() {4return (5<div>6<Header />7<main>8<div className="content">9<SignIn />10</div>11</main>12</div>13);14}
If you sign in, you should now see your avatar. Click on it to see the user profile menu.
You can now sign in, sign out, and manage your account. Clerk makes it super easy with their hosted components.
In order to start fetching data from our Fauna database, we need to set up the Fauna client.
Create a utils
folder inside of app
and a file named db.server.js
.
The .server
naming convention is a hint to the compiler to ignore this file in the browser bundle.
Add the following code:
app/utils/db.server.js1import { getAuth } from '@clerk/remix/ssr.server';2import faunadb from 'faunadb';34export const getClient = async (request) => {5const { userId, getToken } = await getAuth(request);67if (!userId) {8return null;9}1011const secret = await getToken({ template: 'fauna' });1213return new faunadb.Client({ secret });14};1516export const q = faunadb.query;
Here we are using the getAuth
function from Clerk to check if we have a userId
(e.g. if the user is signed in) and to get access to the getToken
function, which when called with our Fauna JWT template name (it was simply “fauna” if you remember), will be passed to the Fauna client as the authentication secret.
If the user is signed in and we get access to the Fauna JWT template, we should then be able to make queries against our Fauna database.
We are also exporting q
here, which is a convention when using faunadb.query
. This way all our database helper functions are kept in the same place.
Now it’s time to display some of these challenges.
First let’s create a new route at /challenges
. We do this by creating a file app/routes/challenges.jsx
and populating it with the following:
app/routes/challenges.jsx1import { Outlet } from '@remix-run/react';2import Header from '../components/header';3import styles from '~/styles/index.css';45export const links = () => {6return [{ rel: 'stylesheet', href: styles }];7};89export default function Challenges() {10return (11<div>12<Header />13<main>14<div className="content">15<Outlet />16</div>17</main>18</div>19);20}
This should look the same as the index route, with the exception being <SignIn />
is replaced with the Remix <Outlet />
component.
Next, import the database utils we previous created and export a loader
function with the below code:
import { getClient, q } from '../utils/db.server';export const loader = async ({ request }) => {const client = await getClient(request);if (!client) {return null;}const response = await client.query(q.Call('getChallenges'));// Check your terminal for response dataconsole.log(response);return json(response);};
You can see we’re using the Fauna client to perform a FQL query to call the getChallenges
function we created. If all went well, check your terminal window (not the browser console) and you should see the response data.
Create a new file app/components/sidebar.jsx
that will loop over this data and create links to each challenge based on its ID.
app/components/sidebar.jsx1import { NavLink } from '@remix-run/react';23export default function Sidebar({ data }) {4return (5<aside>6<h2>Guess these movies...</h2>7<ul>8{data?.map(movie => (9<li key={movie.id}>10<NavLink to={`/challenges/${movie.id}`}>{movie.emoji}</NavLink>11</li>12))}13</ul>14</aside>15);16}
Import the <Sidebar />
component in the index route directly under the main
element.
To access the data from the loader, we will use the aptly named useLoaderData
hook from Remix and pass the data
property to the Sidebar
component:
export default function Challenges() {const { data } = useLoaderData();return (<div><Header /><main><Sidebar data={data} /><div className="content"><Outlet /></div></main></div>);}
If you visit http://localhost:3000/challenges, you should now see the emoji challenges rendered as links in the sidebar:
If you click on the links, they will cause an error because we haven’t created those individual challenge routes. Let’s do that now.
Let’s create a new stylesheet that will hold the challenge styles at app/styles/challenge.css
.
app/styles/challenge.css
1.emoji {2display: block;3font-size: 80px;4line-height: 1.2;5margin: 20px auto 10px;6}78.author {9padding-bottom: 10px;10color: #8a8a8a;11font-size: 14px;12}1314form {15display: flex;16flex-flow: column wrap;17align-items: center;18justify-content: center;19margin-top: 20px;20}2122label {23font-size: 18px;24font-weight: 600;25margin-bottom: 20px;26}2728input[type="text"] {29min-width: 280px;30padding: 8px;31border: 1px solid #ccc;32font-family: var(--font-body);33font-size: 16px;34}3536.submit-btn {37appearance: none;38margin-top: 40px;39padding: 12px 24px;40background-color: #7180AC;41border: 1px solid #6575A4;42border-radius: 4px;43cursor: pointer;44color: #fff;45font-family: var(--font-body);46font-size: 14px;47}4849.submit-btn:hover,50.submit-btn:focus {51background-color: #6575A4;52}5354.form-field {55display: flex;56align-items: baseline;57margin-top: 20px;58text-align: left;59}6061.form-field label {62display: block;63min-width: 50px;64margin: 0 16px 0 0;65}6667.form-validation-error {68color: var(--color-error);69font-size: 14px;70margin-top: 4px;71margin-bottom: 0;72}7374.message {75margin: 8px 0 0;76font-size: 16px;77}7879.message--correct {80color: var(--color-success);81}8283.message--incorrect {84color: var(--color-error);85}8687.reveal {88position: relative;89}9091.reveal-btn {92position: relative;93z-index: 2;94appearance: none;95width: 300px;96margin: 24px auto;97padding: 16px 24px;98background: #F5F5F4;99border: 1px solid #D8D8D4;100transition: opacity 1.5s ease;101cursor: help;102font-family: var(--font-body);103font-size: 14px;104}105106.reveal-btn:hover {107opacity: 0;108}109110.reveal-text {111position: absolute;112top: 40px;113left: 0;114width: 100%;115font-size: 14px;116}
Next, create a file inside a folder at the path app/routes/challenges/$id.jsx
The $
prefix is important here as it creates a dynamic segment.
Add the following code:
app/routes/challenges/$id.jsx1import { json } from '@remix-run/node';2import { useLoaderData } from '@remix-run/react';3import { getClient, q } from '../../utils/db.server';4import styles from '~/styles/challenge.css';56export const links = () => {7return [{ rel: 'stylesheet', href: styles }];8};910export const loader = async ({ params, request }) => {11const client = await getClient(request);1213if (isNaN(params.id)) {14throw new Response('Challenge not found', {15status: 40416});17}1819const challenge = await client.query(q.Call('getChallengeById', params.id));2021if (!challenge) {22throw new Response('Challenge not found', {23status: 40424});25}2627return json(challenge);28};2930export default function Challenge() {31const { emoji } = useLoaderData();3233return (34<div>35<span className="emoji">{emoji}</span>36</div>37);38}
This code follows a similar pattern to what we did in the sidebar with the loader
and the useLoaderData
hook. The difference here is we’re calling the getChallengeById
FQL function and passing it the id
parameter, which comes from the $id
dynamic route.
We are also using a Remix convention of throwing Response
objects for error scenarios (e.g. invalid ID, challenge not found).
If you click on the links in the sidebar, you should now see each emoji challenge rendered in its full glory (a large type size).
Now it’s time to add the form to submit guesses for the emoji challenges. Form handling is one area where Remix really shines.
Add the following form markup below the emoji:
<form method="post" autoComplete="off"><label htmlFor="guess">What movie is this?</label><inputid="guess"type="text"name="guess"placeholder="Enter movie title..."required/><button className="submit-btn">Submit guess</button></form>
This is pretty standard JSX form markup. Nothing too fancy going on here.
Let’s import our DB utils as well as the json
Response helper from Remix.
import { json } from '@remix-run/node';import { getClient, q } from '~/utils/db.server';
Then export the following action function:
export const action = async ({ params, request }) => {const form = await request.formData();const guess = form.get('guess');const client = await getClient(request);const challenge = await client.query(q.Call('getChallengeById', params.id));const isCorrect = guess.toLowerCase() === challenge.title.toLowerCase();return json({guessed: isCorrect ? 'correct' : 'incorrect',message: isCorrect ? 'Correct! ✅' : 'Incorrect! ❌',answer: challenge.title});};
Here we’re using native FormData methods to read the guess input and comparing it against the challenge title we get by calling our FQL getChallengeById
function with the challenge ID from the route params.
Based on whether the guess matches the title (case insensitive), we return an appropriate JSON response. We can access the action data using the useActionData
hook (also aptly named).
import { useActionData, useLoaderData } from '@remix-run/react';
Now we can update the UI to display the correct message based on the guess submitted.
export default function Challenge() {const { emoji } = useLoaderData();const data = useActionData();return (<div><span className="emoji">{emoji}</span><form method="post" autoComplete="off"><label htmlFor="guess">What movie is this?</label><inputid="guess"type="text"name="guess"placeholder="Enter movie title..."required/>{data?.guessed ? (<p className={`message message--${data.guessed}`}>{data.message}</p>) : null}<button className="submit-btn">Submit guess</button>{data?.guessed === 'incorrect' ? (<div className="reveal"><button className="reveal-btn" type="button">Reveal answer</button><span className="reveal-text">{data?.answer}</span></div>) : null}</form></div>);}
If an incorrect guess is submit, we provide the user a way to reveal the answer.
The form should now work to submit correct and incorrect guesses.
Because the form is shared between different challenge routes, if you enter text in one input and go to another challenge, you will see the input value is not being cleared.
We can fix this by using the useTransition
hook, putting a ref on the form element, and then resetting the form when the transition is a normal page load.
export default function Challenge() {const { emoji } = useLoaderData();const data = useActionData();const transition = useTransition();const ref = useRef();useEffect(() => {if (transition.type == 'normalLoad') {// Reset form on route changeref.current && ref.current.reset();}}, [transition]);return (<div><span className="emoji">{emoji}</span><form ref={ref} method="post" autoComplete="off"><label htmlFor="guess">What movie is this?</label><inputid="guess"type="text"name="guess"placeholder="Enter movie title..."required/>{data?.guessed ? (<p className={`message message--${data.guessed}`}>{data.message}</p>) : null}<button className="submit-btn">Submit guess</button>{data?.guessed === 'incorrect' ? (<div className="reveal"><button className="reveal-btn" type="button">Reveal answer</button><span className="reveal-text">{data?.answer}</span></div>) : null}</form></div>);}
You will need to add the necessary hook imports from React and Remix.
import { useEffect, useRef } from 'react';import { useActionData, useLoaderData, useTransition } from '@remix-run/react';
After that is added, the form should clear when choosing a different challenge.
This app is not much so much fun if users can’t submit their own movie emoji challenges. So that’s the functionality we’re going to add now.
As with most things in Remix, the first thing we need to do is create a new route.
Create app/routes/challenges/new.jsx
with the following:
app/routes/challenges/new.jsx1import { getAuth } from '@clerk/remix/ssr.server';2import { json, redirect } from '@remix-run/node';3import { Form, useActionData } from '@remix-run/react';4import { getClient, q } from '~/utils/db.server';5import styles from '~/styles/challenge.css';67export const links = () => {8return [{ rel: 'stylesheet', href: styles }];9};1011const badRequest = data => json(data, { status: 400 });1213const validateEmoji = emoji =>14!emoji.trim() || /\p{L}|\p{N}(?!\uFE0F)|\p{Z}/gu.test(emoji)15? 'Please enter only emoji'16: undefined;1718const validateTitle = title =>19title && title.length > 1 ? undefined : 'Please enter a movie title';2021export const action = async ({ request }) => {22const form = await request.formData();23const emoji = form.get('emoji');24const title = form.get('title');2526if (typeof emoji !== 'string' || typeof title !== 'string') {27return badRequest({28formError: 'Form not submitted correctly.'29});30}3132const fieldErrors = {33emoji: validateEmoji(emoji),34title: validateTitle(title)35};3637if (Object.values(fieldErrors).some(Boolean)) {38return badRequest({39fieldErrors,40fieldValues: {41emoji,42title43}44});45}4647const { userId } = await getAuth(request);48const client = await getClient(request);49const data = {50emoji,51title,52userId53};5455const response = await client.query(q.Create('challenge', { data }));5657return redirect(`/challenges/${response.ref.value.id}`);58};5960export default function NewRoute() {61const actionData = useActionData();6263return (64<div>65<h1>Create new challenge</h1>66<Form method="post" autoComplete="off">67<div className="form-field">68<label htmlFor="emoji">Emoji</label>69<input id="emoji" type="text" name="emoji" />70</div>71{actionData?.fieldErrors?.emoji ? (72<p className="form-validation-error" role="alert" id="name-error">73{actionData.fieldErrors.emoji}74</p>75) : null}76<div className="form-field">77<label htmlFor="title">Movie</label>78<input id="title" type="text" name="title" />79</div>80{actionData?.fieldErrors?.title ? (81<p className="form-validation-error" role="alert" id="name-error">82{actionData.fieldErrors.title}83</p>84) : null}85{actionData?.formError ? (86<p className="form-validation-error" role="alert">87{actionData.formError}88</p>89) : null}90<button className="submit-btn">Submit challenge</button>91</Form>92</div>93);94}
This time we’re using the Form
component provided by Remix, which helps with automatically serializing the values. There’s validation logic to check for valid emoji and titles. If the validation passes, we create a new challenge. We use the getAuth
function from Clerk to send in the user ID along with the emoji and title. These values form the data for a new challenge document in Fauna. On a successful document creation, the user is redirected to the new challenge page.
Let’s add a link to the header so we can get to this page. It’s wrapped with <div className="actions">
to provide the necessary styling.
app/components/header.jsx1import { SignedIn, UserButton } from '@clerk/remix';2import { Link } from '@remix-run/react';34export default function Header() {5return (6<header>7<span className="logo">Movie Emoji Quiz</span>8<SignedIn>9<div className="actions">10<Link to="/challenges/new">Submit challenge</Link>11<UserButton />12</div>13</SignedIn>14</header>15);16}
Clicking the link should take you to the page where you can create a new challenge. You should see validation errors if you don’t fill out the form properly.
Once submitted, you will be redirected to the new challenge page. Because the action created a new mutation, Remix will automatically update the data in the sidebar. Neat!
You now have a working Movie Emoji Quiz app. If you’re ready to share it with your family and friends, Remix makes deployment very easy and has support for various deployment targets. Using the Vercel CLI, all you need to deploy your Remix app is run:
npm i -g vercelvercel
And your app will be live within minutes!
To take this app even further, you can do the following:
/sign-in
and /sign-out
to use mounted Clerk componentsIf you enjoyed this tutorial or have any questions, feel free to reach out to me (@devchampian) on Twitter, follow @ClerkDev, or join our support Discord channel. Happy coding!
Start completely free for up to 10,000 monthly active users and up to 100 monthly active orgs. No credit card required.
Learn more about our transparent per-user costs to estimate how much your company could save by implementing Clerk.
The latest news and updates from Clerk, sent to your inbox.