Complete Guide to shadcn/ui with Next.js: Building Modern Interfaces

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

Introduction

shadcn/ui has become the go-to solution for building modern interfaces with React and Next.js. Unlike traditional component libraries like Material UI or Chakra UI, shadcn/ui takes a radically different approach: instead of installing an npm package, you copy components directly into your project. This gives you full control over the code, styling, and behavior of every component.

In this tutorial, you will learn how to set up shadcn/ui in a Next.js project, customize the theme, build complex components like forms and data tables, and apply best practices for creating professional-grade applications.

What You Will Learn

  • Install and configure shadcn/ui in a Next.js project
  • Use the most common components (Button, Card, Dialog, Form)
  • Customize the theme with CSS variables
  • Build a complete form with validation using React Hook Form and Zod
  • Create an interactive data table with sorting and filtering
  • Implement dark mode
  • Organize your components in a maintainable way

Prerequisites

Before starting, make sure you have:

  • Node.js 18+ installed on your machine
  • Basic knowledge of React and TypeScript
  • Familiarity with Tailwind CSS
  • A code editor (VS Code recommended)

Step 1: Create the Next.js Project

Start by creating a new Next.js project with TypeScript and Tailwind CSS:

npx create-next-app@latest my-shadcn-app --typescript --tailwind --eslint --app --src-dir
cd my-shadcn-app

Select these options during setup:

  • Would you like to use App Router? Yes
  • Would you like to customize the default import alias? Yes, use @/*

Step 2: Install shadcn/ui

Run the shadcn/ui initialization command:

npx shadcn@latest init

The setup wizard will ask several questions:

Which style would you like to use? › New York
Which color would you like to use as base color? › Neutral
Do you want to use CSS variables for colors? › yes

This command will:

  • Create a components.json file at the project root
  • Add necessary utilities in lib/utils.ts
  • Configure CSS variables in your globals.css file
  • Update tailwind.config.ts with component paths

Let's examine the generated components.json:

{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "new-york",
  "rsc": true,
  "tsx": true,
  "tailwind": {
    "config": "tailwind.config.ts",
    "css": "src/app/globals.css",
    "baseColor": "neutral",
    "cssVariables": true
  },
  "aliases": {
    "components": "@/components",
    "utils": "@/lib/utils",
    "ui": "@/components/ui",
    "lib": "@/lib",
    "hooks": "@/hooks"
  }
}

Step 3: Add Your First Components

shadcn/ui provides a CLI to add components individually. Let's add the essentials:

npx shadcn@latest add button card input label

Each component is copied into src/components/ui/. Let's look at the Button component:

// src/components/ui/button.tsx
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
 
const buttonVariants = cva(
  "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
        destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
        outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
        secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
        ghost: "hover:bg-accent hover:text-accent-foreground",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default: "h-9 px-4 py-2",
        sm: "h-8 rounded-md px-3 text-xs",
        lg: "h-10 rounded-md px-8",
        icon: "h-9 w-9",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)
 
export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean
}
 
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : "button"
    return (
      <Comp
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    )
  }
)
Button.displayName = "Button"
 
export { Button, buttonVariants }

The pattern is clear: each component uses cva (Class Variance Authority) to manage variants and cn (a wrapper around clsx and tailwind-merge) to merge CSS classes.

Step 4: Build a Page with Components

Let's modify the home page to use our components:

// src/app/page.tsx
import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
 
export default function Home() {
  return (
    <main className="container mx-auto py-10">
      <h1 className="text-4xl font-bold mb-8">
        My shadcn/ui Application
      </h1>
 
      <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
        <Card>
          <CardHeader>
            <CardTitle>Accessible Components</CardTitle>
            <CardDescription>
              Built on Radix UI, all components are
              accessible by default.
            </CardDescription>
          </CardHeader>
          <CardContent>
            <p className="text-sm text-muted-foreground">
              Keyboard navigation, screen readers, and
              ARIA compliance built in.
            </p>
          </CardContent>
          <CardFooter>
            <Button>Learn More</Button>
          </CardFooter>
        </Card>
 
        <Card>
          <CardHeader>
            <CardTitle>Customizable</CardTitle>
            <CardDescription>
              The code lives in your project. Modify
              everything to suit your needs.
            </CardDescription>
          </CardHeader>
          <CardContent>
            <p className="text-sm text-muted-foreground">
              CSS variables, Tailwind, and fully
              editable components.
            </p>
          </CardContent>
          <CardFooter>
            <Button variant="outline">Explore</Button>
          </CardFooter>
        </Card>
 
        <Card>
          <CardHeader>
            <CardTitle>Native TypeScript</CardTitle>
            <CardDescription>
              Strict types and autocomplete for an
              optimal developer experience.
            </CardDescription>
          </CardHeader>
          <CardContent>
            <p className="text-sm text-muted-foreground">
              Every component is fully typed with
              well-defined props.
            </p>
          </CardContent>
          <CardFooter>
            <Button variant="secondary">Discover</Button>
          </CardFooter>
        </Card>
      </div>
    </main>
  )
}

Start the development server to see the result:

npm run dev

Step 5: Customize the Theme

The shadcn/ui theming system is based on CSS variables. Open src/app/globals.css to see the generated variables:

@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 0 0% 3.9%;
    --card: 0 0% 100%;
    --card-foreground: 0 0% 3.9%;
    --popover: 0 0% 100%;
    --popover-foreground: 0 0% 3.9%;
    --primary: 0 0% 9%;
    --primary-foreground: 0 0% 98%;
    --secondary: 0 0% 96.1%;
    --secondary-foreground: 0 0% 9%;
    --muted: 0 0% 96.1%;
    --muted-foreground: 0 0% 45.1%;
    --accent: 0 0% 96.1%;
    --accent-foreground: 0 0% 9%;
    --destructive: 0 84.2% 60.2%;
    --destructive-foreground: 0 0% 98%;
    --border: 0 0% 89.8%;
    --input: 0 0% 89.8%;
    --ring: 0 0% 3.9%;
    --radius: 0.5rem;
  }
 
  .dark {
    --background: 0 0% 3.9%;
    --foreground: 0 0% 98%;
    --primary: 0 0% 98%;
    --primary-foreground: 0 0% 9%;
    /* ... other dark variables */
  }
}

