Skip to content
4th April 2025: This is a preview, whilst production-ready, it means some APIs might change

Create the Application

Project Generation and Structure

The first thing we’ll do is set up our project.

Terminal window
npx degit redwoodjs/sdk/starters/standard applywize
cd applywize
pnpm install
pnpm dev

Check it out! You’re up and running!

The commands you just ran created a directory called applywize with the RedwoodSDK project structure.

Inside, the applywize directory, you should have the following files and folders:

  • .dev.vars # environment variables for development
  • .env # environment variables
  • .env.example # example environment variables
  • .gitignore # files to ignore in git
  • .prettierrc # prettier configuration
  • .wrangler # Cloudflare configuration
  • Directorymigrations # database migrations
    • 0001_init.sql # initial migration
  • node_modules # dependencies
  • package.json # lists all the dependencies and scripts
  • pnpm-lock.yaml # lock file for pnpm
  • Directoryprisma # prisma configuration
    • schema.prisma # database schema
  • README.md # project overview
  • Directorysrc
    • Directoryapp
      • Document.tsx # Main HTML document
      • headers.ts # Sets up page headers
      • Directorypages
        • Home.tsx # Home page
        • Directoryuser
          • functions.ts # authentication functions
          • Login.tsx # Login page
          • routes.ts # authentication routes
        • Directoryshared
          • links.ts # list of links
    • client.tsx # initializes the client
    • db.ts # database client
    • Directoryscripts
      • seed.ts # seed the database
    • session # sets up sessions for authentication
    • worker.tsx # Cloudflare worker
  • tsconfig.json # TypeScript configuration
  • Directorytypes
    • vite.d.ts # Vite types
  • vite.config.mts # Vite configuration
  • worker-configuration.d.ts # Cloudflare worker configuration
  • wrangler.jsonc # Cloudflare configuration

Setting up our Database

The first time you run pnpm dev, it creates the .wrangler directory. This directory is used by Cloudflare’s Miniflare for storing assets. It contains your local database, caching information, and configuration data.

First, we can create a D1 database that’s connected to our Cloudflare account using the following command:

Terminal window
npx wrangler d1 create applywize

Here, applywize is the name of our database.

Copy the database ID provided and paste it into your project’s wrangler.jsonc file:

wrangler.jsonc
{
"d1_databases": [
{
"binding": "DB",
"database_name": "__change_me__",
"database_id": "__change_me__"
}
]
}

While we’re in our wrangler.jsonc file, let’s also update our worker name:

wrangler.jsonc
"name": "applywize",

and the APP_name:

wrangler.jsonc
"vars": {
"APP_NAME": "applywize"
},

Generating Secret Keys

Next, we need to generate a strong SECRET_KEY for signing session IDs. You can generate a secure random key using OpenSSL. This command will generate a 32-byte random key and encode it as a base64 string.

Terminal window
openssl rand -base64 32

Then set this key as a Cloudflare secret, where SECRET_KEY is the name of the key. It will prompt us for the random string we generated.

Terminal window
npx wrangler secret put SECRET_KEY

For production, set your domain as the RP_ID via Cloudflare secrets:

Terminal window
npx wrangler secret put RP_ID

When prompted, enter your production domain (e.g., my-app.example.com).

Setting up Cloudflare Turnstile

Next, let’s set up Cloudflare Turnstile for bot protection.

  1. Visit Cloudflare Turnstile Dashboard.
  2. Create a new Turnstile widget:
    • Set Widget Mode to Invisible. This will prevent users from having to solve a challenge or see the widget loading bar.
    • Add your application’s hostname to Allowed hostnames, e.g., my-project-name.example.com
    • Mark “Would you like to opt for pre-clearance for this site?” as No.
  3. After you click the Create button, you’ll be redirected to the confirmation screen with your Site Key and Secret Key.
  4. Copy your Site Key and paste it into your application’s Login.tsx. This is defined on line 18.
    src/app/pages/auth/Login.tsx
    const TURNSTILE_SITE_KEY = "<YOUR_SITE_KEY>";
  5. Set your Turnstile Secret Key via Cloudflare secrets for production:
    Terminal window
    npx wrangler secret put TURNSTILE_SECRET_KEY
    When prompted, enter your Turnstile Secret Key.
  6. Our starter kit already has a couple of tables set up, to support the authentication process. This will set up Prisma and create a .wrangler file within our project and setup the Cloudflare d1 database locally. (More on Prisma, migrations, and databases later.)
    Terminal window
    pnpm dev

Setting up TailwindCSS and shadcn/ui

Now, let’s install TailwindCSS and shadcn/ui.

TailwindCSS is a utility-first CSS framework that makes it easy to style your components.

shadcn/ui components is a library of pre-built components making it easier for us to focus on the functionality of our app, rather than the styling and building of components.

TailwindCSS

