diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx
index de949747..eb73a9cb 100644
--- a/src/components/Navbar.tsx
+++ b/src/components/Navbar.tsx
@@ -33,6 +33,12 @@ import { SearchButton } from './SearchButton'
import { libraries, SIDEBAR_LIBRARY_IDS, type LibrarySlim } from '~/libraries'
import { useClickOutside } from '~/hooks/useClickOutside'
import { GithubIcon } from '~/components/icons/GithubIcon'
+import {
+ Dropdown,
+ DropdownContent,
+ DropdownItem,
+ DropdownTrigger,
+} from '~/components/Dropdown'
import { DiscordIcon } from '~/components/icons/DiscordIcon'
import { InstagramIcon } from '~/components/icons/InstagramIcon'
import { BSkyIcon } from '~/components/icons/BSkyIcon'
@@ -216,40 +222,7 @@ export function Navbar({ children }: { children: React.ReactNode }) {
)
- const socialLinks = (
-
- )
+ const socialLinks =
const navbar = (
)
}
+
+const SOCIAL_LINKS = [
+ {
+ label: 'GitHub',
+ href: 'https://github.com/TanStack',
+ Icon: GithubIcon,
+ },
+ {
+ label: 'Discord',
+ href: 'https://tlinz.com/discord',
+ Icon: DiscordIcon,
+ },
+ {
+ label: 'YouTube',
+ href: 'https://youtube.com/@tan_stack',
+ Icon: YouTubeIcon,
+ },
+ {
+ label: 'X (Twitter)',
+ href: 'https://x.com/tan_stack',
+ Icon: BrandXIcon,
+ },
+ {
+ label: 'Bluesky',
+ href: 'https://bsky.app/profile/tanstack.com',
+ Icon: BSkyIcon,
+ },
+ {
+ label: 'Instagram',
+ href: 'https://instagram.com/tan_stack',
+ Icon: InstagramIcon,
+ },
+] as const
+
+function SocialStack() {
+ const stackTop = SOCIAL_LINKS.slice(0, 3)
+
+ return (
+
+
+
+
+
+ {SOCIAL_LINKS.map(({ label, href, Icon }) => (
+
+
+
+ {label}
+
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/stack/CategoryArticle.tsx b/src/components/stack/CategoryArticle.tsx
new file mode 100644
index 00000000..a1b97838
--- /dev/null
+++ b/src/components/stack/CategoryArticle.tsx
@@ -0,0 +1,320 @@
+import * as React from 'react'
+import { Link } from '@tanstack/react-router'
+import { twMerge } from 'tailwind-merge'
+import {
+ ArrowRight,
+ Award,
+ ChevronRight,
+ Layers,
+ Newspaper,
+ Sparkles,
+} from 'lucide-react'
+import { GitHub } from '~/ui'
+
+import type { LibrarySlim } from '~/libraries'
+import { formatPublishedDate, getPostsForLibrary } from '~/utils/blog'
+import {
+ categoryMeta,
+ getCategoryLibraries,
+ type CategorySlug,
+} from './stack-categories'
+
+export function CategoryArticle({ slug }: { slug: CategorySlug }) {
+ const meta = categoryMeta[slug]
+ const libraries = getCategoryLibraries(slug)
+ const topPick =
+ libraries.find((lib) => lib.id === meta.topPickId) ?? libraries[0]
+ const relatedPosts = libraries
+ .flatMap((lib) => getPostsForLibrary(lib.id).map((p) => ({ post: p, lib })))
+ .slice(0, 4)
+
+ return (
+
+ {/* Breadcrumb */}
+
+
+
+ Home
+
+
+
+ Libraries
+
+
+
+ {meta.name}
+
+
+
+
+ {/* Hero */}
+
+
+
+ {meta.shortName}
+
+
+ {meta.headline}
+
+
+ {meta.intro}
+
+
+
+
+ {/* Body */}
+
+
+
+
+ {relatedPosts.length > 0 && (
+
+ )}
+
+
+
+ )
+}
+
+function TopPickBlock({ library }: { library: LibrarySlim }) {
+ return (
+
+
+ Where to start
+
+
+ Start with {library.name}
+
+
+
+
+
+ TanStack
+
+
+ {library.name.replace('TanStack ', '')}
+
+
+ {library.tagline}
+
+
+
+ Open the library
+
+
+
+
+ {library.description}
+
+
+
+
+ {library.frameworks.slice(0, 6).map((fw) => (
+
+ ))}
+ {library.frameworks.length > 6 && (
+
+ + {library.frameworks.length - 6} more frameworks
+
+ )}
+
+ tanstack/
+ {library.repo?.split('/').pop()}
+
+
+
+ )
+}
+
+function FullListBlock({
+ libraries,
+ topPickId,
+}: {
+ libraries: LibrarySlim[]
+ topPickId: string
+}) {
+ return (
+
+
+ The full list
+
+
+ Every library in this category
+
+
+ {libraries.map((lib, i) => (
+
+ ))}
+
+
+ )
+}
+
+function LibraryEntry({
+ library,
+ rank,
+ isTopPick,
+}: {
+ library: LibrarySlim
+ rank: number
+ isTopPick: boolean
+}) {
+ return (
+
+
+
+ {rank}
+
+
+
+
+ TanStack
+
+
+ {library.name.replace('TanStack ', '')}
+
+ {library.badge && (
+
+ {library.badge}
+
+ )}
+ {isTopPick && (
+
+ Where to start
+
+ )}
+
+
+ {library.tagline}
+
+
+ {library.description}
+
+
+
+ {library.frameworks.slice(0, 5).map((fw) => (
+
+ ))}
+ {library.frameworks.length > 5 && (
+
+ + {library.frameworks.length - 5} more
+
+ )}
+
+ Open {library.name.replace('TanStack ', '')}{' '}
+
+
+
+
+
+
+ )
+}
+
+function RelatedPostsBlock({
+ items,
+}: {
+ items: Array<{
+ post: { slug: string; title: string; published: string; excerpt?: string }
+ lib: LibrarySlim
+ }>
+}) {
+ return (
+
+
+ From the team
+
+
+ Recent writing tagged with this category
+
+
+ {items.map(({ post, lib }) => (
+ -
+
+
+ {lib.name.replace('TanStack ', '')}
+
+
+
+ {post.title}
+
+
+ {formatPublishedDate(post.published)}
+
+
+
+
+
+ ))}
+
+
+ )
+}
+
+function SectionEyebrow({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function FrameworkChip({ label }: { label: string }) {
+ return (
+
+ {label}
+
+ )
+}
diff --git a/src/components/stack/stack-categories.ts b/src/components/stack/stack-categories.ts
new file mode 100644
index 00000000..0a9052b0
--- /dev/null
+++ b/src/components/stack/stack-categories.ts
@@ -0,0 +1,123 @@
+import { librariesByGroup, librariesGroupNamesMap } from '~/libraries'
+import type { LibrarySlim } from '~/libraries'
+
+export type GroupId = keyof typeof librariesByGroup
+
+export type CategorySlug =
+ | 'framework'
+ | 'state'
+ | 'ui'
+ | 'performance'
+ | 'tooling'
+
+export const slugToGroup: Record = {
+ framework: 'framework',
+ state: 'state',
+ ui: 'headlessUI',
+ performance: 'performance',
+ tooling: 'tooling',
+}
+
+export const groupToSlug: Record = {
+ framework: 'framework',
+ state: 'state',
+ headlessUI: 'ui',
+ performance: 'performance',
+ tooling: 'tooling',
+}
+
+export const categorySlugs = Object.keys(slugToGroup) as CategorySlug[]
+
+export type CategoryMeta = {
+ slug: CategorySlug
+ groupId: GroupId
+ name: string
+ shortName: string
+ headline: string
+ intro: string
+ topPickId: string
+ /** Accent gradient classes for the hero / numbered chips. */
+ accent: { from: string; to: string; text: string }
+}
+
+export const categoryMeta: Record = {
+ framework: {
+ slug: 'framework',
+ groupId: 'framework',
+ name: librariesGroupNamesMap.framework,
+ shortName: 'Framework',
+ headline: 'The TanStack framework layer',
+ intro:
+ 'Type-safe routing and a full-stack framework built on top of it. Start small with Router, or go end-to-end with Start.',
+ topPickId: 'start',
+ accent: {
+ from: 'from-teal-500',
+ to: 'to-cyan-500',
+ text: 'text-cyan-600 dark:text-cyan-400',
+ },
+ },
+ state: {
+ slug: 'state',
+ groupId: 'state',
+ name: librariesGroupNamesMap.state,
+ shortName: 'Data & State',
+ headline: 'Data and state — without the ceremony',
+ intro:
+ 'Server state, async data, reactive stores, and an AI-aware layer on top. The libraries you reach for when an app needs to remember things, fetch things, and stay coherent across the screen.',
+ topPickId: 'query',
+ accent: {
+ from: 'from-cyan-500',
+ to: 'to-emerald-500',
+ text: 'text-cyan-600 dark:text-cyan-400',
+ },
+ },
+ ui: {
+ slug: 'ui',
+ groupId: 'headlessUI',
+ name: librariesGroupNamesMap.headlessUI,
+ shortName: 'UI & UX',
+ headline: 'Headless primitives for the surfaces users touch',
+ intro:
+ 'Tables, forms, keyboard shortcuts — owned by you, styled by you, validated by the compiler.',
+ topPickId: 'table',
+ accent: {
+ from: 'from-blue-500',
+ to: 'to-yellow-500',
+ text: 'text-blue-600 dark:text-blue-400',
+ },
+ },
+ performance: {
+ slug: 'performance',
+ groupId: 'performance',
+ name: librariesGroupNamesMap.performance,
+ shortName: 'Performance',
+ headline: 'Keep the long lists buttery, the noisy events tame',
+ intro:
+ 'Virtualisation, debouncing, throttling, batching — primitives that compose instead of one-off hooks.',
+ topPickId: 'virtual',
+ accent: {
+ from: 'from-purple-500',
+ to: 'to-lime-500',
+ text: 'text-purple-600 dark:text-purple-400',
+ },
+ },
+ tooling: {
+ slug: 'tooling',
+ groupId: 'tooling',
+ name: librariesGroupNamesMap.tooling,
+ shortName: 'Tooling',
+ headline: 'Devtools, scaffolds, and packaging defaults',
+ intro:
+ 'Take the boring decisions off your plate, so the interesting work stays interesting.',
+ topPickId: 'devtools',
+ accent: {
+ from: 'from-indigo-500',
+ to: 'to-orange-500',
+ text: 'text-indigo-600 dark:text-indigo-400',
+ },
+ },
+}
+
+export function getCategoryLibraries(slug: CategorySlug): LibrarySlim[] {
+ return [...librariesByGroup[slugToGroup[slug]]]
+}
diff --git a/src/libraries/libraries.ts b/src/libraries/libraries.ts
index f3d305fd..ceec0883 100644
--- a/src/libraries/libraries.ts
+++ b/src/libraries/libraries.ts
@@ -835,13 +835,15 @@ export const libraries: LibrarySlim[] = [
]
export const librariesByGroup = {
- state: [start, router, query, db, store, ai],
+ framework: [start, router],
+ state: [query, db, store, ai],
headlessUI: [table, form, hotkeys],
performance: [virtual, pacer],
tooling: [devtools, config, cli, intent],
}
export const librariesGroupNamesMap = {
+ framework: 'Framework',
state: 'Data & State Management',
headlessUI: 'UI & UX',
performance: 'Performance',
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index ca6a34ad..659d1246 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -49,6 +49,7 @@ import { Route as BlogIndexRouteImport } from './routes/blog.index'
import { Route as AdminIndexRouteImport } from './routes/admin/index'
import { Route as AccountIndexRouteImport } from './routes/account/index'
import { Route as LibraryIdIndexRouteImport } from './routes/$libraryId/index'
+import { Route as StackCategoryRouteImport } from './routes/stack.$category'
import { Route as ShowcaseSubmitRouteImport } from './routes/showcase/submit'
import { Route as ShowcaseIdRouteImport } from './routes/showcase/$id'
import { Route as ShopSearchRouteImport } from './routes/shop.search'
@@ -353,6 +354,11 @@ const LibraryIdIndexRoute = LibraryIdIndexRouteImport.update({
path: '/',
getParentRoute: () => LibraryIdRouteRoute,
} as any)
+const StackCategoryRoute = StackCategoryRouteImport.update({
+ id: '/stack/$category',
+ path: '/stack/$category',
+ getParentRoute: () => rootRouteImport,
+} as any)
const ShowcaseSubmitRoute = ShowcaseSubmitRouteImport.update({
id: '/showcase/submit',
path: '/showcase/submit',
@@ -953,6 +959,7 @@ export interface FileRoutesByFullPath {
'/shop/search': typeof ShopSearchRoute
'/showcase/$id': typeof ShowcaseIdRoute
'/showcase/submit': typeof ShowcaseSubmitRoute
+ '/stack/$category': typeof StackCategoryRoute
'/$libraryId/': typeof LibraryIdIndexRoute
'/account/': typeof AccountIndexRoute
'/admin/': typeof AdminIndexRoute
@@ -1090,6 +1097,7 @@ export interface FileRoutesByTo {
'/shop/search': typeof ShopSearchRoute
'/showcase/$id': typeof ShowcaseIdRoute
'/showcase/submit': typeof ShowcaseSubmitRoute
+ '/stack/$category': typeof StackCategoryRoute
'/$libraryId': typeof LibraryIdIndexRoute
'/account': typeof AccountIndexRoute
'/admin': typeof AdminIndexRoute
@@ -1234,6 +1242,7 @@ export interface FileRoutesById {
'/shop/search': typeof ShopSearchRoute
'/showcase/$id': typeof ShowcaseIdRoute
'/showcase/submit': typeof ShowcaseSubmitRoute
+ '/stack/$category': typeof StackCategoryRoute
'/$libraryId/': typeof LibraryIdIndexRoute
'/account/': typeof AccountIndexRoute
'/admin/': typeof AdminIndexRoute
@@ -1381,6 +1390,7 @@ export interface FileRouteTypes {
| '/shop/search'
| '/showcase/$id'
| '/showcase/submit'
+ | '/stack/$category'
| '/$libraryId/'
| '/account/'
| '/admin/'
@@ -1518,6 +1528,7 @@ export interface FileRouteTypes {
| '/shop/search'
| '/showcase/$id'
| '/showcase/submit'
+ | '/stack/$category'
| '/$libraryId'
| '/account'
| '/admin'
@@ -1661,6 +1672,7 @@ export interface FileRouteTypes {
| '/shop/search'
| '/showcase/$id'
| '/showcase/submit'
+ | '/stack/$category'
| '/$libraryId/'
| '/account/'
| '/admin/'
@@ -1790,6 +1802,7 @@ export interface RootRouteChildren {
OauthTokenRoute: typeof OauthTokenRoute
ShowcaseIdRoute: typeof ShowcaseIdRoute
ShowcaseSubmitRoute: typeof ShowcaseSubmitRoute
+ StackCategoryRoute: typeof StackCategoryRoute
ShowcaseIndexRoute: typeof ShowcaseIndexRoute
StatsIndexRoute: typeof StatsIndexRoute
ApiApplicationStarterResolveRoute: typeof ApiApplicationStarterResolveRoute
@@ -2122,6 +2135,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LibraryIdIndexRouteImport
parentRoute: typeof LibraryIdRouteRoute
}
+ '/stack/$category': {
+ id: '/stack/$category'
+ path: '/stack/$category'
+ fullPath: '/stack/$category'
+ preLoaderRoute: typeof StackCategoryRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/showcase/submit': {
id: '/showcase/submit'
path: '/showcase/submit'
@@ -3109,6 +3129,7 @@ const rootRouteChildren: RootRouteChildren = {
OauthTokenRoute: OauthTokenRoute,
ShowcaseIdRoute: ShowcaseIdRoute,
ShowcaseSubmitRoute: ShowcaseSubmitRoute,
+ StackCategoryRoute: StackCategoryRoute,
ShowcaseIndexRoute: ShowcaseIndexRoute,
StatsIndexRoute: StatsIndexRoute,
ApiApplicationStarterResolveRoute: ApiApplicationStarterResolveRoute,
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
index 9e16b636..628f0a85 100644
--- a/src/routes/index.tsx
+++ b/src/routes/index.tsx
@@ -8,13 +8,14 @@ import {
import discordImage from '~/images/discord-logo-white.svg'
import { librariesByGroup, librariesGroupNamesMap, Library } from '~/libraries'
+import { groupToSlug } from '~/components/stack/stack-categories'
+import { twMerge } from 'tailwind-merge'
import { NetlifyImage } from '~/components/NetlifyImage'
import { TrustedByMarquee } from '~/components/TrustedByMarquee'
import { ArrowRight, Code2, Layers, Shield, Zap, Play } from 'lucide-react'
import { YouTubeIcon } from '~/components/icons/YouTubeIcon'
import { Card } from '~/components/Card'
-import LibraryCard from '~/components/LibraryCard'
import { HomeApplicationStarter } from '~/components/home/HomeApplicationStarter'
import { HomeDeferredSection } from '~/components/home/HomeDeferredSection'
import {
@@ -271,49 +272,35 @@ function Index() {
+
+ Every TanStack library, organized by what it does.
+
- {Object.entries(librariesByGroup).map(
- ([groupName, groupLibraries]) => (
-
-
- {
- librariesGroupNamesMap[
- groupName as keyof typeof librariesGroupNamesMap
- ]
- }
-
- {/* Library Cards */}
-
- {groupLibraries.map((library, i: number) => {
- return (
-
- )
- })}
-
-
- ),
- )}
+
+ {Object.entries(librariesByGroup).map(
+ ([groupName, groupLibraries]) => (
+
+ ),
+ )}
+
@@ -499,6 +486,54 @@ function Index() {
)
}
+function StackCategoryCard({
+ groupId,
+ libraries,
+}: {
+ groupId: keyof typeof librariesByGroup
+ libraries: Library[]
+}) {
+ const groupName = librariesGroupNamesMap[groupId]
+ const categorySlug = groupToSlug[groupId]
+
+ return (
+
+
+ Category
+
+
+ {groupName}
+
+
+ {libraries.map((lib, i) => (
+ -
+
+ {i + 1}
+
+
+ {lib.name.replace('TanStack ', '')}
+
+
+ ))}
+
+
+ Browse {groupName.toLowerCase()}
+
+
+
+ )
+}
+
function OpenSourceUnderline() {
return (
diff --git a/src/routes/stack.$category.tsx b/src/routes/stack.$category.tsx
new file mode 100644
index 00000000..a4a5a0e9
--- /dev/null
+++ b/src/routes/stack.$category.tsx
@@ -0,0 +1,36 @@
+import { createFileRoute, notFound } from '@tanstack/react-router'
+
+import { CategoryArticle } from '~/components/stack/CategoryArticle'
+import {
+ categoryMeta,
+ categorySlugs,
+ type CategorySlug,
+} from '~/components/stack/stack-categories'
+import { seo } from '~/utils/seo'
+
+function isCategorySlug(value: string): value is CategorySlug {
+ return (categorySlugs as readonly string[]).includes(value)
+}
+
+export const Route = createFileRoute('/stack/$category')({
+ loader: ({ params }) => {
+ if (!isCategorySlug(params.category)) {
+ throw notFound()
+ }
+ return { category: params.category, meta: categoryMeta[params.category] }
+ },
+ head: ({ loaderData }) => ({
+ meta: seo({
+ title: loaderData
+ ? `${loaderData.meta.shortName} — TanStack libraries`
+ : 'TanStack libraries',
+ description: loaderData?.meta.intro,
+ }),
+ }),
+ component: StackCategoryPage,
+})
+
+function StackCategoryPage() {
+ const { category } = Route.useLoaderData()
+ return
+}