To create a custom theme, modify these variables. For example, a professional blue theme:

:root {
  --primary: 221.2 83.2% 53.3%;
  --primary-foreground: 210 40% 98%;
  --secondary: 210 40% 96.1%;
  --secondary-foreground: 222.2 47.4% 11.2%;
  --accent: 210 40% 96.1%;
  --accent-foreground: 222.2 47.4% 11.2%;
  --radius: 0.75rem;
}

You can also use the shadcn/ui online theme generator to visually create your color palette and copy the generated CSS variables.

Step 6: Form with Validation

One of the most common use cases is building forms. shadcn/ui integrates seamlessly with React Hook Form and Zod for validation.

Install the necessary dependencies:

npx shadcn@latest add form select textarea toast
npm install zod

Create a complete contact form:

// src/components/contact-form.tsx
"use client"
 
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import { useToast } from "@/hooks/use-toast"
 
const formSchema = z.object({
  name: z
    .string()
    .min(2, "Name must be at least 2 characters"),
  email: z
    .string()
    .email("Invalid email address"),
  subject: z
    .string()
    .min(1, "Please select a subject"),
  message: z
    .string()
    .min(10, "Message must be at least 10 characters")
    .max(500, "Message must not exceed 500 characters"),
})
 
type FormValues = z.infer<typeof formSchema>
 
