Building My First Site
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:
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:
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:
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:
1colours
2spacing
3typography
4radius
5shadows
6motionThese 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:
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:
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:
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:
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):
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):
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:
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:
1Sanity → GROQ → Build unified search index → Fuse.js fuzzy matching → Weighted results per content typeEverything 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:
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:
1Sanity (newsletterIssue schema) ↓Next.js API route (draft → HTML) ↓React Email (template renderer) ↓Resend (confirmation + broadcast) ↓Subscriber stored in SanityThis 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:
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};
26This 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:
- User enters email on the site
- API generates a token + stores the pending subscriber in Sanity
- Resend sends confirmation email
- User clicks link → verified + stored
Create pending subscriber + send confirmation email:
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=...
12Confirm endpoint (token → mark confirmed + add to ESP)
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();
18Rendering the email (React Email)
React Email lets you design the email just like a component, but with email‑safe HTML.
Example:
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}
52This 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:
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:
- A fresh Vercel preview build
- Type-checking + linting
- Server component rendering
- 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:
1pnpm lint
2pnpm test
3pnpm typecheck
4pnpm buildThis 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:
1const { success } = await ratelimit.limit(ip);if (!success) return 429It’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.