npx create-remix@latest ./my-app --template echobind/bison-remix
- Built with Remix
- Database with Postgres and Prisma
- Styled with Tailwind
- Components from shadcn/ui and built with Radix
- Icons from Sly
- Forms validated with Conform and Zod
- Auth with remix-auth
- i18n with remix-i18next
- Testing with Vitest, React Testing Library, and Playwright
- Built with Typescript, eslint, and Prettier
This checklist and mini-tutorial will make sure you make the most of your shiny new Bison Remix app.
- Run
npm run setup:dev
to prep and migrate your local database, as well as generate the prisma client. If this fails, make sure you have Postgres running and the generatedDATABASE_URL
values are correct in your.env
files. - Run
npm run dev
to start your development server
While not a requirement, Bison works best when you start development with the database and API layer.
We will illustrate how to use this by adding the concept of an organization to our app.
The workflow below assumes you already have npm run dev
running.
Bison uses Prisma for database operations. We've added a few conveniences around the default Prisma setup, but if you're familiar with Prisma, you're familiar with databases in Bison.
- Define an Organization table in
prisma/schema.prisma
.
We suggest copying the id
, createdAt
and updatedAt
fields from the User
model.
model Organization {
id String @id @default(cuid())
name String
users User[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
If you use VSCode and have the Prisma extension
installed, saving the file should automatically add the inverse relationship to the User
model!
model User {
id String @id @default(cuid())
email String @unique
password String
roles Role[]
profile Profile?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization? @relation(fields: [organizationId], references: [id])
organizationId String?
}
- Generate a migration with
npm run db:migrate
.
You should see a new folder in prisma/migrations
and the migration should have been performed.
For more on Prisma, view the docs.
Now that we have the API finished, we can move to the frontend changes.
- Create a new route to create organizations in
app/routes/_main.organization.create.tsx
- Create an
OrganizationForm
route component. - Add a simple form with a name input. See the Conform docs for detailed information.
We'll use zod to ensure type safety form inputs.
// app/routes/_main.organization.create.tsx
import { z } from "zod";
import { conform, useForm } from "@conform-to/react";
import { getFieldsetConstraint, parse } from "@conform-to/zod";
import { Form, useActionData } from "@remix-run/react";
import { useIsPending } from "~/utils/misc";
import { ErrorList } from "~/components/ui/error-list";
import { Label } from "~/components/ui/label";
import { Input } from "~/components/ui/input";
import { StatusButton } from "~/components/ui/status-button";
const OrganizationFormSchema = z.object({
name: z
.string({ required_error: "Name is required" })
.min(3, { message: "Name is too short" })
.max(100, { message: "Name is too long" }),
});
export default function OrganizationForm() {
const actionData = useActionData<typeof action>();
const isPending = useIsPending();
const [form, fields] = useForm({
id: "organization-form",
constraint: getFieldsetConstraint(OrganizationFormSchema),
lastSubmission: actionData?.submission,
onValidate({ formData }) {
return parse(formData, { schema: OrganizationFormSchema });
},
shouldRevalidate: "onBlur",
});
return (
<Form method="post" className="flex flex-col gap-6" {...form.props}>
<div>
<Label htmlFor="organization-form-name">Name</Label>
<Input
{...conform.input(fields.name)}
autoFocus
isInvalid={!!fields.name.errors?.length}
/>
<div className="min-h-[32px] px-4 pb-3 pt-1">
{fields.name.errors?.length ? (
<ErrorList errors={fields.name.errors} />
) : null}
</div>
</div>
<StatusButton
className="w-full"
status={isPending ? "pending" : actionData?.status ?? "idle"}
type="submit"
disabled={isPending}
>
Create
</StatusButton>
<ErrorList errors={form.errors} id={form.errorId} />
</Form>
);
}
- Add a loader to make sure the user is authenticated.
// app/routes/_main.organization.create.tsx
import { authenticator } from "~/utils/auth.server";
// ...
export async function loader({ request }: DataFunctionArgs) {
await authenticator.isAuthenticated(request, { failureRedirect: "/login" });
return {};
}
// ...
- Add an action to perform validation and create the organization;
// app/routes/_main.organization.create.tsx
import { DataFunctionArgs, json, redirect } from "@remix-run/node";
import { getFieldsetConstraint, parse } from "@conform-to/zod";
import { prisma } from "~/utils/db.server";
// ...
export async function action({ request }: DataFunctionArgs) {
const formData = await request.formData();
const submission = await parse(formData, {
schema: OrganizationFormSchema,
});
if (!submission.value || submission.intent !== "submit") {
return json({ status: "error", submission } as const);
}
const user = await authenticator.isAuthenticated(request, {
failureRedirect: "/login",
});
const org = await prisma.organization.create({
data: {
name: submission.value.name,
users: { connect: [{ id: user.id }] },
},
select: { id: true },
});
return redirect(`/organization/${org.id}`);
}
// ...
You should now have a fully working form that creates a new database entry on submit!
- Generate a new route
app/routes/_main.organization.$id.tsx
. - Create a loader to display the organization.
- Render the loader data to the component.
- Add an error boundary to handle the not found and error cases.
// app/routes/_main.organization.$id.tsx
import {
useLoaderData,
isRouteErrorResponse,
useRouteError,
} from "@remix-run/react";
import { DataFunctionArgs, MetaFunction, json } from "@remix-run/node";
import { prisma } from "~/utils/db.server";
export async function loader({ params }: DataFunctionArgs) {
const organization = await prisma.organization.findUnique({
where: { id: params.id },
});
if (!organization) throw new Response("Not Found", { status: 404 });
return json({ organization });
}
export const meta: MetaFunction<typeof loader> = ({ data }) => {
return [{ title: `An organization named ${data?.organization.name}` }];
};
export default function OrganizationPage() {
const { organization } = useLoaderData<typeof loader>();
return <span>Awesome! {organization.name}</span>;
}
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div>
<h1>
{error.status} {error.statusText}
</h1>
<p>{error.data}</p>
</div>
);
} else if (error instanceof Error) {
return (
<div>
<h1>Error</h1>
<p>{error.message}</p>
<p>The stack trace is:</p>
<pre>{error.stack}</pre>
</div>
);
} else {
return <h1>Unknown Error</h1>;
}
}
Outside of e2e tests, you've used just about every feature in Bison. But don't worry. We've got your back there too.
Bonus:
- View the login and logout e2e tests