Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 16 additions & 10 deletions content-collections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,22 @@ const posts = defineCollection({
name: 'posts',
directory: './src/blog',
include: '*.md',
schema: z.object({
title: z.string(),
published: z.iso.date(),
draft: z.boolean().optional(),
excerpt: z.string(),
authors: z.string().array(),
library: libraryListSchema.optional(),
content: z.string(),
redirect_from: z.string().array().optional(),
}),
schema: z
.object({
title: z.string(),
published: z.iso.date(),
updated: z.iso.date().optional(),
draft: z.boolean().optional(),
excerpt: z.string(),
authors: z.string().array(),
library: libraryListSchema.optional(),
content: z.string(),
redirect_from: z.string().array().optional(),
})
.refine((post) => !post.updated || post.updated >= post.published, {
message: 'updated must be on or after published',
path: ['updated'],
}),
transform: ({ content, ...post }) => {
// Extract header image (first image after frontmatter)
const headerImageMatch = content.match(/!\[([^\]]*)\]\(([^)]+)\)/)
Expand Down
1 change: 1 addition & 0 deletions src/blog/incident-followup.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
title: 'Hardening TanStack After the npm Compromise'
published: 2026-05-12
updated: 2026-05-15
draft: false
excerpt: "A companion to our incident postmortem: what we're changing across the org so the May 11 supply-chain attack can't happen the same way again."
authors:
Expand Down
1 change: 1 addition & 0 deletions src/blog/npm-supply-chain-compromise-postmortem.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
title: 'Postmortem: TanStack npm supply-chain compromise'
published: 2026-05-11
updated: 2026-05-15
excerpt: On 2026-05-11, an attacker chained a pull_request_target Pwn Request, GitHub Actions cache poisoning across the fork↔base trust boundary, and OIDC token extraction from runner memory to publish 84 malicious versions across 42 @tanstack/* packages on npm. Full postmortem.
authors:
- Tanner Linsley
Expand Down
6 changes: 6 additions & 0 deletions src/components/Footer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type * as React from 'react'
import { Link } from '@tanstack/react-router'
import { LinkedinLogoIcon } from '@phosphor-icons/react/LinkedinLogo'
import { BSkyIcon } from '~/components/icons/BSkyIcon'
import { BrandXIcon } from '~/components/icons/BrandXIcon'
import { GithubIcon } from '~/components/icons/GithubIcon'
Expand Down Expand Up @@ -89,6 +90,11 @@ const SOCIAL_LINKS: Array<
to: 'https://youtube.com/@tan_stack',
Icon: YouTubeIcon,
},
{
label: 'LinkedIn',
to: 'https://www.linkedin.com/company/tanstack',
Icon: LinkedinLogoIcon,
},
]

const LEGAL_LINKS: Array<FooterLink> = [
Expand Down
6 changes: 6 additions & 0 deletions src/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { HeartIcon } from '@phosphor-icons/react/Heart'
import { InfinityIcon } from '@phosphor-icons/react/Infinity'
import { LifebuoyIcon } from '@phosphor-icons/react/Lifebuoy'
import { ListIcon } from '@phosphor-icons/react/List'
import { LinkedinLogoIcon } from '@phosphor-icons/react/LinkedinLogo'
import { MagnifyingGlassIcon } from '@phosphor-icons/react/MagnifyingGlass'
import { MailboxIcon } from '@phosphor-icons/react/Mailbox'
import { NotebookIcon } from '@phosphor-icons/react/Notebook'
Expand Down Expand Up @@ -1700,6 +1701,11 @@ const SOCIAL_LINKS = [
href: 'https://instagram.com/tan_stack',
Icon: InstagramIcon,
},
{
label: 'LinkedIn',
href: 'https://www.linkedin.com/company/tanstack',
Icon: LinkedinLogoIcon,
},
] as const

function SocialStack() {
Expand Down
41 changes: 5 additions & 36 deletions src/routes/blog.$.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'
import { seo } from '~/utils/seo'
import { PostNotFound } from './blog'
import { formatAuthors } from '~/utils/blog-format'
import * as React from 'react'
import { MarkdownContent } from '~/components/markdown'
import { Card } from '~/components/Card'
Expand All @@ -16,7 +14,7 @@ import { Breadcrumbs } from '~/components/Breadcrumbs'
import { CoverFallback } from '~/components/CoverFallback'
import { fetchBlogPost } from '~/utils/blog.functions'
import { parseSiteMarkdown } from '~/utils/markdown'
import { getAbsoluteOptimizedImageUrl } from '~/utils/optimizedImage'
import { getBlogPostHead, getBlogSocialImageUrl } from '~/utils/blog-post-seo'

export const Route = createFileRoute('/blog/$')({
staleTime: Infinity,
Expand All @@ -30,40 +28,11 @@ export const Route = createFileRoute('/blog/$')({
return fetchBlogPost({ data: blogPath })
},
head: ({ loaderData }) => {
const getSocialImageUrl = (headerImage?: string) => {
if (!headerImage) return undefined
const socialImage = getBlogSocialImageUrl(loaderData?.headerImage)

if (headerImage.startsWith('http')) {
return headerImage
}

return getAbsoluteOptimizedImageUrl(headerImage, {
fit: 'cover',
format: 'auto',
height: 630,
quality: 80,
width: 1200,
})
}

return {
meta: loaderData
? [
...seo({
title: `${loaderData?.title ?? 'Docs'} | TanStack Blog`,
description: loaderData?.description,
image: getSocialImageUrl(loaderData?.headerImage),
noindex: loaderData?.isUnpublished,
}),
{
name: 'author',
content: `${
loaderData.authors.length > 1 ? 'co-authored by ' : ''
}${formatAuthors(loaderData.authors)}`,
},
]
: [],
}
return getBlogPostHead(
loaderData ? { ...loaderData, socialImage } : undefined,
)
},
notFoundComponent: () => <PostNotFound />,
component: BlogPost,
Expand Down
7 changes: 7 additions & 0 deletions src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { useLibrariesOverlay } from '~/contexts/LibrariesOverlayContext'
import { fetchRecentPosts } from '~/utils/blog.functions'
import { usePrefersReducedMotion } from '~/utils/usePrefersReducedMotion'
import { seo } from '~/utils/seo'
import { getTanStackHomepageJsonLd } from '~/utils/organization-structured-data'

export const Route = createFileRoute('/')({
loader: async ({ context: { queryClient } }) => {
Expand All @@ -44,6 +45,12 @@ export const Route = createFileRoute('/')({
description:
'Headless, type-safe, composable tools for building modern web applications that work naturally for developers and reliably for agents.',
}),
scripts: [
{
type: 'application/ld+json',
children: JSON.stringify(getTanStackHomepageJsonLd()),
},
],
}),
component: Index,
})
Expand Down
46 changes: 46 additions & 0 deletions src/utils/blog-format.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { matchSorter } from 'match-sorter'
import { findLibrary, type LibrarySlim } from '~/libraries'
import { SITE_URL } from '~/utils/site'

const listJoiner = new Intl.ListFormat('en-US', {
style: 'long',
Expand All @@ -22,6 +23,17 @@ export type BlogCardPost = {
source?: string
}

export type BlogAuthorIdentity = {
type: 'Organization' | 'Person'
name: string
url?: string
}

type BlogAuthorProfile = {
name: string
github: string
}

export function normalizeBlogAuthor(author: string) {
return authorAliases.get(author) ?? author
}
Expand All @@ -42,6 +54,33 @@ export function normalizeBlogAuthors(authors: Array<string>) {
return normalizedAuthors
}

export function getBlogAuthorIdentities(
authors: Array<string>,
profiles: ReadonlyArray<BlogAuthorProfile>,
): Array<BlogAuthorIdentity> {
const normalizedAuthors = normalizeBlogAuthors(authors)

if (!normalizedAuthors.length) {
return [
{
type: 'Organization',
name: 'TanStack',
url: `${SITE_URL}/`,
},
]
}

return normalizedAuthors.map((name) => {
const profile = profiles.find((candidate) => candidate.name === name)

return {
type: 'Person',
name,
...(profile ? { url: `https://github.com/${profile.github}` } : {}),
}
})
}

export function formatAuthors(authors: Array<string>) {
const normalizedAuthors = normalizeBlogAuthors(authors)

Expand Down Expand Up @@ -75,6 +114,13 @@ export function isPublishedDateReleased(published: string, now = new Date()) {
return published <= getUtcDateString(now)
}

export function isBlogPostUnpublished(
post: { draft?: boolean; published: string },
now = new Date(),
) {
return Boolean(post.draft) || !isPublishedDateReleased(post.published, now)
}

export function publishedDateToUTCString(published: string) {
return parsePublishedDate(published).toUTCString()
}
Expand Down
109 changes: 109 additions & 0 deletions src/utils/blog-post-seo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import type { BlogAuthorIdentity } from '~/utils/blog-format'
import { formatAuthors } from '~/utils/blog-format'
import {
getTanStackOrganizationJsonLd,
TANSTACK_ORGANIZATION_ID,
} from '~/utils/organization-structured-data'
import { getAbsoluteOptimizedImageUrl } from '~/utils/optimizedImage'
import { canonicalUrl, seo } from '~/utils/seo'

export type BlogPostHeadData = {
authorIdentities: Array<BlogAuthorIdentity>
authors: Array<string>
description: string
isUnpublished: boolean
published: string
slug: string
socialImage?: string
title: string
updated?: string
}

export function getBlogSocialImageUrl(headerImage: string | undefined) {
if (!headerImage) {
return undefined
}

if (headerImage.startsWith('http')) {
return headerImage
}

return getAbsoluteOptimizedImageUrl(headerImage, {
fit: 'cover',
format: 'auto',
height: 630,
quality: 80,
width: 1200,
})
}

export function getBlogPostingJsonLd(post: BlogPostHeadData) {
const pageUrl = canonicalUrl(`/blog/${post.slug}`)

return {
'@context': 'https://schema.org',
'@graph': [
getTanStackOrganizationJsonLd(),
{
'@type': 'BlogPosting' as const,
'@id': `${pageUrl}#blog-post`,
headline: post.title,
description: post.description,
url: pageUrl,
mainEntityOfPage: {
'@type': 'WebPage' as const,
'@id': pageUrl,
},
...(post.socialImage ? { image: post.socialImage } : {}),
datePublished: post.published,
...(post.updated ? { dateModified: post.updated } : {}),
author: post.authorIdentities.map((author) =>
author.type === 'Organization' && author.name === 'TanStack'
? { '@id': TANSTACK_ORGANIZATION_ID }
: {
'@type': author.type,
name: author.name,
...(author.url ? { url: author.url } : {}),
},
),
publisher: {
'@id': TANSTACK_ORGANIZATION_ID,
},
},
],
}
}

export function getBlogPostHead(post: BlogPostHeadData | undefined) {
if (!post) {
return { meta: [], scripts: [] }
}

return {
meta: [
...seo({
title: `${post.title} | TanStack Blog`,
description: post.description,
image: post.socialImage,
noindex: post.isUnpublished,
ogType: 'article',
articlePublishedTime: post.published,
articleModifiedTime: post.updated,
}),
{
name: 'author',
content: `${
post.authors.length > 1 ? 'co-authored by ' : ''
}${formatAuthors(post.authors)}`,
},
],
scripts: post.isUnpublished
? []
: [
{
type: 'application/ld+json',
children: JSON.stringify(getBlogPostingJsonLd(post)),
},
],
}
}
Loading
Loading