Building My First Site

Shipped
Build time: 3 weeks

I built this site from scratch without knowing React, CSS, JavaScript, or how websites actually worked. I learned everything by building, breaking things, and rebuilding.

This guide is the system that powers yetty.xyz, broken into modules you can understand and reuse; whether you’re technical, learning, or simply curious.

By the end of this guide, you’ll understand:

  • how a modern site is structured
  • how content flows from a CMS to the UI
  • how to build a reusable design system
  • how to implement search that actually works
  • how to add thoughtful motion & physics
  • how to build your own newsletter pipeline
  • how to deploy reliably

Foundation & Architecture

When I started, I didn’t really know how a site should be structured. I designed each page independently until it looked right. No shared system, no design rules, no foundation. It worked for a while until it started breaking.

The pages looked like completely different sites stitched together. There was no underlying structure.

Next.js was the first thing that provided structure. It handled routing, layouts, and server-side rendering without configuration. React introduced components: small, reusable pieces of UI instead of copy-pasted markup, and Tailwind brought consistency across spacing, typography, and sizing.

Essentially, don't design each page individually. Define the system that all pages belong to:

  • a root layout handling global spacing, typography, navigation
  • a set of primitives (containers, grids, cards) used across the entire site
  • a content layer that feeds structured data into components
  • routing that maps cleanly to the folder structure

Once the system was set, every page inherited it automatically.

The Site Content (CMS)

All the content on this site is being pulled directly from Sanity. It provides structured content, flexible schemas, fast queries, and no friction when publishing.

Sanity gave me exactly what I needed:

  • custom schemas (I could model content the way I wanted)
  • GROQ queries (simple, predictable, flexible)
  • real‑time updates, validation and previews
  • a clean Studio UI for writing and editing
  • no redeploy required when content changes

And it took less time to set up than adding content manually to the repo.

How the data actually flows

The entire site runs on one pattern:

text
1Sanity → GROQ Query → Next.js Server Component → UI
  • Sanity stores the content.
  • GROQ fetches it.
  • Next.js renders it on the server.
  • The UI displays it.

No duplication. No manual syncing. No broken pages when content updates.

Schemas

Each content type has its own schema. Most share similar fields, which kept the system consistent.

An example:

typescript
1export default defineType({ 2 name: 'tool', 3 title: 'Tool', 4 type: 'document', 5 fields: [ 6 defineField({ name: 'name', title: 'Name', type: 'string', validation: R => R.required() }), 7 defineField({ 8 name: 'slug', 9 title: 'Slug', 10 type: 'slug', 11 options: { source: 'name', maxLength: 96 }, 12 validation: R => R.required(), 13 }), 14 defineField({ name: 'category', title: 'Category', type: 'string' }), 15 defineField({ name: 'shortDesc', title: 'Short Description', type: 'string' }), 16 defineField({ name: 'desc', title: 'About', type: 'text', rows: 4 }), 17 defineField({ 18 name: 'how', 19 title: 'How I Use It', 20 type: 'array', 21 of: [{ type: 'string' }], 22 }), 23 defineField({ 24 name: 'demoVideo', 25 title: 'Demo Video', 26 type: 'file', 27 options: { accept: 'video/*' }, 28 }), 29 defineField({ 30 name: 'rating', 31 title: 'Rating', 32 type: 'number', 33 validation: R => R.min(0).max(5), 34 }), 35 defineField({ 36 name: 'tags', 37 title: 'Tags', 38 type: 'array', 39 of: [{ type: 'reference', to: [{ type: 'tag' }] }], 40 }), 41 ], 42});
  • Schema -> structure.
  • Structure -> reliability.
  • Reliability -> a site that scales without rewriting everything.

Querying content

Pages request exactly what they need:

typescript
1export const TOOL_BY_SLUG = groq` 2 *[_type == "tool" && slug.current == $slug][0]{ 3 name, 4 category, 5 shortDesc, 6 desc, 7 how, 8 rating, 9 "tags": coalesce(tags[]->title, tags[]), 10 "coverUrl": cover.asset->url, 11 "demoVideoUrl": demoVideo.asset->url 12 } 13`;

That’s the entire workflow.

If your site has more than one type of content, or if you want to publish without redeploying, a structured CMS will save you time, complexity, and frustration.

You can model your content once and focus on design, interactions, and growth.

(embed your workflow video here.)

