How I Built a

Modern Website in 2021

Kent C. Dodds

Let's wake up

Your brian needs this 🧠

What this talk is

  • A high-level tour of a real-world modern website and the tools used to build it.

What this talk is not

  • Contrived

Let's
Get
STARTED!

What kentcdodds.com is

General stats

  • 27k lines of code
  • PM, Designer, Illustrator, UI Dev + me + some other contributors (/credits)

Tech overview

Client

Server

type LoaderData = Await<ReturnType<typeof getLoaderData>>

async function getLoaderData() {
  const users = await prisma.user.findMany({
    select: {
      id: true,
      email: true,
      firstName: true,
      team: true,
      postReads: {
        select: {
          postSlug: true,
        },
      },
    },
  })
  return {users}
}

export const loader: LoaderFunction = async ({request}) => {
  return json(await getLoaderData())
}

export default function UsersPage() {
  const data = useLoaderData<LoaderData>()
  return (
    <div>
      <h1>Users</h1>
      <ul>
        {/* all this auto-completes and type checks!! */}
        {data.users.map(user => (
          <li key={user.id}>
            <div>{user.firstName}</div>
          </li>
        ))}
      </ul>
    </div>
  )
}
  • fetch Request/Response
  • <Form />
  • <link rel="modulepreload" />
  • <link rel="prefetch" as="fetch" />

CSS

import type {LinksFunction} from 'remix'
import aboutStyles from '~/styles/routes/about.css'

export const links: LinksFunction = () => {
  return [{rel: 'stylesheet', href: aboutStyles}]
}

export default function AboutScreen() {
  return <stuff />
}

Client-side server state cache management

  1. Loaders run in parallel
  2. Remix loads only what's needed
  3. Mutations trigger invalidation of all loaders
  4. Context for shared UI state & Remix for shared server state

Nested Routing

  1. Declarative Error Boundaries
  2. No isLoading worries
  3. No <Layout /> component

Architecture overview

Local Development

node .
node --require ./mocks .

MSW to the rescue!

Caching

Caching to the rescue!

// here's an example of the cachified credits.yml
// that powers the /credits page:
async function getPeople({
  request,
  forceFresh,
}: {
  request?: Request
  forceFresh?: boolean
}) {
  const allPeople = await cachified({
    cache: redisCache,
    key: 'content:data:credits.yml',
    request,
    forceFresh,
    maxAge: 1000 * 60 * 60 * 24 * 30,
    getFreshValue: async () => {
      const creditsString = await downloadFile(
        'content/data/credits.yml',
      )
      const rawCredits = YAML.parse(creditsString)
      if (!Array.isArray(rawCredits)) {
        console.error('Credits is not an array', rawCredits)
        throw new Error('Credits is not an array.')
      }

      return rawCredits.map(mapPerson).filter(typedBoolean)
    },
    checkValue: (value: unknown) => Array.isArray(value),
  })
  return allPeople
}
type CacheMetadata = {
  createdTime: number
  maxAge: number | null
}

type VNUP<Value> =
  | Value
  | null
  | undefined
  | Promise<Value | null | undefined>

async function cachified<
  Value,
  Cache extends {
    name: string
    get: (key: string) => VNUP<{
      metadata: CacheMetadata
      value: Value
    }>
    set: (
      key: string,
      value: {
        metadata: CacheMetadata
        value: Value
      },
    ) => unknown | Promise<unknown>
    del: (key: string) => unknown | Promise<unknown>
  },
>(options: {
  key: string
  cache: Cache
  getFreshValue: () => Promise<Value>
  checkValue?: (value: Value) => boolean | string
  forceFresh?: boolean
  request?: Request
  fallbackToCache?: boolean
  timings?: Timings
  timingType?: string
  maxAge?: number
}): Promise<Value> {
  // do the stuff
}

Image Optimization with Cloudinary   

<img
  {...otherProps}
  src="https://res.cloudinary.com/kentcdodds-com/image/upload/w_1517,q_auto,f_auto,b_rgb:e6e9ee/unsplash/photo-1459262838948-3e2de6c1ec80"
  srcset="
    https://res.cloudinary.com/kentcdodds-com/image/upload/w_280,q_auto,f_auto,b_rgb:e6e9ee/unsplash/photo-1459262838948-3e2de6c1ec80   280w,
    https://res.cloudinary.com/kentcdodds-com/image/upload/w_560,q_auto,f_auto,b_rgb:e6e9ee/unsplash/photo-1459262838948-3e2de6c1ec80   560w,
    https://res.cloudinary.com/kentcdodds-com/image/upload/w_840,q_auto,f_auto,b_rgb:e6e9ee/unsplash/photo-1459262838948-3e2de6c1ec80   840w,
    https://res.cloudinary.com/kentcdodds-com/image/upload/w_1100,q_auto,f_auto,b_rgb:e6e9ee/unsplash/photo-1459262838948-3e2de6c1ec80 1100w,
    https://res.cloudinary.com/kentcdodds-com/image/upload/w_1650,q_auto,f_auto,b_rgb:e6e9ee/unsplash/photo-1459262838948-3e2de6c1ec80 1650w,
    https://res.cloudinary.com/kentcdodds-com/image/upload/w_2500,q_auto,f_auto,b_rgb:e6e9ee/unsplash/photo-1459262838948-3e2de6c1ec80 2500w,
    https://res.cloudinary.com/kentcdodds-com/image/upload/w_2100,q_auto,f_auto,b_rgb:e6e9ee/unsplash/photo-1459262838948-3e2de6c1ec80 2100w,
    https://res.cloudinary.com/kentcdodds-com/image/upload/w_3100,q_auto,f_auto,b_rgb:e6e9ee/unsplash/photo-1459262838948-3e2de6c1ec80 3100w
  "
  sizes="
    (max-width:1023px) 80vw,
    (min-width:1024px) and (max-width:1620px) 67vw,
    1100px
  "
