30
loading...
This website collects cookies to deliver better user experience
Link to the source code
Here's a live demo
UPDATE: After implementing authentication, the link above has only view access.
Here's the demo with authentication
word
:If you are not yet familiar with Remix, I suggest checking first my previous blog post about it, or refer to their documentation.
If you want to use another provider, you can skip to this part Create the Remix project
CREATE TABLE words (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name varchar NOT NULL,
definitions varchar ARRAY NOT NULL,
sentences varchar ARRAY NOT NULL,
type varchar NOT NULL
);
INSERT INTO words
(name, type, definitions, sentences)
VALUES
('hello', 'noun', ARRAY['used as a greeting'], ARRAY['Hello world.']);
ALTER TABLE words ENABLE ROW LEVEL SECURITY;
CREATE POLICY "anon_select" ON public.words FOR SELECT USING (
auth.role() = 'anon'
);
CREATE POLICY "anon_insert" ON public.words FOR INSERT WITH CHECK (
auth.role() = 'anon'
);
CREATE POLICY "anon_update" ON public.words FOR UPDATE USING (
auth.role() = 'anon'
);
CREATE POLICY "anon_delete" ON public.words FOR DELETE USING (
auth.role() = 'anon'
);
Authentication/Policies
tab, should be seeing this.npx create-remix@latest
cd [whatever you named the project]
# Remove demo files
rm -rf app/routes/demos app/styles/demos
# We'll recreate this files later
rm app/routes/index.tsx app/root.tsx
// app/root.tsx
import {LiveReload,Meta,Outlet,Scripts,ScrollRestoration,useCatch} from "remix";
export default function App() {
return (
<Document>
<Layout>
<Outlet />
</Layout>
</Document>
);
}
function Document({
children,
title,
}: {
children: React.ReactNode;
title?: string;
}) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
{title ? <title>{title}</title> : null}
<Meta />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
{process.env.NODE_ENV === "development" && <LiveReload />}
</body>
</html>
);
}
function Layout({children}: React.PropsWithChildren<{}>) {
return (
<main>{children}</main>
);
}
export function CatchBoundary() {
let caught = useCatch();
let message;
switch (caught.status) {
case 404:
message = <p>This is a custom error message for 404 pages</p>
break;
// You can customize the behavior for other status codes
default:
throw new Error(caught.data || caught.statusText);
}
return (
<Document title={`${caught.status} ${caught.statusText}`}>
<Layout>
<h1>
{caught.status}: {caught.statusText}
</h1>
{message}
</Layout>
</Document>
);
}
// app/routes/index.tsx
export default function Index() {
return (
<div>
<h1>Hello world</h1>
</div>
);
}
Expect the design will be terrible, I will create a separate blog for styling.
Not using Supabase? Skip to Fetch All Words
npm install @supabase/supabase-js
OR
yarn add @supabase/supabase-js
SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
SUPABASE_URL=YOUR_SUPABASE_URL
Make sure to add .env
in the .gitignore
file.
// libs/supabase-client.ts
import { createClient } from "@supabase/supabase-js";
const supabaseUrl = process.env.SUPABASE_URL as string;
const supabaseKey = process.env.SUPABASE_ANON_KEY as string;
export const supabase = createClient(supabaseUrl, supabaseKey);
If you prefer not to use TypeScript, remove the declarations and usages of types, and change the file extensions from .tsx
to .jsx
.
// app/models/word.ts
export enum WordType {
NOUN = "noun",
VERB = "verb",
ADJECTIVE = "adjective",
}
export type Word = {
id: number;
name: string;
sentences: string[];
definitions: string[];
type: WordType;
};
/
to /words
, for now./words
every time we open the root page.// app/routes/index.tsx
import { redirect } from "remix";
export function loader() {
return redirect("/words", 308);
}
If you are not using Supabase, replace the Supabase API calls with your choice.
// app/routes/words.tsx
import type { LoaderFunction } from "remix";
import { supabase } from "~/libs/supabase-client";
import { Word } from "~/models/word";
export const loader: LoaderFunction = async () => {
const { data: words } = await supabase
.from<Word>("words")
.select("id,name,type");
// We can pick and choose what we want to display
// This can solve the issue of over-fetching or under-fetching
return words;
};
// app/routes/words.tsx
import { useLoaderData, Link } from "remix";
// export const loader ...
export default function Index() {
const words = useLoaderData<Word[]>();
return (
<div>
<h1>English words I learned</h1>
<ul>
{words.map((word) => (
<li key={word.id}>
<div>
<Link to={`/words/${word.id}`}>
{word.name} | {word.type}
</Link>
</div>
</li>
))}
</ul>
</div>
);
}
// app/routes/words/$id.tsx
import type { LoaderFunction } from "remix";
import { supabase } from "~/libs/supabase-client";
import { Word } from "~/models/word";
export const loader: LoaderFunction = async ({ params }) => {
const { data } = await supabase
.from<Word>("words")
.select("*")
.eq("id", params.id as string)
.single();
return data;
};
// app/routes/words/$id.tsx
// import ...
import { /*other imports*/, useLoaderData } from "remix";
// export const loader ...
export default function Word() {
const word = useLoaderData<Word>();
return (
<div>
<h3>
{word.name} | {word.type}
</h3>
{word.definitions.map((definition, i) => (
<p key={i}>
<i>{definition}</i>
</p>
))}
{word.sentences.map((sentence, i) => (
<p key={i}>{sentence}</p>
))}
</div>
);
}
Outlet
inside our Words Index component to fix the above issue.// app/routes/words.tsx
import {/*other imports*/, Outlet} from "remix";
// export const loader ...
export default function Index() {
const words = useLoaderData<Word[]>();
return (
<div>
<h1>English words I learned</h1>
{/* To put the list and outlet side by side */}
<div style={{ display: "flex", justifyContent: "space-between" }}>
<ul>
{words.map((word) => (
<li key={word.id}>
<Link to={`/words/${word.id}`}>
{word.name} | {word.type}
</Link>
</li>
))}
</ul>
<Outlet /> {/* <-- this is where $id.tsx will render */}
</div>
</div>
);
}
/words/$id
page, let's proceed with deletion first// app/routes/words/$id.tsx
import {/*other imports*/, Form} from "remix";
// export const loader ...
export default function Index() {
// ...
return (
<div>
{/* previous content */}
<Form method="post">
<input type="hidden" name="_method" value="delete" />
<button type="submit">Delete</button>
</Form>
</div>
);
}
// app/routes/words/$id.tsx
import { /*other imports*/, redirect} from "remix";
import type {/*other imports*/, ActionFunction} from "remix";
export const action: ActionFunction = async ({ request, params }) => {
const formData = await request.formData();
if (formData.get("_method") === "delete") {
await supabase
.from<Word>("words")
.delete()
.eq("id", params.id as string);
return redirect("/words");
}
};
// ...rest of the code
hello
will be deleted from the database, and the page will redirect to the /words
page._method
with value delete
.action
handler will trigger in the server.action
handler, we check if the _method
is delete
.// app/routes/words.tsx
import { /*other imports*/ Form } from "remix";
// export const loader ...
export default function Index() {
// const words ...
return (
<div>
<h1>English words I learned</h1>
<Form method="get" action={"/words/add"}>
<button type="submit">Add new word</button>
</Form>
{/* previous contents */}
</div>
);
}
404
page, let's create the /words/add
route.// app/routes/words/add.tsx
import {Form} from "remix";
import { WordType } from "~/models/word";
export default function AddWord() {
return (
<Form method="post">
<div>
<label htmlFor="name">Word</label>
<input id="name" name="name" type="text" placeholder="Word" required />
</div>
<div>
<label htmlFor="type">Type</label>
<select id="type" name="type" defaultValue={WordType.NOUN}>
<option value={WordType.NOUN}>Noun</option>
<option value={WordType.VERB}>Verb</option>
<option value={WordType.ADJECTIVE}>Adjective</option>
</select>
</div>
<div>
<label htmlFor="sentence.1">Sentences</label>
<textarea
id="sentence.1"
name="sentence"
placeholder="Sentence"
minLength={10}
/>
</div>
<div>
<label htmlFor="definition.1">Definitions</label>
<textarea
id="definition.1"
name="definition"
placeholder="Definition"
minLength={10}
/>
</div>
<button type="submit">Submit</button>
</Form>
);
}
Submit
button, let's add an action on the words/add
route.// app/routes/words/add.tsx
import { /*other imports*/, redirect } from "remix";
import type { ActionFunction } from "remix";
import { supabase } from "~/libs/supabase-client";
export const action: ActionFunction = async ({ request }) => {
const formData = await request.formData();
const newWord = {
name: formData.get("name"),
type: formData.get("type"),
sentences: formData.getAll("sentence"),
definitions: formData.getAll("definition"),
};
const { data } = await supabase.from("words").insert([newWord]).single();
return redirect(`/words/${data?.id}`);
};
I'm not sure if you noticed, but we haven't used any JavaScript code on the Frontend, yet it could complete the intended task. We only used an HTML form as it was initially used to handle validations and perform the submission. This is what I like about Remix; we can focus on the web fundamentals to become a better Web developer instead of making some workarounds.
.
between words, it will transform to /
in the URL.words/edit/[id]
.// app/components/WordForm.tsx
import { Form } from "remix";
import { Word, WordType } from "~/models/word";
export default function WordForm({ word }: { word?: Word }) {
return (
<Form method="post">
<div>
<label htmlFor="name">Word</label>
<input
id="name"
name="name"
type="text"
placeholder="Word"
required
defaultValue={word?.name ?? ""}
disabled={Boolean(word?.name)}
/>
</div>
<div>
<label htmlFor="type">Type</label>
<select
id="type"
name="type"
defaultValue={word?.type ?? WordType.NOUN}
>
<option value={WordType.NOUN}>Noun</option>
<option value={WordType.VERB}>Verb</option>
<option value={WordType.ADJECTIVE}>Adjective</option>
</select>
</div>
<div>
{word?.sentences.map((sentence, i) => (
<SentenceField index={i + 1} sentence={sentence} key={i} />
)) ?? <SentenceField index={1} sentence={""} />}
</div>
<div>
{word?.definitions.map((definition, i) => (
<DefinitionField index={i + 1} definition={definition} key={i} />
)) ?? <DefinitionField index={1} definition={""} />}
</div>
<button type="submit">Submit</button>
</Form>
);
}
const SentenceField = ({ index, sentence }) => (
<div>
<label htmlFor={`sentence.${index}`}>Sentence #{index}</label>
<textarea
id={`sentence.${index}`}
name="sentence"
defaultValue={sentence}
placeholder={`Sentence #${index}`}
minLength={10}
/>
</div>
);
const DefinitionField = ({ index, definition }) => (
<div>
<label htmlFor={`definition.${index}`}>Definition #{index}</label>
<textarea
id={`definition.${index}`}
name="definition"
defaultValue={definition}
placeholder={`Definition #${index}`}
minLength={10}
/>
</div>
);
add.tsx
and edit.$id.tsx
.// app/routes/words/add.tsx
// other code...
export default function AddWord() {
return <WordForm />;
}
// app/routes/words/edit.$id.tsx
import { useLoaderData } from "remix";
import WordForm from "~/components/WordForm";
import { Word } from "~/models/word";
export default function EditWord() {
const data = useLoaderData<Word>();
return <WordForm word={data} />;
}
WordForm
component, reflecting the change on both routes. NOTE: I'm extracting everything to WordForm.tsx
since it is applicable in my use case.
// app/routes/words/edit.$id.tsx
import { supabase } from "~/libs/supabase-client";
import type {LoaderFunction} from "remix";
export const loader: LoaderFunction = async ({ params }) => {
const { data } = await supabase
.from<Word>("words")
.select("*")
.eq("id", params.id as string)
.single();
return data;
};
// export const EditWord...
// app/routes/words/$id.tsx
// ...
export default function Word() {
// const word ...
return (
<div>
{/* other code... */}
<Form method="get" action={`/words/edit/${word.id}`}>
<button type="submit">Edit</button>
</Form>
</div>
);
}
// app/routes/words/edit$.id.tsx
import {/*other imports*/, redirect} from "remix";
import type {/*other imports*/, ActionFunction} from "remix";
export const action: ActionFunction = async ({ request, params }) => {
const formData = await request.formData();
const id = params.id as string;
const updates = {
type: formData.get("type"),
sentences: formData.getAll("sentence"),
definitions: formData.getAll("definition"),
};
await supabase.from("words").update(updates).eq("id", id);
return redirect(`/words/${id}`);
};
// export const loader...
// export const EditWord...
useTransition
hook, we can add or change something on the screen depending on the route's state.useTransition
import { useTransition } from "remix";
// Somewhere in the route's compnent
let transition = useTransition();
<div>... State: {transition.state}</div>
I'm not saying we should not use JavaScript on the client-side, as JavaScript can do some cool stuff to help with user experience.
For instance, you might notice that the states were stuck in idle
.