A Tufte-inspired blog built with Next.js and Sanity CMS, featuring sidenotes, math rendering, and elegant typography.
Access the studio at https://doubleword.sanity.studio/ (or your studio URL).
Document Types:
- post - Blog posts
- author - Author profiles
Post Fields:
| Field | Purpose |
|---|---|
| title | Post title |
| slug | URL path (e.g., my-post → blog.doubleword.ai/my-post) |
| publishedAt | Publication date (shown on post and used for ordering) |
| body | Markdown content |
| description | Summary for homepage and SEO meta description |
| authors | Link to author profiles |
| image | Featured image (shown at top of post) |
| images | Upload images, reference by filename in body |
| videoUrl | Embed URL for video (shown above content) |
| externalSource | URL to fetch markdown content from (see below) |
| canonicalUrl | Original source URL for syndicated content |
Author Fields:
| Field | Purpose |
|---|---|
| name | Display name |
| title | Role/position |
| image | Profile photo |
Standard GitHub-flavored markdown is supported:
- Bold, italic,
strikethrough - Links,
inline code - Lists, tables, blockquotes
- Headings (h2 and h3 appear with anchor links)
- Upload image in the "Images" field with a filename (e.g.,
diagram.png) - Reference in body:

The filename is automatically replaced with the Sanity CDN URL. Alt text and captions from Sanity are used when available.
```python
print("Hello, world!")
```Supported languages: javascript, typescript, python, bash, json, jsx, tsx, yaml, shell, go, rust, sql, html, css, markdown, text, plaintext
Code blocks include a copy button on hover and use dual-theme syntax highlighting (light/dark).
Sidenotes appear in the margin on large screens and as toggleable popups on mobile.
Numbered sidenotes:
This is a statement that needs elaboration.[>1]
[>1]: This is the sidenote content. It can include **bold**, *italic*, [links](url), and `code`.Unnumbered sidenotes (margin notes):
This paragraph has a margin note.[>_note]
[>_note]: Margin notes don't have numbers—they're for tangential information.The syntax is similar to footnotes but uses > instead of ^:
[>id]- numbered sidenote reference[>id]: content- numbered sidenote definition[>_id]- unnumbered sidenote reference (note the underscore)[>_id]: content- unnumbered sidenote definition
Sidenote content supports markdown formatting including bold, italic, links, inline code, and math.
Inline math with single dollar signs:
The equation $E = mc^2$ is famous.Display math with double dollar signs:
$$
\int_0^\infty e^{-x^2} dx = \frac{\sqrt{\pi}}{2}
$$Math also works inside sidenotes.
Standard GitHub-flavored markdown tables:
| Header 1 | Header 2 |
|----------|----------|
| Cell 1 | Cell 2 |
| Cell 3 | Cell 4 |For posts where the markdown lives elsewhere (e.g., a GitHub repo), use the externalSource field:
- Set
externalSourceto the raw markdown URL (e.g.,https://raw.githubusercontent.com/...) - The blog fetches content from this URL at render time
- You can still set
bodyas a fallback if the external source fails
This is useful for:
- Keeping content in sync with a repository
- Cross-posting content that lives elsewhere
- Syndicated content from other platforms
For syndicated content (content that originally appeared elsewhere), set the canonicalUrl field to the original source URL. This ensures search engines know the original source and prevents duplicate content issues.
To embed a video at the top of a post:
- Set the
videoUrlfield to the embed URL (e.g., YouTube embed URL) - The video appears above the post content in a responsive 16:9 container
- Edit content in Sanity Studio
- Save creates a draft
- Publish makes content live and triggers webhook
- Site rebuilds affected pages automatically (usually within seconds)
- Use h2 (
##) and h3 (###) for main sections—they get anchor links - Keep slugs URL-friendly (lowercase, hyphens, no special characters)
- Use the
descriptionfield for better homepage summaries and SEO - Sidenotes work best for short asides; use regular paragraphs for longer content
- Test math rendering locally before publishing
Content not updating after publish:
- Check that the webhook is configured in Sanity
- Verify the revalidation secret matches
- Wait 10-30 seconds for cache to clear
Images not showing:
- Make sure filename in body matches exactly (case-sensitive)
- Verify image was uploaded in the Images field
Sidenotes not appearing:
- Check that the definition exists:
[>id]: content - Make sure the reference uses the same id:
[>id] - For unnumbered, both must have underscore:
[>_id]and[>_id]:
Math not rendering:
- Check for balanced dollar signs
- Escape special characters if needed
- Display math needs blank lines before and after
This is a Next.js 16 application using the App Router with:
- Static Site Generation (SSG) - All pages prerendered at build time
- Sanity CMS - Headless CMS for content management
- Webhook-based revalidation - Content updates trigger automatic page rebuilds
- Tufte-inspired design - Sidenotes, elegant typography, warm color palette
blog/
├── src/
│ ├── app/
│ │ ├── [slug]/
│ │ │ └── page.tsx # Individual post pages
│ │ ├── api/
│ │ │ ├── revalidate/route.ts # Sanity webhook handler
│ │ │ └── draft-mode/enable/route.ts
│ │ ├── layout.tsx # Root layout with fonts
│ │ ├── page.tsx # Homepage with post list
│ │ ├── globals.css # Tufte-inspired styles
│ │ ├── robots.ts # Robots.txt generation
│ │ └── sitemap.ts # Sitemap generation
│ ├── components/
│ │ ├── MarkdownRenderer.tsx # Markdown processing pipeline
│ │ ├── CopyButton.tsx # Code block copy button
│ │ ├── ThemeToggle.tsx # Dark/light mode toggle
│ │ ├── PostLink.tsx # Post card link with analytics
│ │ ├── PaginationLink.tsx # Pagination navigation
│ │ └── BackLink.tsx # Back to home link
│ ├── plugins/
│ │ └── remark-sidenotes.mjs # Custom sidenote syntax
│ ├── lib/
│ │ └── posthog-server.ts # Server-side analytics
│ └── sanity/
│ ├── lib/
│ │ ├── client.ts # Sanity client configuration
│ │ └── queries.ts # GROQ queries
│ ├── env.ts # Environment config
│ └── types.ts # TypeScript types
├── public/
│ └── doubleword-icon.png # Site icon
├── .env.local # Environment variables
├── next.config.ts # Next.js configuration
└── package.json
Sanity CMS (edit)
→ Webhook fires on publish
→ /api/revalidate called
→ revalidateTag() purges cache
→ Next request rebuilds page
Raw Markdown
→ remarkGfm (tables, strikethrough, etc.)
→ remarkMath (math syntax)
→ remarkUnwrapImages (clean image markup)
→ remarkSidenotes (custom sidenote syntax)
→ rehypeSlug (heading IDs)
→ rehypeAutolinkHeadings (clickable headings)
→ rehypeRaw (pass through HTML from sidenotes)
→ rehypeKatex (math rendering)
→ rehypeShiki (syntax highlighting, dual themes)
→ React components (custom img, pre)
The custom remark-sidenotes plugin:
- Collects sidenote definitions (
[>id]: content) - Replaces references (
[>id]) with HTML structure - Supports markdown inside sidenotes (links, bold, italic, code, math)
On large screens (xl+), sidenotes float in the right margin. On smaller screens, numbered sidenotes become toggleable popups, and unnumbered ones appear as callout boxes.
The blog uses CSS custom properties for theming:
data-theme="light"/data-theme="dark"on<html>- Theme preference saved to localStorage
- System preference detection as fallback
- Shiki dual-theme syntax highlighting
The blog uses a Tufte-inspired design with:
- Crimson Pro - Serif body text
- IBM Plex Sans - Display headings
- Source Sans 3 - UI elements
- JetBrains Mono - Code
Color palette uses warm tones with a deep red accent (#a00000 light, #ff6868 dark).
# Sanity
NEXT_PUBLIC_SANITY_PROJECT_ID=your-project-id
NEXT_PUBLIC_SANITY_DATASET=production
SANITY_REVALIDATE_SECRET=your-webhook-secret
# Optional: Sanity API token for draft mode
SANITY_API_READ_TOKEN=your-read-token
# Optional: PostHog analytics
NEXT_PUBLIC_POSTHOG_KEY=your-posthog-key
POSTHOG_API_KEY=your-posthog-api-key- Go to Sanity Manage → Your Project → API → Webhooks
- Create webhook:
- URL:
https://blog.doubleword.ai/api/revalidate - Secret: Same as
SANITY_REVALIDATE_SECRET - Trigger on: Create, Update, Delete
- Projection:
{_type}
- URL:
npm run dev # Start dev server (http://localhost:3000)
npm run build # Build for production
npm run start # Start production server
npm run lint # Run ESLintNew remark plugin:
- Create plugin in
src/plugins/remark-*.mjs - Add to
remarkPluginsarray inMarkdownRenderer.tsx
New rehype plugin:
- Install package or create in
src/plugins/ - Add to
rehypePluginsarray inMarkdownRenderer.tsx - Note: Order matters—
rehypeRawmust come before plugins that process HTML
Extending sidenote syntax:
The sidenote plugin in src/plugins/remark-sidenotes.mjs can be extended to support additional inline formatting by adding cases to the content processing loop.
The blog uses PostHog for analytics:
- Client-side: Page views, link clicks (via PostLink component)
- Server-side: Post view events captured in
[slug]/page.tsx
Events tracked:
post_viewed- When a post is rendered (server-side)- Navigation tracking via PostLink and PaginationLink components