export function ContactForm() {
  const { toast } = useToast()
 
  const form = useForm<FormValues>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      name: "",
      email: "",
      subject: "",
      message: "",
    },
  })
 
  function onSubmit(values: FormValues) {
    console.log(values)
    toast({
      title: "Message sent!",
      description: "We will get back to you within 24 hours.",
    })
    form.reset()
  }
 
  return (
    <Form {...form}>
      <form
        onSubmit={form.handleSubmit(onSubmit)}
        className="space-y-6"
      >
        <FormField
          control={form.control}
          name="name"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Full Name</FormLabel>
              <FormControl>
                <Input placeholder="John Doe" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
 
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input
                  type="email"
                  placeholder="john@example.com"
                  {...field}
                />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
 
        <FormField
          control={form.control}
          name="subject"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Subject</FormLabel>
              <Select
                onValueChange={field.onChange}
                defaultValue={field.value}
              >
                <FormControl>
                  <SelectTrigger>
                    <SelectValue placeholder="Choose a subject" />
                  </SelectTrigger>
                </FormControl>
                <SelectContent>
                  <SelectItem value="general">
                    General Inquiry
                  </SelectItem>
                  <SelectItem value="support">
                    Technical Support
                  </SelectItem>
                  <SelectItem value="sales">
                    Sales Inquiry
                  </SelectItem>
                  <SelectItem value="partnership">
                    Partnership
                  </SelectItem>
                </SelectContent>
              </Select>
              <FormMessage />
            </FormItem>
          )}
        />
 
        <FormField
          control={form.control}
          name="message"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Message</FormLabel>
              <FormControl>
                <Textarea
                  placeholder="Describe your request..."
                  className="min-h-[120px]"
                  {...field}
                />
              </FormControl>
              <FormDescription>
                Between 10 and 500 characters.
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
 
        <Button type="submit" className="w-full">
          Send Message
        </Button>
      </form>
    </Form>
  )
}

This form provides:

  • Client-side validation with clear error messages
  • TypeScript types automatically inferred from the Zod schema
  • Accessibility: associated labels, error messages linked to fields
  • User feedback via toast notifications

Step 7: Interactive Data Table

Data tables are another frequently used component. shadcn/ui provides a Table component that integrates with TanStack Table for sorting, filtering, and pagination.

npx shadcn@latest add table badge dropdown-menu
npm install @tanstack/react-table

Create a data table to display a list of users:

// src/components/users-table.tsx
"use client"
 
import { useState } from "react"
import {
  ColumnDef,
  flexRender,
  getCoreRowModel,
  getFilteredRowModel,
  getSortedRowModel,
  SortingState,
  useReactTable,
} from "@tanstack/react-table"
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ArrowUpDown } from "lucide-react"
 
type User = {
  id: string
  name: string
  email: string
  role: "admin" | "editor" | "viewer"
  status: "active" | "inactive"
}
 
const data: User[] = [
  {
    id: "1",
    name: "Alice Martin",
    email: "alice@example.com",
    role: "admin",
    status: "active",
  },
  {
    id: "2",
    name: "Bob Johnson",
    email: "bob@example.com",
    role: "editor",
    status: "active",
  },
  {
    id: "3",
    name: "Claire Williams",
    email: "claire@example.com",
    role: "viewer",
    status: "inactive",
  },
]
 
const columns: ColumnDef<User>[] = [
  {
    accessorKey: "name",
    header: ({ column }) => (
      <Button
        variant="ghost"
        onClick={() =>
          column.toggleSorting(column.getIsSorted() === "asc")
        }
      >
        Name
        <ArrowUpDown className="ml-2 h-4 w-4" />
      </Button>
    ),
  },
  {
    accessorKey: "email",
    header: "Email",
  },
  {
    accessorKey: "role",
    header: "Role",
    cell: ({ row }) => {
      const role = row.getValue("role") as string
      const variant =
        role === "admin"
          ? "default"
          : role === "editor"
            ? "secondary"
            : "outline"
      return <Badge variant={variant}>{role}</Badge>
    },
  },
  {
    accessorKey: "status",
    header: "Status",
    cell: ({ row }) => {
      const status = row.getValue("status") as string
      return (
        <Badge
          variant={status === "active" ? "default" : "destructive"}
        >
          {status}
        </Badge>
      )
    },
  },
]
 