Design System & Layout

When I started, every page had its own font sizes, colours, spacing, and hard-coded components. It worked until I tried to change anything. One change broke three pages. Adding a new layout meant rebuilding old ones.

I realised a site isn’t a collection of pages: it’s a system.

A design system gives you:

  • consistency across every page
  • faster iteration (you change a token once, the whole site updates)
  • cleaner code (no magic numbers or one-off styles)
  • a recognisable identity

It doesn’t have to be complicated. Mine isn’t. But it is structured.

Tokens

Everything runs off a single file of CSS variables:

text
1colours 2spacing 3typography 4radius 5shadows 6motion

These tokens are the foundation. Tailwind reads them. Components use them. Pages inherit them.

If I change --accent-mint, the entire site updates instantly.

Tokens remove the need to hard‑code anything.

Typography

I wanted the typography to feel clean but expressive. I landed on:

  • PP Mori for headings
  • Satoshi for body text

The combination feels modern without being loud. And by controlling line heights, letter spacing, and responsive sizes through Tailwind + tokens, every block of text behaves consistently.

Layout primitives

Every page follows a variation of the same structure:

  • a container with consistent padding
  • a grid with predictable breakpoints
  • reusable card components

Hero UI

Before Hero UI, I was building every page by hand. Every margin, every component, every layout. It was chaos.

Hero UI gave me:

  • layout primitives
  • cards
  • spacing rules
  • responsive defaults
  • consistent structure without losing flexibility

Once I rebuilt the site using Hero UI as the baseline, Tailwind finally made sense.

The whole site snapped into place.

Reusable components

Cards, containers, buttons, and sections, once these were built properly, building new pages became quick.

I could:

  • add a new content type in minutes
  • drop it into the grid
  • apply the same design language everywhere

A good component library is like compound interest for development.

If you build page by page, you’ll eventually rebuild everything.

If you build the system first, the pages almost build themselves.

A minimal, intentional design system will:

  • prevent UI drift
  • reduce redesign time
  • make your site feel cohesive and premium
  • let you scale without losing identity

The design system is the difference between a site that feels stitched together and a site that feels designed.

Here's how it looked before I factored in the above.

Interactions & Animations

The whole site uses four motion layers:

  • GSAP — micro‑interactions, magnetics on the header, cursor, hero text
  • Framer Motion — component‑level transitions and mount animations
  • Lenis — heavy smooth scrolling
  • Matter.js — physics environments (pills, squares, and reactive objects)
Don’t use one library to solve every motion problem. Design a stack.

Lenis

Lenis handles the foundational scroll on the site:

  • inertia
  • easing
  • overscroll control
  • touch + wheel consistency
  • route‑change resets

This is why the site feels smooth

Technical note:

