img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(4)] dark:ring-foreground/10 *:[img:first-child]:rounded-t-4xl *:[img:last-child]:rounded-b-4xl",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx
new file mode 100644
index 0000000..4f65961
--- /dev/null
+++ b/src/components/ui/separator.tsx
@@ -0,0 +1,23 @@
+import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
+
+import { cn } from "@/lib/utils"
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ ...props
+}: SeparatorPrimitive.Props) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx
new file mode 100644
index 0000000..15d776b
--- /dev/null
+++ b/src/components/ui/sheet.tsx
@@ -0,0 +1,133 @@
+import * as React from "react"
+import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
+import { XIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+
+function Sheet({ ...props }: SheetPrimitive.Root.Props) {
+ return
+}
+
+function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
+ return
+}
+
+function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
+ return
+}
+
+function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
+ return
+}
+
+function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function SheetContent({
+ className,
+ children,
+ side = "right",
+ showCloseButton = true,
+ ...props
+}: SheetPrimitive.Popup.Props & {
+ side?: "top" | "right" | "bottom" | "left"
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+ }
+ >
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function SheetDescription({
+ className,
+ ...props
+}: SheetPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Sheet,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+}
diff --git a/src/content/authors.ts b/src/content/authors.ts
new file mode 100644
index 0000000..573816a
--- /dev/null
+++ b/src/content/authors.ts
@@ -0,0 +1,28 @@
+// Blog authors. Add an entry per person; posts reference an author by key
+// through their `author` meta field. Optional fields (role, avatar, url) can be
+// filled in as we grow the team.
+export type Author = {
+ name: string
+ // Optional short role or title shown alongside the name.
+ role?: string
+ // Optional avatar path under /public.
+ avatar?: string
+ // Optional link to a profile or personal site.
+ url?: string
+}
+
+export const authors = {
+ hasnae: {
+ name: 'Hasnae',
+ },
+} satisfies Record
+
+export type AuthorId = keyof typeof authors
+
+export function getAuthor(id: AuthorId): Author {
+ const author = authors[id]
+ if (!author) {
+ throw new Error(`Unknown author "${id}". Add it to content/authors.ts.`)
+ }
+ return author
+}
diff --git a/src/content/posts.ts b/src/content/posts.ts
new file mode 100644
index 0000000..06d0c9a
--- /dev/null
+++ b/src/content/posts.ts
@@ -0,0 +1,74 @@
+import type { ComponentType } from 'react'
+
+import { authors, type AuthorId } from '@/content/authors'
+
+export type PostMeta = {
+ title: string
+ description: string
+ // Author key, resolved against the registry in content/authors.ts.
+ author: AuthorId
+ // ISO date, YYYY-MM-DD. Used for sorting and display.
+ date: string
+ readingTime: string
+ tags: string[]
+ // Optional cover image (a path under public/). When absent, the listing draws
+ // a generated gradient instead.
+ cover?: string
+}
+
+// The compiled MDX body. It accepts an optional `components` map so posts can be
+// rendered with our styled elements (see components/mdx/mdx-components.tsx).
+export type PostContent = ComponentType<{ components?: Record }>
+
+export type Post = {
+ slug: string
+ meta: PostMeta
+ Content: PostContent
+}
+
+// Every .mdx file under posts/ is a published entry, grouped into year/month
+// folders. Slug is the filename, so the folders never touch the URL.
+const modules = import.meta.glob<{ meta: PostMeta; default: PostContent }>(
+ './posts/**/*.mdx',
+ { eager: true },
+)
+
+// .mdx meta literals are not type-checked by tsc, so validate them here. This
+// runs at import (build/prerender) and fails loudly on a malformed post rather
+// than crashing later when a component dereferences the missing field.
+function toPost(path: string, mod: { meta: PostMeta; default: PostContent }): Post {
+ const slug = path.split('/').pop()!.replace(/\.mdx$/, '')
+ const meta = mod.meta
+
+ if (!meta) throw new Error(`Blog post "${slug}" is missing its \`meta\` export.`)
+ for (const field of ['title', 'description', 'author', 'date', 'readingTime'] as const) {
+ if (!meta[field]) throw new Error(`Blog post "${slug}" is missing \`meta.${field}\`.`)
+ }
+ if (!Array.isArray(meta.tags)) throw new Error(`Blog post "${slug}" is missing \`meta.tags\`.`)
+ if (!authors[meta.author]) {
+ throw new Error(
+ `Blog post "${slug}" has unknown author "${meta.author}". Add it to content/authors.ts.`,
+ )
+ }
+
+ return { slug, meta, Content: mod.default }
+}
+
+export const posts: Post[] = Object.entries(modules)
+ .map(([path, mod]) => toPost(path, mod))
+ // Newest first.
+ .sort((a, b) => b.meta.date.localeCompare(a.meta.date))
+
+export function getPost(slug: string): Post | undefined {
+ return posts.find((post) => post.slug === slug)
+}
+
+export function formatPostDate(date: string): string {
+ // Parse as UTC so the displayed day never shifts with the viewer's timezone.
+ return new Date(`${date}T00:00:00Z`).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ timeZone: 'UTC',
+ })
+}
diff --git a/src/content/posts/2026/07/file-based-routing.mdx b/src/content/posts/2026/07/file-based-routing.mdx
new file mode 100644
index 0000000..bb2e0d3
--- /dev/null
+++ b/src/content/posts/2026/07/file-based-routing.mdx
@@ -0,0 +1,24 @@
+export const meta = {
+ title: 'File-based routing, explained',
+ description:
+ 'Each file under src/routes becomes a route. Here is how the root route, index route, and dynamic segments fit together.',
+ author: 'hasnae',
+ date: '2026-07-18',
+ readingTime: '6 min read',
+ tags: ['routing', 'tanstack'],
+}
+
+The `__root.tsx` route owns the HTML document and renders an `Outlet` for its
+children. Every other file under `src/routes` maps to a URL by its name.
+
+## Dynamic segments
+
+Dynamic segments use a `$` prefix: `blog/$slug.tsx` matches `/blog/anything`.
+The matched value arrives as a typed route param, so you never reach for a
+loosely-typed `params` bag.
+
+## The generated route tree
+
+The route tree is generated for you into `routeTree.gen.ts` whenever you run the
+dev server or a build — you never hand-maintain it. Because it is generated,
+navigation, params, and loader data are all checked at compile time.
diff --git a/src/content/posts/2026/07/hello-tanstack-start.mdx b/src/content/posts/2026/07/hello-tanstack-start.mdx
new file mode 100644
index 0000000..bfcc7eb
--- /dev/null
+++ b/src/content/posts/2026/07/hello-tanstack-start.mdx
@@ -0,0 +1,35 @@
+export const meta = {
+ title: 'Hello, TanStack Start',
+ description:
+ 'Why we rebuilt the website template on TanStack Start — server rendering, type-safe routing, and static prerendering out of the box.',
+ author: 'hasnae',
+ date: '2026-07-24',
+ readingTime: '4 min read',
+ tags: ['tanstack', 'react'],
+}
+
+TanStack Start pairs the file-based, fully type-safe routing of TanStack Router
+with a Vite-powered full-stack runtime. That means server-side rendering,
+streaming, and static prerendering without leaving your React app.
+
+## What ships in this template
+
+This starter keeps things minimal: a document shell in the root route, a home
+page, and a small blog stream backed by an MDX content layer — a solid starting
+point you can grow into.
+
+- **Type-safe routing** — route params and loader data flow through TypeScript.
+- **Static prerendering** — every page is crawled from the homepage and written
+ to HTML at build time.
+- **Author-controlled styling** — Tailwind tokens in OKLCH, headless Base UI
+ primitives wrapped with shadcn patterns.
+
+
+ Posts are MDX, so you can drop React components right into the prose when plain
+ Markdown isn't enough.
+
+
+## Why it matters
+
+Refactors stay honest because the types follow you everywhere, and readers get
+real HTML on first paint instead of a loading spinner.
diff --git a/src/content/posts/2026/07/the-content-layer.mdx b/src/content/posts/2026/07/the-content-layer.mdx
new file mode 100644
index 0000000..5c00648
--- /dev/null
+++ b/src/content/posts/2026/07/the-content-layer.mdx
@@ -0,0 +1,25 @@
+export const meta = {
+ title: 'An MDX content layer',
+ description:
+ 'Posts are MDX files discovered at build time with import.meta.glob. This blog stream is powered by exactly that.',
+ author: 'hasnae',
+ date: '2026-07-11',
+ readingTime: '5 min read',
+ tags: ['content', 'mdx'],
+}
+
+Posts live under `src/content/posts/YYYY/MM/` as MDX — Markdown with the option
+to drop in React components when you need them. Each file exports a `meta`
+object and its body compiles to a component.
+
+## One glob to rule them all
+
+`import.meta.glob('./posts/**/*.mdx', { eager: true })` loads every post module
+up front. We validate each `meta`, map it to a slug and content component, then
+sort by date. Routes consume that single, centralized array.
+
+## Slug from filename
+
+The slug comes from the filename, so the `YYYY/MM` folders keep things tidy
+without affecting URLs. Add a file, and it shows up in the stream — no registry
+to update.
diff --git a/src/index.css b/src/index.css
index 5fb3313..f2a8774 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,111 +1,130 @@
-:root {
- --text: #6b6375;
- --text-h: #08060d;
- --bg: #fff;
- --border: #e5e4e7;
- --code-bg: #f4f3ec;
- --accent: #aa3bff;
- --accent-bg: rgba(170, 59, 255, 0.1);
- --accent-border: rgba(170, 59, 255, 0.5);
- --social-bg: rgba(244, 243, 236, 0.5);
- --shadow:
- rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
-
- --sans: system-ui, 'Segoe UI', Roboto, sans-serif;
- --heading: system-ui, 'Segoe UI', Roboto, sans-serif;
- --mono: ui-monospace, Consolas, monospace;
-
- font: 18px/145% var(--sans);
- letter-spacing: 0.18px;
- color-scheme: light dark;
- color: var(--text);
- background: var(--bg);
- font-synthesis: none;
- text-rendering: optimizeLegibility;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
-
- @media (max-width: 1024px) {
- font-size: 16px;
- }
-}
-
-@media (prefers-color-scheme: dark) {
- :root {
- --text: #9ca3af;
- --text-h: #f3f4f6;
- --bg: #16171d;
- --border: #2e303a;
- --code-bg: #1f2028;
- --accent: #c084fc;
- --accent-bg: rgba(192, 132, 252, 0.15);
- --accent-border: rgba(192, 132, 252, 0.5);
- --social-bg: rgba(47, 48, 58, 0.5);
- --shadow:
- rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
- }
+@import "tailwindcss";
+@import "tw-animate-css";
+@import "shadcn/tailwind.css";
+@import "@fontsource-variable/inter";
- #social .button-icon {
- filter: invert(1) brightness(2);
- }
-}
+@custom-variant dark (&:is(.dark *));
-#root {
- width: 1126px;
- max-width: 100%;
- margin: 0 auto;
- text-align: center;
- border-inline: 1px solid var(--border);
- min-height: 100svh;
- display: flex;
- flex-direction: column;
- box-sizing: border-box;
+@theme inline {
+ --font-heading: var(--font-sans);
+ --font-sans: 'Inter Variable', sans-serif;
+ --color-sidebar-ring: var(--sidebar-ring);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar: var(--sidebar);
+ --color-chart-5: var(--chart-5);
+ --color-chart-4: var(--chart-4);
+ --color-chart-3: var(--chart-3);
+ --color-chart-2: var(--chart-2);
+ --color-chart-1: var(--chart-1);
+ --color-ring: var(--ring);
+ --color-input: var(--input);
+ --color-border: var(--border);
+ --color-destructive: var(--destructive);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-accent: var(--accent);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-muted: var(--muted);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-secondary: var(--secondary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-primary: var(--primary);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-popover: var(--popover);
+ --color-card-foreground: var(--card-foreground);
+ --color-card: var(--card);
+ --color-foreground: var(--foreground);
+ --color-background: var(--background);
+ --radius-sm: calc(var(--radius) * 0.6);
+ --radius-md: calc(var(--radius) * 0.8);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) * 1.4);
+ --radius-2xl: calc(var(--radius) * 1.8);
+ --radius-3xl: calc(var(--radius) * 2.2);
+ --radius-4xl: calc(var(--radius) * 2.6);
}
-body {
- margin: 0;
-}
-
-h1,
-h2 {
- font-family: var(--heading);
- font-weight: 500;
- color: var(--text-h);
-}
-
-h1 {
- font-size: 56px;
- letter-spacing: -1.68px;
- margin: 32px 0;
- @media (max-width: 1024px) {
- font-size: 36px;
- margin: 20px 0;
- }
-}
-h2 {
- font-size: 24px;
- line-height: 118%;
- letter-spacing: -0.24px;
- margin: 0 0 8px;
- @media (max-width: 1024px) {
- font-size: 20px;
- }
-}
-p {
- margin: 0;
+:root {
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --chart-1: oklch(0.87 0 0);
+ --chart-2: oklch(0.556 0 0);
+ --chart-3: oklch(0.439 0 0);
+ --chart-4: oklch(0.371 0 0);
+ --chart-5: oklch(0.269 0 0);
+ --radius: 0.625rem;
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
}
-code,
-.counter {
- font-family: var(--mono);
- display: inline-flex;
- border-radius: 4px;
- color: var(--text-h);
+.dark {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.922 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.556 0 0);
+ --chart-1: oklch(0.87 0 0);
+ --chart-2: oklch(0.556 0 0);
+ --chart-3: oklch(0.439 0 0);
+ --chart-4: oklch(0.371 0 0);
+ --chart-5: oklch(0.269 0 0);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.556 0 0);
}
-code {
- font-size: 15px;
- line-height: 135%;
- padding: 4px 8px;
- background: var(--code-bg);
-}
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+ html {
+ @apply font-sans;
+ }
+}
\ No newline at end of file
diff --git a/src/lib/site.ts b/src/lib/site.ts
new file mode 100644
index 0000000..65e7749
--- /dev/null
+++ b/src/lib/site.ts
@@ -0,0 +1,44 @@
+// Shared site-wide constants used for canonical URLs and social cards.
+// Change SITE_URL to your deployed origin. No trailing slash; build paths as
+// `${SITE_URL}/blog/...`.
+export const SITE_URL = 'https://example.com'
+export const SITE_NAME = 'website-template'
+export const SITE_DESCRIPTION =
+ 'A minimal starter built with TanStack Start, React, Tailwind, and shadcn — server-rendered, type-safe, and ready to grow.'
+
+type SocialMetaOptions = {
+ title: string
+ description: string
+ url: string
+ image?: string
+ type?: 'website' | 'article'
+}
+
+// The Open Graph + Twitter card tags shared by every route's `head()`. Callers
+// still own the page , meta description, canonical link, and any
+// page-specific tags (robots, article:published_time). Pass `image` once you
+// have social cards to point at.
+export function socialMeta({
+ title,
+ description,
+ url,
+ image,
+ type = 'website',
+}: SocialMetaOptions) {
+ return [
+ { property: 'og:type', content: type },
+ { property: 'og:site_name', content: SITE_NAME },
+ { property: 'og:title', content: title },
+ { property: 'og:description', content: description },
+ { property: 'og:url', content: url },
+ { name: 'twitter:card', content: image ? 'summary_large_image' : 'summary' },
+ { name: 'twitter:title', content: title },
+ { name: 'twitter:description', content: description },
+ ...(image
+ ? [
+ { property: 'og:image', content: image },
+ { name: 'twitter:image', content: image },
+ ]
+ : []),
+ ]
+}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..bd0c391
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/src/main.tsx b/src/main.tsx
deleted file mode 100644
index bef5202..0000000
--- a/src/main.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import { StrictMode } from 'react'
-import { createRoot } from 'react-dom/client'
-import './index.css'
-import App from './App.tsx'
-
-createRoot(document.getElementById('root')!).render(
-
-
- ,
-)
diff --git a/src/mdx-env.d.ts b/src/mdx-env.d.ts
new file mode 100644
index 0000000..a0e2999
--- /dev/null
+++ b/src/mdx-env.d.ts
@@ -0,0 +1,10 @@
+// Type declarations for MDX blog posts. Each post default-exports its rendered
+// content component and named-exports a `meta` object describing the entry.
+declare module '*.mdx' {
+ import type { ComponentType } from 'react'
+ import type { PostMeta } from '@/content/posts'
+
+ export const meta: PostMeta
+ const MDXContent: ComponentType<{ components?: Record }>
+ export default MDXContent
+}
diff --git a/src/providers/theme/index.ts b/src/providers/theme/index.ts
new file mode 100644
index 0000000..b5b50fa
--- /dev/null
+++ b/src/providers/theme/index.ts
@@ -0,0 +1,3 @@
+export { ThemeProvider } from './theme-provider'
+export { useTheme } from './use-theme'
+export type { Theme, ResolvedTheme } from './theme-context'
diff --git a/src/providers/theme/theme-context.ts b/src/providers/theme/theme-context.ts
new file mode 100644
index 0000000..3de6fad
--- /dev/null
+++ b/src/providers/theme/theme-context.ts
@@ -0,0 +1,14 @@
+import { createContext } from 'react'
+
+export type Theme = 'light' | 'dark' | 'system'
+export type ResolvedTheme = 'light' | 'dark'
+
+export type ThemeContextValue = {
+ theme: Theme
+ resolvedTheme: ResolvedTheme
+ setTheme: (theme: Theme) => void
+}
+
+export const THEME_STORAGE_KEY = 'theme'
+
+export const ThemeContext = createContext(null)
diff --git a/src/providers/theme/theme-provider.tsx b/src/providers/theme/theme-provider.tsx
new file mode 100644
index 0000000..52e7152
--- /dev/null
+++ b/src/providers/theme/theme-provider.tsx
@@ -0,0 +1,68 @@
+import { useEffect, useSyncExternalStore, type ReactNode } from 'react'
+
+import { THEME_STORAGE_KEY, ThemeContext, type ResolvedTheme, type Theme } from './theme-context'
+
+// External store: theme lives in localStorage + the OS colour-scheme media
+// query. useSyncExternalStore reads it SSR-safely (getServerSnapshot) with no
+// hydration mismatch and no setState-in-effect.
+const listeners = new Set<() => void>()
+
+function subscribe(onStoreChange: () => void) {
+ listeners.add(onStoreChange)
+ const media = window.matchMedia('(prefers-color-scheme: dark)')
+ media.addEventListener('change', onStoreChange)
+ window.addEventListener('storage', onStoreChange)
+ return () => {
+ listeners.delete(onStoreChange)
+ media.removeEventListener('change', onStoreChange)
+ window.removeEventListener('storage', onStoreChange)
+ }
+}
+
+function prefersDark() {
+ if (typeof window === 'undefined') return false
+ return window.matchMedia('(prefers-color-scheme: dark)').matches
+}
+
+function readStoredTheme(): Theme {
+ if (typeof localStorage === 'undefined') return 'system'
+ try {
+ const stored = localStorage.getItem(THEME_STORAGE_KEY)
+ return stored === 'light' || stored === 'dark' || stored === 'system' ? stored : 'system'
+ } catch {
+ return 'system'
+ }
+}
+
+function resolveTheme(theme: Theme): ResolvedTheme {
+ if (theme === 'system') return prefersDark() ? 'dark' : 'light'
+ return theme
+}
+
+export function ThemeProvider({ children }: { children: ReactNode }) {
+ const theme = useSyncExternalStore(subscribe, readStoredTheme, () => 'system' as Theme)
+ const resolvedTheme = useSyncExternalStore(
+ subscribe,
+ () => resolveTheme(readStoredTheme()),
+ () => 'light' as ResolvedTheme,
+ )
+
+ useEffect(() => {
+ document.documentElement.classList.toggle('dark', resolvedTheme === 'dark')
+ }, [resolvedTheme])
+
+ const setTheme = (next: Theme) => {
+ try {
+ localStorage.setItem(THEME_STORAGE_KEY, next)
+ } catch {
+ // Ignore storage failures (private mode, disabled storage).
+ }
+ listeners.forEach((listener) => listener())
+ }
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/providers/theme/use-theme.ts b/src/providers/theme/use-theme.ts
new file mode 100644
index 0000000..d4a6251
--- /dev/null
+++ b/src/providers/theme/use-theme.ts
@@ -0,0 +1,11 @@
+import { useContext } from 'react'
+
+import { ThemeContext } from './theme-context'
+
+export function useTheme() {
+ const context = useContext(ThemeContext)
+ if (!context) {
+ throw new Error('useTheme must be used within a ThemeProvider')
+ }
+ return context
+}
diff --git a/src/router.tsx b/src/router.tsx
new file mode 100644
index 0000000..0b054f5
--- /dev/null
+++ b/src/router.tsx
@@ -0,0 +1,15 @@
+import { createRouter } from '@tanstack/react-router'
+
+import { NotFound } from '@/components/layout/not-found'
+import { routeTree } from './routeTree.gen'
+
+export function getRouter() {
+ return createRouter({
+ routeTree,
+ defaultPreload: 'intent',
+ scrollRestoration: true,
+ // Site-wide fallback for any unmatched route (the blog $slug route
+ // overrides this with its own notFoundComponent).
+ defaultNotFoundComponent: NotFound,
+ })
+}
diff --git a/src/routes/404.tsx b/src/routes/404.tsx
new file mode 100644
index 0000000..2b860f1
--- /dev/null
+++ b/src/routes/404.tsx
@@ -0,0 +1,16 @@
+import { createFileRoute } from '@tanstack/react-router'
+
+import { NotFound } from '@/components/layout/not-found'
+
+// Prerendered to dist/client/404.html (see vite.config.ts `pages`) so a static
+// host can serve our branded not-found page for any unmatched URL. In-app
+// navigation to a missing route uses the router's defaultNotFoundComponent.
+export const Route = createFileRoute('/404')({
+ head: () => ({
+ meta: [
+ { title: 'Page not found · website-template' },
+ { name: 'robots', content: 'noindex' },
+ ],
+ }),
+ component: NotFound,
+})
diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx
new file mode 100644
index 0000000..d63712b
--- /dev/null
+++ b/src/routes/__root.tsx
@@ -0,0 +1,34 @@
+import { createRootRoute } from '@tanstack/react-router'
+
+import { RootDocument } from '@/components/layout/root-document'
+import { SITE_DESCRIPTION, SITE_NAME, SITE_URL, socialMeta } from '@/lib/site'
+import appCss from '@/index.css?url'
+
+const TITLE = `${SITE_NAME} — TanStack Start starter`
+const HOME_URL = `${SITE_URL}/`
+
+// Apply the saved theme before paint to avoid a flash of the wrong colour
+// scheme. Keep the 'theme' key in sync with THEME_STORAGE_KEY in
+// src/providers/theme/theme-context.ts.
+const themeScript = `(function(){try{var t=localStorage.getItem('theme')||'system';var d=t==='dark'||(t==='system'&&window.matchMedia('(prefers-color-scheme: dark)').matches);if(d)document.documentElement.classList.add('dark');}catch(e){}})();`
+
+export const Route = createRootRoute({
+ head: () => ({
+ meta: [
+ { charSet: 'utf-8' },
+ { name: 'viewport', content: 'width=device-width, initial-scale=1' },
+ { title: TITLE },
+ { name: 'description', content: SITE_DESCRIPTION },
+ { name: 'robots', content: 'index, follow' },
+ ...socialMeta({ title: TITLE, description: SITE_DESCRIPTION, url: HOME_URL }),
+ ],
+ // Canonical is page-specific and lives on each leaf route so it isn't
+ // duplicated; TanStack dedupes but not . See routes/index.tsx.
+ links: [
+ { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
+ { rel: 'stylesheet', href: appCss },
+ ],
+ scripts: [{ children: themeScript }],
+ }),
+ component: RootDocument,
+})
diff --git a/src/routes/blog/$slug.tsx b/src/routes/blog/$slug.tsx
new file mode 100644
index 0000000..f9bc134
--- /dev/null
+++ b/src/routes/blog/$slug.tsx
@@ -0,0 +1,38 @@
+import { createFileRoute, notFound } from '@tanstack/react-router'
+
+import { BlogNotFound, BlogPostPage } from '@/components/blog/blog-post-page'
+import { getPost } from '@/content/posts'
+import { SITE_URL, socialMeta } from '@/lib/site'
+
+export const Route = createFileRoute('/blog/$slug')({
+ loader: ({ params }) => {
+ if (!getPost(params.slug)) throw notFound()
+ },
+ head: ({ params }) => {
+ const post = getPost(params.slug)
+ // notFound path: keep the missing post out of search indexes.
+ if (!post) {
+ return {
+ meta: [
+ { title: 'Post not found · website-template' },
+ { name: 'robots', content: 'noindex' },
+ ],
+ }
+ }
+
+ const title = `${post.meta.title} · website-template`
+ const url = `${SITE_URL}/blog/${post.slug}`
+
+ return {
+ meta: [
+ { title },
+ { name: 'description', content: post.meta.description },
+ ...socialMeta({ title, description: post.meta.description, url, type: 'article' }),
+ { property: 'article:published_time', content: post.meta.date },
+ ],
+ links: [{ rel: 'canonical', href: url }],
+ }
+ },
+ component: BlogPostPage,
+ notFoundComponent: BlogNotFound,
+})
diff --git a/src/routes/blog/index.tsx b/src/routes/blog/index.tsx
new file mode 100644
index 0000000..bc5a6ca
--- /dev/null
+++ b/src/routes/blog/index.tsx
@@ -0,0 +1,20 @@
+import { createFileRoute } from '@tanstack/react-router'
+
+import { BlogIndex } from '@/components/blog/blog-index'
+import { SITE_URL, socialMeta } from '@/lib/site'
+
+const TITLE = 'Blog · website-template'
+const DESCRIPTION = 'Notes, updates, and the thinking behind this template.'
+const URL = `${SITE_URL}/blog`
+
+export const Route = createFileRoute('/blog/')({
+ head: () => ({
+ meta: [
+ { title: TITLE },
+ { name: 'description', content: DESCRIPTION },
+ ...socialMeta({ title: TITLE, description: DESCRIPTION, url: URL }),
+ ],
+ links: [{ rel: 'canonical', href: URL }],
+ }),
+ component: BlogIndex,
+})
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
new file mode 100644
index 0000000..313733d
--- /dev/null
+++ b/src/routes/index.tsx
@@ -0,0 +1,11 @@
+import { createFileRoute } from '@tanstack/react-router'
+
+import { LandingPage } from '@/components/home/landing-page'
+import { SITE_URL } from '@/lib/site'
+
+export const Route = createFileRoute('/')({
+ head: () => ({
+ links: [{ rel: 'canonical', href: `${SITE_URL}/` }],
+ }),
+ component: LandingPage,
+})
diff --git a/tsconfig.app.json b/tsconfig.app.json
index 6830b6f..71062e0 100644
--- a/tsconfig.app.json
+++ b/tsconfig.app.json
@@ -20,7 +20,14 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
- "noFallthroughCasesInSwitch": true
+ "noFallthroughCasesInSwitch": true,
+
+ /* Aliasing */
+ "ignoreDeprecations": "6.0",
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
},
"include": ["src"]
}
diff --git a/tsconfig.json b/tsconfig.json
index 1ffef60..58ed6e9 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,4 +1,11 @@
{
+ "compilerOptions": {
+ "ignoreDeprecations": "6.0",
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
diff --git a/vite.config.ts b/vite.config.ts
index 8b0f57b..8beb961 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,7 +1,32 @@
import { defineConfig } from 'vite'
+import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import react from '@vitejs/plugin-react'
+import tailwindcss from '@tailwindcss/vite'
+import mdx from '@mdx-js/rollup'
+import path from 'path'
// https://vite.dev/config/
export default defineConfig({
- plugins: [react()],
+ plugins: [
+ tanstackStart({
+ // Prerender the whole site to static HTML, crawling links from the
+ // homepage to discover every reachable page.
+ prerender: { enabled: true, crawlLinks: true },
+ // Emit the /404 route as dist/client/404.html; static hosts serve it
+ // (with a 404 status) for unmatched URLs. crawlLinks never reaches /404
+ // since nothing links to it, so it is listed explicitly.
+ pages: [{ path: '/404', prerender: { enabled: true, outputPath: '/404.html' } }],
+ }),
+ tailwindcss(),
+ // MDX must run before react's plugin so the JSX it emits gets transformed.
+ { enforce: 'pre', ...mdx() },
+ // react's plugin must come after start's plugin; include mdx so posts go
+ // through Fast Refresh and the automatic JSX runtime.
+ react({ include: /\.(jsx|js|mdx|md|tsx|ts)$/ }),
+ ],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
})
Connect with us
-Join the Vite community
---
-
-
- GitHub
-
-
- -
-
-
- Discord
-
-
- -
-
-
- X.com
-
-
- -
-
-
- Bluesky
-
-
-
-