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
- Loaders run in parallel
- Remix loads only what's needed
- Mutations trigger invalidation of all loaders
- Context for shared UI state & Remix for shared server state
Nested Routing
- Declarative Error Boundaries
- No isLoading worries
- 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!
How I Built a Modern Website in 2021 (full)
By Kent C. Dodds
How I Built a Modern Website in 2021 (full)
I just released a complete rewrite of my website. This isn't your regular developer blog though. You can actually log in, record some audio for a podcast, choose a "team" to be a part of, connect with discord, and much more. I'm using some of the coolest modern tech around including React, Remix, Prisma, Postgres, Redis, Fly.io, tailwind, TypeScript, and more! I want to take you on a tour of some of the highlights and talk about some of the problems I faced and decisions I had to make while building a brand new modern website in 2021.
- 2,859