tsx
1 const lenis = new Lenis({ 2 duration: 1.2, 3 easing: t => Math.min(1, 1.001 - Math.pow(2, -10 * t)), // smooth, not floaty 4 orientation: 'vertical', 5 gestureOrientation: 'vertical', 6 smoothWheel: true, 7 touchMultiplier: 2, 8 });

GSAP integrates directly with Lenis via raf, so scroll‑triggered animations remain in sync.

GSAP

GSAP powers the small‑scale interactions that make the UI feel reactive:

  • the magnetic navbar
  • hover elasticity
  • the custom cursor
  • link + button micro‑motion
  • fade + transform transitions

Magnetic wrapper on the header:

tsx
1const ref = useRef<HTMLDivElement>(null); 2 3useEffect(() => { 4 const el = ref.current; 5 if (!el) return; 6 7 const xTo = gsap.quickTo(el, 'x', { duration: 1, ease: 'elastic.out(1, 0.3)' }); 8 const yTo = gsap.quickTo(el, 'y', { duration: 1, ease: 'elastic.out(1, 0.3)' }); 9 10 const onMove = (e: MouseEvent) => { 11 const { left, top, width, height } = el.getBoundingClientRect(); 12 const x = (e.clientX - (left + width / 2)) * 0.3; // strength 13 const y = (e.clientY - (top + height / 2)) * 0.3; 14 xTo(x); 15 yTo(y); 16 }; 17 18 const onLeave = () => { xTo(0); yTo(0); }; 19 20 el.addEventListener('mousemove', onMove); 21 el.addEventListener('mouseleave', onLeave); 22 return () => { 23 el.removeEventListener('mousemove', onMove); 24 el.removeEventListener('mouseleave', onLeave); 25 }; 26}, []); 27 28return <div ref={ref} style={{ display: 'inline-block' }}>{children}</div>;

Custom cursor:

tsx
1const ref = useRef<HTMLDivElement>(null); 2 3useEffect(() => { 4 const el = ref.current; 5 if (!el || !window.matchMedia('(pointer: fine)').matches) return; 6 7 const xTo = gsap.quickTo(el, 'x', { duration: 0.3, ease: 'power3.out' }); 8 const yTo = gsap.quickTo(el, 'y', { duration: 0.3, ease: 'power3.out' }); 9 const onMove = (e: MouseEvent) => { xTo(e.clientX); yTo(e.clientY); }; 10 11 const onHoverStart = () => gsap.to(el, { scale: 0.5, opacity: 0.6, duration: 0.3 }); 12 const onHoverEnd = () => gsap.to(el, { scale: 1, opacity: 1, duration: 0.3 }); 13 14 window.addEventListener('mousemove', onMove); 15 const targets = () => document.querySelectorAll('a, button, input, textarea, [role="button"]'); 16 targets().forEach(t => { t.addEventListener('mouseenter', onHoverStart); t.addEventListener('mouseleave', onHoverEnd); }); 17 18 return () => { 19 window.removeEventListener('mousemove', onMove); 20 targets().forEach(t => { t.removeEventListener('mouseenter', onHoverStart); t.removeEventListener('mouseleave', onHoverEnd); }); 21 }; 22}, [pathname]);

Hero text split-fill:

tsx
1const titleRef = useRef<HTMLHeadingElement>(null); 2 3useEffect(() => { 4 const el = titleRef.current; 5 if (!el || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; 6 7 const [{ gsap }, mod] = await Promise.all([import('gsap'), import('gsap/SplitText')]); 8 const SplitText = (mod as any).SplitText; 9 const split = new SplitText(el, { type: 'words,lines', reduceWhiteSpace: false }); 10 11 const [r, g, b] = [181, 234, 215]; // fallback accent; resolve var(--accent-page) if needed 12 const start = `rgba(${r},${g},${b},0.18)`; 13 const end = `rgba(${r},${g},${b},1)`; 14 15 gsap.set(split.lines, { overflow: 'visible' }); 16 gsap.set(split.words, { color: start, '--hero-blur': '2.5px', filter: 'blur(var(--hero-blur))' }); 17 18 gsap.timeline({ defaults: { ease: 'none' } }) 19 .to(split.words, { color: end, duration: 0.9, stagger: 0.14 }, 0) 20 .to(split.words, { '--hero-blur': '0px', duration: 0.72, stagger: 0.14, ease: 'power2.out' }, 0); 21 22 return () => split.revert(); 23}, []);

GSAP is great for precise, choreographed UI motion, not physics (I tried and failed).

Framer Motion

Framer Motion controls the animations that relate to React component life cycles:

  • mount/unmount fades
  • subtle section reveals
  • article page transitions
  • layout shift smoothing

Framer is great for animations tied to component state rather than the DOM.

Page transitions (mount/unmount):

tsx
1const variants = { 2 hidden: { opacity: 0, y: 40, filter: 'blur(12px)', scale: 0.98 }, 3 enter: { opacity: 1, y: 0, filter: 'blur(0px)', scale: 1, transition: { duration: 0.8, ease: [0.25,1,0.5,1] } }, 4 exit: { opacity: 0, y: -20, filter: 'blur(8px)', transition: { duration: 0.4, ease: 'easeIn' } }, 5}; 6 7<AnimatePresence mode="wait" onExitComplete={() => window.scrollTo(0, 0)}> 8 <motion.div key={pathname} initial={shouldAnimate ? 'hidden' : false} animate="enter" exit="exit" variants={variants}> 9 {children} 10 </motion.div> 11</AnimatePresence>

Section reveals (mount fade/slide):

tsx
1<motion.div 2 initial={{ opacity: 0, y: 20, filter: 'blur(8px)' }} 3 animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }} 4 transition={{ duration: 0.6, ease: [0.25,1,0.5,1], delay: Math.min(delay, 0.4) }} 5> 6 {children} 7</motion.div>

Matter.js

Matter.js is the engine behind the "living" elements:

  • homepage pills
  • library squares
  • navigation pills in the sidebar

Matter handles:

  • gravity modelling
  • friction
  • restitution (bounce)
  • velocity + rotation limits
  • sleeping/waking states
  • collision detection

The core Matter.js setup:

tsx
1// Create engine + renderer (canvas stays hidden) 2const host = hostRef.current; 3const { width: BOX_W, height: BOX_H } = host.getBoundingClientRect(); 4const engine = Matter.Engine.create(); 5engine.world.gravity.y = 1.0; // gravity 6const render = Matter.Render.create({ 7 element: host, 8 engine, 9 options: { width: BOX_W, height: BOX_H, background: 'transparent', wireframes: false }, 10}); 11Matter.Render.run(render); 12const runner = Matter.Runner.create(); 13Matter.Runner.run(runner, engine); 14 15// Walls 16const wall = { isStatic: true, render: { visible: false } }; 17Matter.Composite.add(engine.world, [ 18 Matter.Bodies.rectangle(BOX_W / 2, BOX_H + 100, BOX_W * 2, 200, wall), 19 Matter.Bodies.rectangle(BOX_W / 2, -100, BOX_W * 2, 200, wall), 20 Matter.Bodies.rectangle(-100, BOX_H / 2, 200, BOX_H * 2, wall), 21 Matter.Bodies.rectangle(BOX_W + 100, BOX_H / 2, 200, BOX_H * 2, wall), 22]); 23 24// Spawn items as physics bodies 25items.forEach((item, i) => { 26 const w = /* width from text length */; 27 const h = 48; 28 const body = Matter.Bodies.rectangle( 29 Math.random() * BOX_W, 30 -Math.random() * 400, 31 w, 32 h, 33 { 34 chamfer: { radius: h / 2 }, 35 restitution: 0.4, // bounce 36 friction: 0.5, 37 frictionAir: 0.02, // air drag 38 density: 0.05, 39 angle: (Math.random() - 0.5) * 0.2, 40 }, 41 ); 42 Matter.Composite.add(engine.world, body); 43 pairs.push({ el: pillElement, body }); 44}); 45 46// Mouse interactions (drag + click “explosion”) 47const mouse = Matter.Mouse.create(render.canvas); 48const mc = Matter.MouseConstraint.create(engine, { mouse, constraint: { stiffness: 0.1 } }); 49Matter.Composite.add(engine.world, mc); 50// on click: setStatic(clicked, true), applyForce to others, then router.push(url) 51 52// Cleanup 53return () => { 54 Matter.Runner.stop(runner); 55 Matter.Engine.clear(engine); 56 render.canvas.remove(); 57 render.textures = {}; 58};

I loved fine-tuning these. A small change in gravity or friction completely alters the personality of a section.

Tuning knobs you may find helpful:

  • gravity.y -> heaviness
  • restitution -> bounce
  • friction/frictionAir -> slide vs drift
  • angle + velocity clamps -> keep things readable
  • Sleep/wake is controlled by enableSleeping (default), so idle bodies park themselves until nudged.

Here’s how these systems interact without stepping on each other:

  • Lenis scrolls the page.
  • GSAP updates magnetic elements based on pointer position.
  • Framer fades content on mount.
  • Matter runs independently in its own RAF loop.

Each library stays in its lane.

Common mistakes I made (so you don’t)

  • using GSAP for physics → jitter, inconsistent behaviour
  • putting scroll logic in React state → stutters
  • mixing Framer + GSAP on the same element → unpredictable results
  • not capping Matter velocity → literal chaos

The fix in every case was learning to assign one job to one tool.

Search & Discovery

Search looks simple from the outside, but it’s arguably the most deceptively complex feature I wired. It went through multiple failures before it worked. Different content types weren’t showing up, certain words returned everything, and British vs American spelling mismatches confused the index.

The architecture

Search is entirely client-side, powered by Fuse.js, with data loaded from Sanity via GROQ.

The flow looks like this:

text
1Sanity → GROQ → Build unified search index → Fuse.js fuzzy matching → Weighted results per content type

Everything loads in under a second.

Building the index

As all the content types look different in the UI, in search, they needed to become a single, unified dataset.

Each entry becomes a Doc with:

  • id n
  • type
  • href
  • title
  • description
  • tags
  • body (normalised text)

The body field does a lot of the work. Portable Text gets converted into plain text. Arrays get flattened. Different schemas get reduced to the same shape.

This normalisation is why search results feel consistent.

Weighting the results

Not every field should matter equally.

I weighted search like this:

  • title — 52%
  • description — 20%
  • tags — 16%
  • body text — 12%

Titles are strong intent signals. Tags help reinforce relevance. Body text catches longer matches without overwhelming the results.

Normalising the query

User input is messy. "behaviour" vs "behavior". Case differences, short tokens and typos.

Normalisation fixes this:

  • lowercase everything
  • split into tokens
  • drop tokens under 2 characters
  • fuzzy matching with a cutoff (0.33)

A simplified version of search:

typescript
1// 1. Load docs from Sanity 2const docs = await client.fetch(SEARCH_GROQ); 3 4// 2. Normalise each doc 5const index = docs.map(toUnifiedShape); 6 7// 3. Create Fuse index 8const fuse = new Fuse(index, { keys: [...], threshold: 0.34 }); 9 10// 4. Search 11const results = fuse.search(query); 12 13// 5. Group results 14return groupByType(results);

This is the entire search engine.

Common mistakes

I made a lot of mistakes, so hopefully these are of help to you:

  • Content type missing from results → inconsistent schemas; fix by normalising fields.
  • Every result showing up → fuzzy threshold too high.
  • Correct words not matching → token normalisation needed.
  • Draft content leaking in → wrong GROQ filter; exclude path("drafts.**").
  • Performance issues → memoise the index for 60 seconds.

Newsletter & Publishing Pipeline

I wanted the email to feel like an extension of the site, same fonts, same spacing, same structure, and I wanted everything to live in one place. I didn't have that flexibility with most ESPs. So I built the newsletter workflow into the site itself, using:

  • Sanity — to write, store, and manage newsletter issues
  • React Email — to design custom email templates
  • Resend — to send the emails
  • Next.js API routes — to handle double opt‑in, confirmation, and sending

Everything is wired so I can write inside Sanity, press publish, and have the entire pipeline handled automatically.

The Architecture

The workflow looks like this:

text
1Sanity (newsletterIssue schema) ↓Next.js API route (draft → HTML) ↓React Email (template renderer) ↓Resend (confirmation + broadcast) ↓Subscriber stored in Sanity

This gives you:

  • fully custom layout
  • no vendor lock‑in
  • easy versioning, previews and editing
  • email + web content coming from the same source
The best part: no need to touch the codebase to send new issues.

Schema

A simplified newsletterIssue schema:

typescript
1export default { 2 name: 'newsletterIssue', 3 title: 'Newsletter Issue', 4 type: 'document', 5 fields: [ 6 { name: 'title', type: 'string', validation: R => R.required() }, 7 { name: 'slug', type: 'slug', options: { source: 'title', maxLength: 96 }, validation: R => R.required() }, 8 { name: 'date', type: 'datetime', title: 'Publish Date' }, 9 { name: 'excerpt', type: 'text', rows: 3, title: 'Excerpt (preview/SEO)' }, 10 11 // Sections 12 { name: 'thought', type: 'blockContentBasic', title: 'One Thought' }, 13 { name: 'tool', type: 'blockContentBasic', title: 'One Tool' }, 14 { name: 'inProgress', type: 'blockContentBasic', title: 'One Idea in Progress' }, 15 { name: 'book', type: 'blockContentBasic', title: 'One Book/Chapter' }, 16 { name: 'question', type: 'blockContentBasic', title: 'One Question for the Week' }, 17 18 // Optional image/tags for the consuming section 19 { name: 'consumingImage', type: 'image', options: { hotspot: true } }, 20 { name: 'consumingTags', type: 'array', of: [{ type: 'string' }], options: { layout: 'tags' } }, 21 22 // Light status 23 { name: 'status', type: 'string', options: { list: ['draft', 'sent'] }, initialValue: 'draft' }, 24 ], 25}; 26

This lets you:

  • write the issue in Portable Text
  • schedule it
  • track whether it was sent

The content happens in Sanity.

Subscriber opt-in system

I implemented a proper double opt‑in system, so subscribers confirm their emails before being added.

Flow:

  1. User enters email on the site
  2. API generates a token + stores the pending subscriber in Sanity
  3. Resend sends confirmation email
  4. User clicks link → verified + stored

Create pending subscriber + send confirmation email:

typescript
1// server action / API 2import { createSubscriberInSanity, sendConfirmationEmail } from '@/lib/resend'; 3 4const email = formData.get('email') as string; 5const name = formData.get('name') as string; 6 7const { confirmationToken } = await createSubscriberInSanity(email, name); 8// store { email, name, confirmed:false, unsubscribed:false, confirmationToken, unsubscribeToken } 9 10await sendConfirmationEmail(email, confirmationToken, name); 11// email contains link: https://your-site.com/newsletter/confirm?token=... 12

Confirm endpoint (token → mark confirmed + add to ESP)

typescript
1// e.g., newsletter/confirm/page.tsx 2const token = searchParams.token; 3const sub = await sanityClient.fetch( 4 `*[_type == "subscriber" && confirmationToken == $token][0]`, 5 { token }, 6); 7if (!sub) redirect('/newsletter?status=invalid-token'); 8 9// Add to ESP only after confirmation 10await addSubscriberToResend(sub.email, sub.name); 11 12// Mark confirmed in Sanity 13await sanityWriteClient.patch(sub._id).set({ 14 confirmed: true, 15 confirmationToken: null, 16 unsubscribed: false, 17}).commit(); 18

Rendering the email (React Email)

React Email lets you design the email just like a component, but with email‑safe HTML.

Example:

tsx
1import { 2 Html, 3 Head, 4 Body, 5 Container, 6 Heading, 7 Text, 8 Section, 9 Link, 10 Hr, 11} from '@react-email/components'; 12 13export default function NewsletterEmail({ 14 title, 15 intro, 16 viewUrl, 17 unsubscribeUrl, 18}: { 19 title: string; 20 intro: string; 21 viewUrl: string; 22 unsubscribeUrl: string; 23}) { 24 return ( 25 <Html> 26 <Head /> 27 <Body style={{ margin: 0, backgroundColor: '#fff', color: '#111', fontFamily: 'Arial, sans-serif' }}> 28 <Container style={{ maxWidth: 640, padding: '24px 20px' }}> 29 <Heading style={{ fontSize: 24, margin: '0 0 12px' }}>{title}</Heading> 30 <Text style={{ fontSize: 15, lineHeight: 1.6, margin: '0 0 20px' }}>{intro}</Text> 31 32 <Section style={{ marginTop: 20 }}> 33 <Link href={viewUrl} style={{ color: '#0066cc', textDecoration: 'underline' }}> 34 View in browser 35 </Link> 36 </Section> 37 38 <Hr style={{ borderColor: '#ddd', margin: '24px 0' }} /> 39 40 <Text style={{ fontSize: 12, color: '#666', lineHeight: 1.5 }}> 41 You’re receiving this email because you subscribed. 42 <br /> 43 <Link href={unsubscribeUrl} style={{ color: '#666', textDecoration: 'underline' }}> 44 Unsubscribe 45 </Link> 46 </Text> 47 </Container> 48 </Body> 49 </Html> 50 ); 51} 52

This allowed the email to match the site’s visual style; typography, spacing, structure, something I kept forcing with other ESPs.

Sending the email (Resend)

Once an issue is published in Sanity, an API route:

  • fetches the content
  • converts Portable Text → HTML
  • injects into the React Email template
  • sends via Resend
  • marks the issue as sent inside Sanity

This turns what would normally be a manual workflow into a single automated action.

Snippet:

typescript
1export async function POST(req: Request) { 2 const { slug } = await req.json(); 3 4 // 1) Fetch issue from Sanity 5 const issue = await sanityClient.fetch(NEWSLETTER_ISSUE_BY_SLUG, { slug }); 6 if (!issue) return new Response('Not found', { status: 404 }); 7 8 // 2) Convert Portable Text sections to HTML 9 const thoughtHtml = issue.thought ? toHTML(issue.thought) : ''; 10 const toolHtml = issue.tool ? toHTML(issue.tool) : ''; 11 const inProgressHtml = issue.inProgress ? toHTML(issue.inProgress) : ''; 12 const bookHtml = issue.book ? toHTML(issue.book) : ''; 13 const questionHtml = issue.question ? toHTML(issue.question) : ''; 14 15 // 3) Render email HTML with React Email template 16 const html = await render( 17 NewsletterEmail({ 18 issue: { ...issue, thoughtHtml, toolHtml, inProgressHtml, bookHtml, questionHtml }, 19 viewUrl: `https://your-site.com/newsletter/${slug}`, 20 unsubscribeUrl: `https://your-site.com/newsletter/unsubscribe?email={{ email }}`, 21 }), 22 ); 23 24 // 4) Send via Resend 25 const result = await resend.emails.send({ 26 from: 'Your Name <newsletter@yourdomain.com>', 27 to: issue.recipients ?? 'audience', // or use contacts/segments 28 subject: issue.title, 29 html, 30 }); 31 32 // 5) Mark as sent in Sanity 33 await sanityWriteClient 34 .patch(issue._id) 35 .set({ status: 'sent', sentAt: new Date().toISOString(), resendBroadcastId: result.id }) 36 .commit(); 37 38 return new Response(JSON.stringify({ ok: true, id: result.id })); 39}

Resend handles delivery, analytics, and failure logs.

Deployment & Dev

The deployment workflow:

  • Next.js (build system + server components)
  • Vercel (preview + production deploys)
  • Git (safe haven + rollbacks)
  • Automated checks (linting, type-checking, build verification)

The Deployment Architecture

Local dev → Git → Vercel Preview → QA → Production Deploy

Every push triggers:

  1. A fresh Vercel preview build
  2. Type-checking + linting
  3. Server component rendering
  4. Route + API evaluation

This ensures the site in the preview link behaves exactly like the production environment.

1. Local development checks

Before pushing anything, I run:

text
1pnpm lint 2pnpm test 3pnpm typecheck 4pnpm build

This caught the majority of preventable errors:

  • missing imports
  • TypeScript mismatches
  • build-time failures
  • missing environment variables
Don’t push broken code.

Preview deployments (Vercel)

Every branch push creates a unique preview URL:

  • same server environment
  • same build output
  • same edge/runtime behaviour

This lets you test:

  • layout consistency
  • CMS data fetching
  • API routes
  • search behaviour
  • image optimisation

You catch things here that don't show up locally.

Environment variables

Sanity, Resend, rate limiting, preview mode — all of these require environment variables.

The biggest mistake was assuming that .env.local = production.

In reality, every environment must define its own secrets, so whatever is in .env.local has to be on Vercel.

If something works locally but breaks in production, this is the first place to check.

Rollbacks and hotfixes

Vercel makes rollbacks painless.

If something breaks in production:

  • open the previous successful deployment
  • click "Rollback to this version"

Instant revert.

Observability (Sentry + analytics)

To monitor behaviour in production:

  • Sentry tracks runtime errors
  • Plausible provides privacy-friendly analytics
  • Vercel analytics highlights slow routes, heavy bundles, or hydration issues

This helps you catch:

  • client-side errors
  • server execution failures
  • layout shifts
  • performance regressions

Good observability = fewer surprises.

Rate limiting (Upstash Redis)

Certain routes (like search + newsletter signup) needed rate limiting to prevent spam and server load.

The rate limit logic:

typescript
1const { success } = await ratelimit.limit(ip);if (!success) return 429

It’s lightweight and serverless-friendly.

Deployment isn’t the final step — it’s part of your development cycle.

A reliable workflow gives you:

  • predictable builds
  • confidence to ship quickly
  • clean separation between dev and production
  • safety nets when things break

If you get this right early, everything else becomes smoother.

Build, Break, Learn, Repeat

When I started this project, I didn’t know how websites were made. I just had ideas, content, and the curiosity to figure it out as I went. Three weeks later, after breaking things more times than I can count, I ended up with a site I actually wanted to share.

None of it came from knowing the “right way” to code. Most of what I learned came from mistakes. I learned Git because I deleted my work. I learned GSAP because the animations were janky. I learned data structures because my content wouldn’t render. Every problem forced a new skill, and most of the things I fought for early on didn’t even make it into the final version.

That’s the part people don’t see. You learn by running into walls, not by avoiding them. And you only build confidence by actually building something, even if you don’t fully understand the tools at the start.

If you want to build your own thing, you don’t need to wait until you “learn how to code.” You don’t need a degree. You don’t need perfect planning. You just need a project you care about and the willingness to debug until it works.

Start with one content type.
Define a layout system.
Let a CMS handle your data.
Add search early.
Be intentional with animation.
Deploy often.

Most of the learning will happen automatically because the project will force it out of you. The hardest part is simply beginning.