Since the RedwoodSDK is based on React and Vite, we can work through the “Using Vite” documentation.

  1. Install Tailwind CSS

    Terminal window
    pnpm install tailwindcss @tailwindcss/vite
  2. Configure the Vite Plugin

    vite.config.mts
    import { defineConfig } from "vite";
    import tailwindcss from '@tailwindcss/vite'
    import { redwood } from "@redwoodjs/sdk/vite";
    export default defineConfig({
    plugins: [
    redwood(),
    tailwindcss(),
    ],
    });
  3. Import Tailwind CSS. (You’ll need to create the src/app/styles.css file.)

    src/app/styles.css
    @import "tailwindcss";
  4. Add the Tailwind CSS inside the head tag in the Document.tsx file.

    src/app/Document.tsx
    <head>
    ...
    <link rel="stylesheet" href="/src/app/styles.css" />
    ...
    </head>
  5. Now, you can run pnpm run dev and go to http://localhost:5173/user/login.

    Terminal window
    pnpm run dev

    For reference, before adding TailwindCSS, the login page looked like this:

You can test this even further by going to the src/app/pages/user/Login.tsx file and adding an h1 at the top of the return statement:

src/app/pages/user/Login.tsx
return (
<>
<h1 className="text-4xl font-bold text-red-500">YOLO</h1>
14 collapsed lines
<div ref={turnstile.ref} />
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Username"
/>
<button onClick={handlePerformPasskeyLogin} disabled={isPending}>
{isPending ? <>...</> : "Login with passkey"}
</button>
<button onClick={handlePerformPasskeyRegister} disabled={isPending}>
{isPending ? <>...</> : "Register with passkey"}
</button>
{result && <div>{result}</div>}
</>
);

Setting up Custom Fonts

First, let’s add the fonts that we’ll need for our project. We’re using Poppins and Inter, both can be found on Google Fonts.

From the font specimen page, click on the “Get Font” button.

Once both fonts have been added, click on the “View selected families” button on the top right. Then, click on the “Get Embed Code” button.

Next, let’s only select the font weights that we’ll need for our project.

Under Poppins, click on the “Change Styles” button. Turn everything off except for “500” and “700”.

Under Inter, if you click on the “Change Styles” button, you’ll see the settings are slightly different. That’s because this is variable font. A variable font is a single font file that contains multiple variations of a typeface, allowing for dynamic manipulation of the font. Meaning, nothing to do here.

Next, select the @import radio button and copy the code.

Paste the code at the top of our styles.css file. Then, remove the <style> tags:

src/app/styles.css
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@500;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap');
@import "tailwindcss";

Next, we need to add a custom configuration. In TailwindCSS v4, all customizations happen in the CSS file, not tailwind.config.js.

Below the @import "tailwindcss"; line, add the following:

src/app/styles.css
@theme {
--font-display: "Poppins", sans-serif;
--font-body: "Inter", sans-serif;
}

If you’re unsure how the font-family is written, you can find it in the CSS class definition on the Google Fonts page. Here, Poppins and Inter are both in quotes and capitalized.

Now, you can use the class font-display and font-body in your project.

Setting up our Custom Color Palette

Defining colors is similar, except we’ll prepend each color value with --color-.

src/app/styles.css
@theme {
...
--color-bg: #e4e3d4;
--color-border: #eeeef0;
--color-primary: #f7b736;
--color-secondary: #f1f1e8;
--color-destructive: #ef533f;
--color-tag-applied: #b1c7c0;
--color-tag-interview: #da9b7c;
--color-tag-new: #db9a9f;
--color-tag-rejected: #e4e3d4;
--color-tag-offer: #aae198;
}

We're using three different groups of colors:

  • semantically named colors: background and border
  • buttons: primary, secondary, and destructive
  • tag colors: applied, interview, new, rejected, and offer

Now, we can use these colors for backgrounds (bg-bg), borders (border-border), and text (text-destructive).

Full styles.css file

src/app/styles.css
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@500;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap');
@import "tailwindcss";
@theme {
--font-display: "Poppins", sans-serif;
--font-body: "Inter", sans-serif;
--color-bg: #e4e3d4;
--color-border: #eeeef0;
--color-primary: #f7b736;
--color-secondary: #f1f1e8;
--color-destructive: #ef533f;
--color-tag-applied: #b1c7c0;
--color-tag-interview: #da9b7c;
--color-tag-new: #db9a9f;
--color-tag-rejected: #e4e3d4;
--color-tag-offer: #aae198;
}

Let’s test to make sure our custom colors are working. On the Login.tsx file, let’s change the React fragment <></> to a <main> tag and add a className of bg-bg:

src/app/pages/user/Login.tsx
...
return (
<main className="bg-bg">
15 collapsed lines
<h1 className="text-4xl font-bold text-red-500">YOLO</h1>
<div ref={turnstile.ref} />
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Username"
/>
<Button onClick={handlePerformPasskeyLogin} disabled={isPending}>
{isPending ? <>...</> : "Login with passkey"}
</Button>
<Button onClick={handlePerformPasskeyRegister} disabled={isPending}>
{isPending ? <>...</> : "Register with passkey"}
</Button>
{result && <div>{result}</div>}
</main>
);
}