/>
<meta
  name="twitter:image"
  content="
    https://res.cloudinary.com/kentcdodds-com/image/upload
    /$th_1256,$tw_2400,$gw_$tw_div_24,$gh_$th_div_12
    /co_rgb:a9adc1,c_fit,g_north_west,w_$gw_mul_14,h_$gh,x_$gw_mul_1.5,y_$gh_mul_1.3,l_text:kentcdodds.com:Matter-Regular.woff2_50:Checkout%2520this%2520article
    /co_white,c_fit,g_north_west,w_$gw_mul_13.5,h_$gh_mul_7,x_$gw_mul_1.5,y_$gh_mul_2.3,l_text:kentcdodds.com:Matter-Regular.woff2_110:Don't%2520Solve%2520Problems%252C%2520Eliminate%2520Them
    /c_fit,g_north_west,r_max,w_$gw_mul_4,h_$gh_mul_3,x_$gw,y_$gh_mul_8,l_kent:profile-transparent
    /co_rgb:a9adc1,c_fit,g_north_west,w_$gw_mul_5.5,h_$gh_mul_4,x_$gw_mul_4.5,y_$gh_mul_9,l_text:kentcdodds.com:Matter-Regular.woff2_70:Kent%20C.%20Dodds
    /co_rgb:a9adc1,c_fit,g_north_west,w_$gw_mul_9,x_$gw_mul_4.5,y_$gh_mul_9.8,l_text:kentcdodds.com:Matter-Regular.woff2_40:kentcdodds.com%252Fblog
    /c_fill,ar_3:4,r_12,g_east,h_$gh_mul_10,x_$gw,l_unsplash:photo-1459262838948-3e2de6c1ec80
    /c_fill,w_$tw,h_$th/kentcdodds.com/social-background.png
  "
/>

=

+

Migrations

-- prisma migrate dev --name user_roles

-- CreateEnum
CREATE TYPE "Role" AS ENUM ('ADMIN', 'MEMBER');

-- AlterTable
ALTER TABLE "User" ADD COLUMN     "role" "Role" DEFAULT E'MEMBER';

-- Manually written stuff:

-- Update all users to be members:
update "User" set role = E'MEMBER';

-- update me@kentcdodds.com to be ADMIN:
update "User" set role = E'ADMIN' where email = 'me@kentcdodds.com';

-- make role required
ALTER TABLE "User" ALTER COLUMN "role" SET NOT NULL;
-- prisma migrate dev --name init

-- CreateEnum
CREATE TYPE "Team" AS ENUM ('BLUE', 'RED', 'YELLOW');

-- CreateTable
CREATE TABLE "User" (
    "id" TEXT NOT NULL,
    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "updatedAt" TIMESTAMP(3) NOT NULL,
    "email" TEXT NOT NULL,
    "firstName" TEXT NOT NULL,
    "team" "Team" NOT NULL,

    PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "Session" (
    "id" TEXT NOT NULL,
    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "userId" TEXT NOT NULL,
    "expirationDate" TIMESTAMP(3) NOT NULL,

    PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "Call" (
    "id" TEXT NOT NULL,
    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "updatedAt" TIMESTAMP(3) NOT NULL,
    "title" TEXT NOT NULL,
    "description" TEXT NOT NULL,
    "userId" TEXT NOT NULL,
    "base64" TEXT NOT NULL,

    PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "PostRead" (
    "id" TEXT NOT NULL,
    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "userId" TEXT NOT NULL,
    "postSlug" TEXT NOT NULL,

    PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "User.email_unique" ON "User"("email");

-- AddForeignKey
ALTER TABLE "Session" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "Call" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "PostRead" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- prisma migrate dev --name call_kent_episode_id

-- AlterTable
ALTER TABLE "Call" ADD COLUMN     "episodeId" TEXT;
-- prisma migrate dev --name remove_episode_id_and_add_keywords

/*
  Warnings:

  - You are about to drop the column `episodeId` on the `Call` table. All the data in the column will be lost.

*/
-- AlterTable
ALTER TABLE "Call" DROP COLUMN "episodeId",
ADD COLUMN     "keywords" TEXT DEFAULT E'', -- first autofill all keywords with an empty string
ALTER COLUMN "keywords" SET NOT NULL; -- then set it to not null

TypeScript

// source
const users = await prisma.user.findMany({
  select: {
    id: true,
    email: true,
    firstName: true,
  },
})
// types
const users: Array<{
  id: string
  email: string
  firstName: string
}>
// source
const users = await prisma.user.findMany({
  select: {
    id: true,
    email: true,
    firstName: true,
    team: true, // <-- new field
  },
})
// types
const users: Array<{
  id: string
  email: string
  firstName: string
  team: Team // <-- field appears!
}>
// source
const users = await prisma.user.findMany({
  select: {
    id: true,
    email: true,
    firstName: true,
    team: true,
    postReads: {
      select: {
        postSlug: true,
      },
    },
  },
})
// types
const users: Array<{
  id: string
  email: string
  firstName: string
  team: Team
  postReads: Array<{
    postSlug: string
  }>
}>

You also get:

distinct, include, skip, take, orderBy, where, etc.

All with autocomplete/TypeSafety 🤯

Authentication ✨

🤦‍♂️

Thank you!