export function UsersTable() {
  const [sorting, setSorting] = useState<SortingState>([])
  const [globalFilter, setGlobalFilter] = useState("")
 
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    onSortingChange: setSorting,
    onGlobalFilterChange: setGlobalFilter,
    state: { sorting, globalFilter },
  })
 
  return (
    <div className="space-y-4">
      <Input
        placeholder="Search users..."
        value={globalFilter}
        onChange={(e) => setGlobalFilter(e.target.value)}
        className="max-w-sm"
      />
      <div className="rounded-md border">
        <Table>
          <TableHeader>
            {table.getHeaderGroups().map((headerGroup) => (
              <TableRow key={headerGroup.id}>
                {headerGroup.headers.map((header) => (
                  <TableHead key={header.id}>
                    {header.isPlaceholder
                      ? null
                      : flexRender(
                          header.column.columnDef.header,
                          header.getContext()
                        )}
                  </TableHead>
                ))}
              </TableRow>
            ))}
          </TableHeader>
          <TableBody>
            {table.getRowModel().rows.length > 0 ? (
              table.getRowModel().rows.map((row) => (
                <TableRow key={row.id}>
                  {row.getVisibleCells().map((cell) => (
                    <TableCell key={cell.id}>
                      {flexRender(
                        cell.column.columnDef.cell,
                        cell.getContext()
                      )}
                    </TableCell>
                  ))}
                </TableRow>
              ))
            ) : (
              <TableRow>
                <TableCell
                  colSpan={columns.length}
                  className="h-24 text-center"
                >
                  No results.
                </TableCell>
              </TableRow>
            )}
          </TableBody>
        </Table>
      </div>
    </div>
  )
}

Step 8: Implement Dark Mode

shadcn/ui natively supports dark mode through CSS variables. Install next-themes to manage the toggle:

npm install next-themes
npx shadcn@latest add dropdown-menu

Create a theme provider:

// src/components/theme-provider.tsx
"use client"
 
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
 
export function ThemeProvider({
  children,
  ...props
}: React.ComponentProps<typeof NextThemesProvider>) {
  return (
    <NextThemesProvider {...props}>
      {children}
    </NextThemesProvider>
  )
}

Add it to the root layout:

// src/app/layout.tsx
import { ThemeProvider } from "@/components/theme-provider"
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <ThemeProvider
          attribute="class"
          defaultTheme="system"
          enableSystem
          disableTransitionOnChange
        >
          {children}
        </ThemeProvider>
      </body>
    </html>
  )
}

Create a theme toggle button:

// src/components/theme-toggle.tsx
"use client"
 
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
 
export function ThemeToggle() {
  const { setTheme } = useTheme()
 
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="outline" size="icon">
          <Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
          <Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
          <span className="sr-only">Toggle theme</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onClick={() => setTheme("light")}>
          Light
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("dark")}>
          Dark
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("system")}>
          System
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

Step 9: Dialog and Sheet Components

Modals and side panels are essential in modern applications:

npx shadcn@latest add dialog sheet alert-dialog

Example confirmation dialog:

// src/components/confirm-dialog.tsx
"use client"
 
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
 
interface ConfirmDialogProps {
  title: string
  description: string
  onConfirm: () => void
  children: React.ReactNode
}
 
export function ConfirmDialog({
  title,
  description,
  onConfirm,
  children,
}: ConfirmDialogProps) {
  return (
    <AlertDialog>
      <AlertDialogTrigger asChild>
        {children}
      </AlertDialogTrigger>
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>{title}</AlertDialogTitle>
          <AlertDialogDescription>
            {description}
          </AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel>Cancel</AlertDialogCancel>
          <AlertDialogAction onClick={onConfirm}>
            Confirm
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  )
}

Step 10: Organize Your Components

To maintain a clean project, adopt this structure:

src/
├── components/
│   ├── ui/           # shadcn/ui components (generated)
│   │   ├── button.tsx
│   │   ├── card.tsx
│   │   ├── dialog.tsx
│   │   └── ...
│   ├── forms/        # Composed forms
│   │   ├── contact-form.tsx
│   │   └── login-form.tsx
│   ├── tables/       # Data tables
│   │   └── users-table.tsx
│   ├── layout/       # Navigation, Footer, etc.
│   │   ├── header.tsx
│   │   ├── sidebar.tsx
│   │   └── footer.tsx
│   └── shared/       # Custom reusable components
│       ├── confirm-dialog.tsx
│       └── theme-toggle.tsx
├── hooks/            # Custom hooks
│   └── use-toast.ts
└── lib/
    └── utils.ts      # cn() utility

Organization rules:

  • Never modify files in ui/ for use-case-specific changes. Create a wrapper in shared/ instead
  • Prefix composed components with their context: ContactForm, UsersTable
  • Export from index files if you have many components in a folder

Best Practices

1. Always Use the cn() Function

import { cn } from "@/lib/utils"
 
function MyComponent({ className }: { className?: string }) {
  return (
    <div className={cn("p-4 rounded-lg", className)}>
      Content
    </div>
  )
}

2. Composition Over Configuration

// Prefer composition
<Card>
  <CardHeader>
    <CardTitle>Title</CardTitle>
  </CardHeader>
  <CardContent>Content</CardContent>
</Card>
 
// Rather than complex props
// <Card title="Title" content="Content" /> // Avoid this

3. Built-in Accessibility

shadcn/ui is built on Radix UI, which automatically handles:

  • Keyboard navigation
  • ARIA attributes
  • Focus trapping in modals
  • Screen reader announcements

Always include labels for form fields and alternative text for visual elements.

4. Performance with React Server Components

shadcn/ui works with React Server Components. Components that do not require interactivity can remain as server components:

// This can be a Server Component
// No need for "use client"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
 
export function StatCard({ title, value }: { title: string; value: string }) {
  return (
    <Card>
      <CardHeader>
        <CardTitle className="text-sm font-medium">{title}</CardTitle>
      </CardHeader>
      <CardContent>
        <p className="text-2xl font-bold">{value}</p>
      </CardContent>
    </Card>
  )
}

Troubleshooting

Component Not Rendering Correctly

Check that tailwind.config.ts includes the component paths:

content: [
  "./src/**/*.{ts,tsx}",
  "./components/**/*.{ts,tsx}",
],

"Module not found" Error After Adding a Component

Restart the development server. Sometimes new files are not detected by hot reload:

# Ctrl+C then
npm run dev

Dark Mode Styles Not Working

Make sure darkMode: "class" is in your tailwind.config.ts and that the ThemeProvider wraps your application.

Next Steps

Now that you have mastered the basics of shadcn/ui with Next.js, here is how to go further:

  • Explore more components: shadcn/ui offers over 40 components (Accordion, Calendar, Combobox, Command palette, etc.)
  • Create a Design System: use CSS variables and variants to create a consistent visual identity
  • Integrate with an API: connect your forms and tables to a backend API with TanStack Query
  • Add animations: use Framer Motion for smooth state transitions

Conclusion

shadcn/ui represents a major evolution in how we build interfaces with React. By giving you full ownership of the code, it eliminates the constraints of traditional component libraries while providing a solid, accessible, and customizable foundation.

Key takeaways:

  • Code ownership: you control every aspect of your components
  • Native accessibility: Radix UI handles complex interactions
  • Flexible theming: CSS variables enable total customization
  • TypeScript integration: strict types for a better developer experience
  • Rich ecosystem: React Hook Form, Zod, TanStack Table integrate naturally

With this foundation, you are ready to build professional applications with high-quality user interfaces.


Want to read more tutorials? Check out our latest tutorial on Enrolling on El Fatoora: A Practical Step-by-Step Guide.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles

Build a Real-Time Full-Stack App with Convex and Next.js 15

Learn how to build a real-time full-stack application using Convex and Next.js 15. This tutorial covers schema design, queries, mutations, real-time subscriptions, authentication, and file uploads — all with end-to-end type safety.

30 min read·