When you visit https://localhost:5173/user/login, you should see a beige background color:

shadcn/ui UI Components

You can also use the shadcn/ui Vite Installation instructions.

  1. Install shadcn/ui

    Terminal window
    pnpm dlx shadcn@latest init

    It will ask you what theme you want to use. Let’s go with Neutral.

    This command will create a components.json file in the root of your project. It contains all the configuration for our shadcn/ui components.

    Let’s modify the default paths so that it will put our components in the src/app/components/ui folder.

    components.json
    ...
    "aliases": {
    "components": "@/app/components",
    "utils": "@/app/lib/utils",
    "ui": "@/app/components/ui",
    "lib": "@/app/lib",
    "hooks": "@/app/hooks"
    },
  2. Add path aliases to tsconfig.json:

    tsconfig.json
    {
    "compilerOptions": {
    "baseUrl": ".",
    "paths": {
    "@/*": ["./src/*"]
    }
    }
    }
  3. Install @types/node

    Terminal window
    pnpm add -D @types/node
  4. Add resolve alias config to vite.config.mts:

    vite.config.mts
    import path from "path"
    import { defineConfig } from "vite";
    import tailwindcss from "@tailwindcss/vite"
    import { redwood } from "@redwoodjs/sdk/vite";
    export default defineConfig({
    plugins: [redwood(), tailwindcss()],
    resolve: {
    alias: {
    "@": path.resolve(__dirname, "./src"),
    },
    },
    )
  5. You should now be able to add components:

    Terminal window
    pnpm dlx shadcn@latest add

    Select the following by hitting the Space key:

    • Alert
    • Avatar
    • Badge
    • Breadcrumb
    • Button
    • Calendar
    • Dialog
    • Popover
    • Select
    • Sheet
    • Sonner
    • Table

    When you’ve selected all the components you want to add, hit Enter. This will add all the components inside the src/app/components/ui folder.

    This will install all of our components into the src/app/components/ui folder.

Even though we specified the path to the lib directory in the components.json file, the script still placed the folder inside the src directory. You’ll need to move it into the app directory.

  • Directorysrc/
    • Directoryapp/
      • Directorycomponents/
        • Directoryui/
      • Directorylib/
        • utils.ts

We also need the date picker component, but it can’t be installed using the pnpm dlx shadcn@latest add command.

Instead, we’ll need to install it manually. It’s built using the <Popover /> and <Calendar /> components and we just installed those.

Within the src/app/components/ui folder, create a new file called datepicker.tsx.

Add the following code to the datepicker.tsx file:

src/app/components/ui/datepicker.tsx
"use client"
import * as React from "react"
import { format } from "date-fns"
import { Calendar as CalendarIcon } from "lucide-react"
import { cn } from "@/app/lib/utils"
import { Button } from "@/app/components/ui/button"
import { Calendar } from "@/app/components/ui/calendar"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/app/components/ui/popover"
export function DatePicker() {
const [date, setDate] = React.useState<Date>()
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant={"outline"}
className={cn(
"w-[280px] justify-start text-left font-normal",
!date && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{date ? format(date, "PPP") : <span>Pick a date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={date}
onSelect={setDate}
initialFocus
/>
</PopoverContent>
</Popover>
)
}
  • Official Date Picker documentation.
  • If you copy and paste directly from the shadcn/ui documentation, though, you’ll find a few errors. In the code I provided, I adjusted the paths on some of the @import statements.
import { cn } from "@/lib/utils"
import { cn } from "@/app/lib/utils"
import { Button } from "@/components/ui/button"
import { Button } from "@/app/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { Calendar } from "@/app/components/ui/calendar"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/app/components/ui/popover"
  • I also changed the component name to DatePicker:
src/app/components/ui/datepicker.tsx
export function DatePickerDemo() {
export function DatePicker() {

If you want to make sure everything is installed correctly, head over to the src/app/pages/user/Login.tsx file and let’s use our Button component.

Change the <button> tag to use a capital B. This will reference the Button component, instead of the standard HTML button. You’ll also need to import the Button component at the top of the file.

src/app/pages/auth/Login.tsx
import { Button } from "@/app/components/ui/button";
...
return (
<>
8 collapsed lines
<h1 className="text-4xl font-bold text-red-500">YOLO</h1>
<div ref={turnstile.ref} />
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Username"
/>
<Button onClick={handlePerformPasskeyLogin} disabled={isPending}>
{isPending ? <>...</> : "Login with passkey"}
</Button>
<Button onClick={handlePerformPasskeyRegister} disabled={isPending}>
{isPending ? <>...</> : "Register with passkey"}
</Button>
{result && <div>{result}</div>}
</>
);

Now, when you visit https://localhost:5173/user/login, you should see styled buttons:

Perfect, now that we have our project setup, and all the frontend components installed, let’s start building the backend.

Further reading