diff --git a/scripts/build-landing.mjs b/scripts/build-landing.mjs
index 84c94a04a..b1503a43b 100644
--- a/scripts/build-landing.mjs
+++ b/scripts/build-landing.mjs
@@ -45,6 +45,20 @@ once(
'Terms of service \n Support ',
)
once("white paper link", 'href="/scroll-whitepaper.pdf"', 'href="/files/whitepaper.pdf"')
+// the blog is a page of its own again (Zhengqi, 2026-09-21): it went off the site with the
+// rest of the old pages in the redesign, and the link people were sent to it with has been
+// dead since. It sits in the nav after the product sections and opens the footer's
+// Resources column, here and in LandingFooter.tsx (the other pages' footer) alike.
+once(
+ "blog nav link",
+ '
AI hardware ',
+ 'AI hardware \n Blog ',
+)
+once(
+ "blog footer link",
+ 'Documentation ',
+ 'Blog \n Documentation ',
+)
// ---- the Compass button on the Compass API panel: his file points it at his own Compass
// prototype (index.html, 2026-09-11); here it opens the Compass site ----
diff --git a/src/app/_blog/[blogId]/MoreBlogs.tsx b/src/app/_blog/[blogId]/MoreBlogs.tsx
deleted file mode 100644
index 972084839..000000000
--- a/src/app/_blog/[blogId]/MoreBlogs.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { Pagination } from "swiper/modules"
-import { Swiper, SwiperSlide } from "swiper/react"
-
-import { Box, Stack, Typography } from "@mui/material"
-
-import ArticleCard from "@/components/ArticleCard"
-
-const MoreBlogs = props => {
- const { blogs, title, ...restProps } = props
-
- return (
-
-
- {title}
-
-
-
- {props.blogs.map(blog => (
-
-
-
-
-
- ))}
-
-
- )
-}
-
-export default MoreBlogs
diff --git a/src/app/_blog/[blogId]/actions.ts b/src/app/_blog/[blogId]/actions.ts
deleted file mode 100644
index 65e05e043..000000000
--- a/src/app/_blog/[blogId]/actions.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-"use server"
-
-import { fetchBlogDetailURL } from "@/apis/blog"
-
-export const fetchBlogContent = async (blogId: string) => {
- const response = await fetch(fetchBlogDetailURL(blogId))
-
- if (response.ok) {
- const blogContent = await response.text()
- if (!blogContent) {
- throw new Error("Not found")
- }
- return blogContent
- } else {
- throw new Error("Failed to fetch blog content")
- }
-}
diff --git a/src/app/_blog/[blogId]/components/tableOfContents.tsx b/src/app/_blog/[blogId]/components/tableOfContents.tsx
deleted file mode 100644
index e14e02e36..000000000
--- a/src/app/_blog/[blogId]/components/tableOfContents.tsx
+++ /dev/null
@@ -1,124 +0,0 @@
-import { default as RouterLink } from "next/link"
-import { usePathname } from "next/navigation"
-import type { FC } from "react"
-import { useEffect, useRef, useState } from "react"
-
-import { SvgIcon, Typography } from "@mui/material"
-import { styled } from "@mui/system"
-
-import BackSvg from "@/assets/svgs/common/back.svg"
-
-const Link = styled(RouterLink)({
- display: "flex",
- alignItems: "center",
- "& *": {
- fontWeight: 500,
- },
-})
-
-export interface Heading {
- depth: string
- text: string
- slug: string
-}
-
-const TableOfContents: FC = () => {
- const pathname = usePathname()
-
- const [headings, setHeadings] = useState([])
- const tableOfContents = useRef(null)
- const [currentID, setCurrentID] = useState("")
- const scrolledRef = useRef(false)
-
- const hash = pathname!.split("#")[1]
- const hashRef = useRef(hash)
-
- useEffect(() => {
- if (hash) {
- // We want to reset if the hash has changed
- if (hashRef.current !== hash) {
- hashRef.current = hash
- scrolledRef.current = false
- }
-
- // only attempt to scroll if we haven't yet (this could have just reset above if hash changed)
- if (!scrolledRef.current) {
- const id = hash.replace("#", "")
- const element = document.getElementById(id)
- if (element) {
- element.scrollIntoView()
- scrolledRef.current = true
- }
- }
- }
- }, [tableOfContents.current])
-
- useEffect(() => {
- if (!tableOfContents.current) return
- const setCurrent: IntersectionObserverCallback = entries => {
- for (const entry of entries) {
- if (entry.isIntersecting) {
- setCurrentID(entry.target.id)
- break
- }
- }
- }
-
- const observerOptions: IntersectionObserverInit = {
- // Negative top margin accounts for `scroll-margin`.
- // Negative bottom margin means heading needs to be towards top of viewport to trigger intersection.
- rootMargin: "0px 0% -66%",
- }
-
- const headingsObserver = new IntersectionObserver(setCurrent, observerOptions)
- // Observe all the h2 in the main page content.
- document.querySelectorAll("h2").forEach((heading: HTMLHeadElement) => {
- const id = (heading.textContent as string)
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, "-")
- .replace(/[^a-z0-9]+/g, "-")
- heading.id = id
- headingsObserver.observe(heading)
- })
- // Stop observing when the component is unmounted.
- return () => headingsObserver.disconnect()
- }, [tableOfContents.current])
-
- useEffect(() => {
- if (!tableOfContents.current) return
- refreshHeadings()
- }, [tableOfContents.current])
-
- const refreshHeadings = () => {
- const headingList: Heading[] = []
- document.querySelectorAll("h2").forEach((heading: HTMLHeadElement) => {
- if (heading.className) return
- headingList.push({
- depth: heading.nodeName.charAt(1),
- text: heading.textContent as string,
- slug: heading.id,
- })
- })
- setHeadings(headingList)
- }
-
- return (
- <>
-
-
-
-
- All blogs
-
-
- {headings.map(header => (
-
- {header.text}
-
- ))}
-
- >
- )
-}
-
-export default TableOfContents
diff --git a/src/app/_blog/[blogId]/detail.tsx b/src/app/_blog/[blogId]/detail.tsx
deleted file mode 100644
index 235fcf11c..000000000
--- a/src/app/_blog/[blogId]/detail.tsx
+++ /dev/null
@@ -1,132 +0,0 @@
-"use client"
-
-import { shuffle } from "lodash"
-import { useRouter } from "next/navigation"
-import { useEffect, useMemo, useState } from "react"
-import ReactMarkdown from "react-markdown"
-import rehypeKatex from "rehype-katex"
-import rehypeRaw from "rehype-raw"
-import remarkGfm from "remark-gfm"
-import remarkMath from "remark-math"
-
-import { Box } from "@mui/material"
-import { styled } from "@mui/system"
-
-import blogSource from "@/assets/blog/main.data.json"
-import LoadingPage from "@/components/LoadingPage"
-import { LANGUAGE_MAP } from "@/constants"
-import useCheckViewport from "@/hooks/useCheckViewport"
-import useUserLanguage from "@/hooks/useUserLanguage"
-import { filterBlogsByLanguage } from "@/utils"
-
-import MoreBlogs from "./MoreBlogs"
-import { fetchBlogContent } from "./actions"
-import TOC from "./components/tableOfContents"
-
-const BlogContainer = styled(Box)(
- ({ theme }) => `
- max-width: 140rem;
- padding: 6rem 4rem 14rem;
- overflow: visible;
- display: flex;
- width: 100%;
- margin: auto;
- ${theme.breakpoints.down("md")} {
- padding: 4rem 2rem;
- display: block;
- overflow: hidden;
- };
- `,
-) as typeof Box
-
-const BlogNavbar = styled(Box)(({ theme }) => ({
- position: "sticky",
- top: "14rem",
- maxWidth: "32vw",
- [theme.breakpoints.down("md")]: {
- display: "none",
- },
-})) as typeof Box
-
-const BlogDetail = props => {
- const { blogId } = props
- const router = useRouter()
-
- const [language] = useUserLanguage()
- const [blogContent, setBlogContent] = useState(null)
- const [moreBlog, setMoreBlog] = useState([])
-
- const [loading, setLoading] = useState(true)
-
- const blogsWithLang = useMemo(() => filterBlogsByLanguage(blogSource, language), [blogSource, language])
-
- useEffect(() => {
- // TODO: no _lang_ in blogId
- async function fetchCurrentBlog() {
- const regex = /([^_]*?)_lang_[^_]+/g
- const blogIdMatch = blogId?.match(regex)
-
- const blogItemWithLang = blogSource.find(item => item.id === `${blogId}_lang_${language}`)
-
- if ((!blogIdMatch && language === "en") || (blogIdMatch && language !== "en") || (!blogIdMatch && language !== "en" && !blogItemWithLang)) {
- let anchors = [...document.querySelectorAll("a")]
- anchors.map(anchor => {
- if (anchor.href.includes("/Content/")) {
- anchor.setAttribute("target", "")
- }
- return anchor
- })
- try {
- setLoading(true)
- const text = await fetchBlogContent(blogId)
- setBlogContent(text)
- } catch (_error) {
- router.push("/404")
- } finally {
- setLoading(false)
- }
- } else if (blogIdMatch && language === "en") {
- const nextBlogId = blogId.replace(regex, "$1")
- router.push(`/blog/${nextBlogId}`)
- } else if (blogItemWithLang) {
- router.push(`/blog/${blogId}_lang_${language}`)
- }
- }
- fetchCurrentBlog()
- }, [blogId, language])
-
- useEffect(() => {
- const blogs = shuffle(blogsWithLang.filter(blog => blog.id !== blogId)).slice(0, 3)
- setMoreBlog(blogs)
- }, [blogId, blogsWithLang])
-
- const { isPortrait } = useCheckViewport()
-
- return (
-
- {loading ? (
-
- ) : (
-
-
-
-
-
-
-
-
-
-
- {!!isPortrait && }
-
- )}
-
- )
-}
-
-export default BlogDetail
diff --git a/src/app/_blog/[blogId]/page.tsx b/src/app/_blog/[blogId]/page.tsx
deleted file mode 100644
index b4f99a0f1..000000000
--- a/src/app/_blog/[blogId]/page.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import { notFound } from "next/navigation"
-
-import blogSource from "@/assets/blog/main.data.json"
-import { isSepolia } from "@/utils"
-import { genMeta } from "@/utils/route"
-
-import Detail from "./detail"
-
-export const generateMetadata = genMeta(async ({ params }) => {
- const { blogId } = await params
- const currentBlog = blogSource.find(blog => blog.id === blogId)
- const imgUrl = currentBlog?.ogImg || currentBlog?.posterImg || ""
-
- return {
- titleSuffix: currentBlog?.title,
- relativeURL: currentBlog?.canonical || `https://scroll.io/blog/${currentBlog?.id}`,
- description: currentBlog?.summary,
- ogImg: imgUrl,
- twitterImg: imgUrl,
- alternates: {
- canonical: currentBlog?.canonical,
- },
- }
-})
-
-const BlogDetail = async ({ params }) => {
- if (isSepolia) {
- notFound()
- }
- const { blogId } = await params
- return (
- <>
-
-
-
- >
- )
-}
-
-export default BlogDetail
diff --git a/src/app/_blog/layout.tsx b/src/app/_blog/layout.tsx
deleted file mode 100644
index 8ee03bbd4..000000000
--- a/src/app/_blog/layout.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import { notFound } from "next/navigation"
-
-import { isSepolia } from "@/utils"
-import { genMeta } from "@/utils/route"
-
-export const generateMetadata = genMeta(() => ({
- titleSuffix: "Blog",
- relativeURL: "/blog",
-}))
-
-export default function Layout({ children }) {
- if (isSepolia) {
- notFound()
- }
- return <>{children}>
-}
diff --git a/src/app/_blog/page.tsx b/src/app/_blog/page.tsx
deleted file mode 100644
index ab6613692..000000000
--- a/src/app/_blog/page.tsx
+++ /dev/null
@@ -1,288 +0,0 @@
-"use client"
-
-import { orderBy } from "lodash"
-import { useSearchParams } from "next/navigation"
-import { useEffect, useMemo, useState } from "react"
-
-import { Tune as TuneIcon } from "@mui/icons-material"
-import { Box, Modal, Typography } from "@mui/material"
-import { styled } from "@mui/system"
-
-import blogSource from "@/assets/blog/main.data.json"
-import ArticleCard from "@/components/ArticleCard"
-import SectionWrapper from "@/components/SectionWrapper"
-import { LANGUAGE_MAP, getBlogCategoryList, getBlogSortList } from "@/constants"
-import useCheckViewport from "@/hooks/useCheckViewport"
-import { filterBlogsByLanguage } from "@/utils"
-
-const BlogContainer = styled(Box)(({ theme }) => ({
- padding: "0 6rem 14rem",
- [theme.breakpoints.down("md")]: {
- padding: "0 2rem 9rem",
- },
-})) as typeof Box
-
-const BlogBox = styled(Box)(({ theme }) => ({
- marginBottom: "9rem",
- [theme.breakpoints.down("md")]: {
- marginBottom: "0",
- padding: "3rem 0",
- "&:not(:last-of-type)": {
- borderBottom: `1px solid ${theme.vars.palette.themeBackground.highlight}`,
- },
- "&:first-of-type": {
- padding: "0 0 3rem",
- },
- },
-})) as typeof Box
-
-const Header = styled(Box)(({ theme }) => ({
- padding: "15.5rem 0",
- [theme.breakpoints.down("md")]: {
- padding: "6.8rem 0 8rem",
- },
-})) as typeof Box
-
-const Title = styled(Typography)(({ theme }) => ({
- fontSize: "6.4rem",
- lineHeight: 1,
- fontWeight: 600,
- [theme.breakpoints.down("sm")]: {
- fontSize: "3.6rem",
- },
-}))
-
-const Summary = styled(Typography)(({ theme }) => ({
- fontSize: "2.6rem",
- maxWidth: "56rem",
- marginTop: "2.4rem",
- [theme.breakpoints.down("md")]: {
- marginTop: "2rem",
- },
- [theme.breakpoints.down("sm")]: {
- fontSize: "2rem",
- },
-}))
-
-const FilterContainer = styled(Box)(({ theme }) => ({
- [theme.breakpoints.down("lg")]: {
- display: "flex",
- justifyContent: "flex-end",
- },
-})) as typeof Box
-
-const MobileFilter = styled(Box)(({ theme }) => ({
- marginBottom: "1.7rem",
- fontSize: "1.6rem",
- fontWeight: 500,
- color: theme.vars.palette.text.primary,
- cursor: "pointer",
- borderRadius: "20px",
- border: `1px solid ${theme.vars.palette.text.primary}`,
- width: "fit-content",
- padding: "0.5rem 1.2rem",
- [theme.breakpoints.between("sm", "lg")]: {
- marginBottom: "3rem",
- },
-})) as typeof Box
-
-const FilterModal = styled(Box)({
- display: "flex",
- justifyContent: "center",
- alignItems: "center",
- height: "100vh",
-}) as typeof Box
-
-const FilterModalContent = styled(Box)(({ theme }) => ({
- background: theme.vars.palette.background.default,
- borderRadius: "2rem",
- width: "35.8rem",
- padding: "1.4rem 1.8rem",
-})) as typeof Box
-
-const BlogBody = styled(Box)(({ theme }) => ({
- display: "grid",
- gap: "3rem",
- gridTemplateColumns: "1fr 4fr",
- [theme.breakpoints.down("lg")]: {
- gridTemplateColumns: "1fr",
- gap: "0",
- },
-})) as typeof Box
-
-const FilterTypeName = styled(Typography)(({ theme }) => ({
- color: theme.vars.palette.text.primary,
- fontSize: "1.6rem",
- fontWeight: 600,
- marginBottom: "2rem",
- "&:nth-of-type(2)": {
- marginTop: "6.8rem",
- },
- [theme.breakpoints.down("lg")]: {
- height: "4rem",
- lineHeight: "4rem",
- marginBottom: 0,
- fontSize: "2rem",
- "&:nth-of-type(2)": {
- marginTop: "5rem",
- },
- },
-}))
-
-const FilterItem = styled(Typography)(({ theme }) => ({
- color: theme.vars.palette.text.primary,
- cursor: "pointer",
- fontSize: "1.6rem",
- marginBottom: "1.2rem",
- "&.active": {
- fontWeight: 700,
- },
- "&:hover": {
- fontWeight: 700,
- },
- [theme.breakpoints.down("lg")]: {
- fontWeight: 500,
- height: "3.6rem",
- lineHeight: "3.6rem",
- marginBottom: 0,
- fontSize: "1.8rem",
- },
-}))
-
-const BlogList = styled("ul")(({ theme }) => ({
- display: "flex",
- flexDirection: "column",
- justifyContent: "space-between",
- width: "100%",
- [theme.breakpoints.down("md")]: {
- borderRight: "none",
- marginBottom: "0",
- justifyContent: "center",
- },
-}))
-
-const Blog = () => {
- const searchParams = useSearchParams()
- const { isDesktop } = useCheckViewport()
- const language = "en"
- const BLOG_CATEGORY_LIST = useMemo(() => getBlogCategoryList(language), [language])
- const BLOG_SORT_LIST = useMemo(() => getBlogSortList(language), [language])
- const BLOG_COPY = useMemo(() => LANGUAGE_MAP[language], [language])
- const [filterOpen, setFilterOpen] = useState(false)
- const handleFilterOpen = () => setFilterOpen(true)
- const handleFilterClose = () => setFilterOpen(false)
-
- const [blogs, setBlogs] = useState(blogSource)
- const [queryForm, setQueryForm] = useState({
- sort: "Newest",
- category: searchParams?.get("category") ?? "All",
- })
-
- const blogsWithLang = useMemo(() => filterBlogsByLanguage(blogSource, language), [blogSource, language])
-
- useEffect(() => {
- const blogs = orderBy(
- blogsWithLang.filter(blog => blog.type === queryForm.category || queryForm.category === "All"),
- "date",
- queryForm.sort === "Newest" ? "desc" : "asc",
- )
- setBlogs(blogs)
- }, [queryForm, blogsWithLang])
-
- const hanleFilter = (attr: string, value: string) => {
- handleFilterClose()
- setQueryForm({
- ...queryForm,
- [attr]: value,
- })
- }
-
- const renderBlogs = () => {
- return (
-
- {blogs.map(blog => (
-
-
-
- ))}
-
- )
- }
-
- const renderFilter = () => {
- if (isDesktop) {
- return (
-
- {BLOG_COPY.category}
- {BLOG_CATEGORY_LIST.map(({ label, key }) => (
- hanleFilter("category", key)} key={key} className={key === queryForm.category ? "active" : ""}>
- {label}
-
- ))}
-
- {BLOG_COPY.sort}
- {BLOG_SORT_LIST.map(({ label, key }) => (
- hanleFilter("sort", key)} key={key} className={key === queryForm.sort ? "active" : ""}>
- {label}
-
- ))}
-
- )
- }
- return (
-
-
-
- {BLOG_COPY.filters}
-
-
-
-
-
- Filters
-
-
-
-
-
- Category
- {BLOG_CATEGORY_LIST.map(({ label, key }) => (
- hanleFilter("category", key)} key={key} className={key === queryForm.category ? "active" : ""}>
- {label}
-
- ))}
- Order by
- {BLOG_SORT_LIST.map(({ label, key }) => (
- hanleFilter("sort", key)} key={key} className={key === queryForm.sort ? "active" : ""}>
- {label}
-
- ))}
-
-
-
-
- )
- }
-
- return (
-
-
-
- {BLOG_COPY.title}
- {BLOG_COPY.sub_title}
-
-
- {renderFilter()}
- {renderBlogs()}
-
-
-
- )
-}
-
-export default Blog
diff --git a/src/app/_components/LandingFooter.tsx b/src/app/_components/LandingFooter.tsx
index b33544d15..2b9e804ee 100644
--- a/src/app/_components/LandingFooter.tsx
+++ b/src/app/_components/LandingFooter.tsx
@@ -1,6 +1,7 @@
import Link from "next/link"
import ScrollMarkSvg from "@/assets/svgs/landingpage/scroll-mark.svg"
+import { DOC_URL } from "@/constants/link"
import AnchorLink from "./AnchorLink"
import FooterRidge from "./FooterRidge"
@@ -28,6 +29,17 @@ const FOOTER_COLUMNS = [
{ label: "AI hardware", href: "/#hardware" },
],
},
+ {
+ title: "Resources",
+ links: [
+ // the blog, back on the site and opening the column (Zhengqi, 2026-09-21); the three
+ // under it are the ones his file carries, so the two footers finally say the same thing
+ { label: "Blog", href: "/blog" },
+ { label: "Documentation", href: DOC_URL, external: true },
+ { label: "White paper", href: "/files/whitepaper.pdf", external: true },
+ { label: "Scroll swap", href: "https://swap.scroll.io/swap?input=0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", external: true },
+ ],
+ },
]
interface LandingFooterProps {
@@ -55,8 +67,12 @@ const LandingFooter = ({ ridge = false }: LandingFooterProps) => (
{FOOTER_COLUMNS.map(({ title, links }) => (
{title}
- {links.map(({ label, href }) =>
- href.includes("#") ? (
+ {links.map(({ label, href, external }: { label: string; href: string; external?: boolean }) =>
+ external ? (
+
+ {label}
+
+ ) : href.includes("#") ? (
{label}
diff --git a/src/app/_components/LandingNav.tsx b/src/app/_components/LandingNav.tsx
index 96159daea..23300c421 100644
--- a/src/app/_components/LandingNav.tsx
+++ b/src/app/_components/LandingNav.tsx
@@ -14,6 +14,9 @@ const NAV_LINKS = [
{ label: "Compass", href: "/#compass" },
{ label: "ZK API keys", href: "/#api" },
{ label: "AI hardware", href: "/#hardware" },
+ // the blog went off the site with the rest of the old pages in the redesign; it is a
+ // page of its own, not a section of the front page (Zhengqi 2026-09-21)
+ { label: "Blog", href: "/blog" },
]
/** true once the page has scrolled past `threshold`; read on a frame, not on every event */
@@ -55,10 +58,11 @@ const LandingNav = ({ collapsible = false }: LandingNavProps) => {
const [open, setOpen] = useState(false)
const scrolled = useScrolled(24, collapsible)
- // on the landing page itself, scroll smoothly instead of re-navigating (Home would jump otherwise)
+ // on the landing page itself, scroll smoothly instead of re-navigating (Home would jump
+ // otherwise). Links to another page — the blog — are left alone.
const handleNavClick = (e: MouseEvent, href: string) => {
setOpen(false)
- if (pathname !== "/") return
+ if (pathname !== "/" || (href !== "/" && !href.startsWith("/#"))) return
e.preventDefault()
const id = href.split("#")[1]
if (id) {
diff --git a/src/app/_components/landing.module.css b/src/app/_components/landing.module.css
index dbc279622..d7263a9da 100644
--- a/src/app/_components/landing.module.css
+++ b/src/app/_components/landing.module.css
@@ -510,8 +510,8 @@
position: relative;
z-index: 2;
display: grid;
- grid-template-columns: 1fr auto auto;
- gap: clamp(32px, 6vw, 96px);
+ grid-template-columns: 1fr repeat(3, auto);
+ gap: clamp(28px, 4vw, 72px);
align-items: start;
}
diff --git a/src/app/blog/PostCard.tsx b/src/app/blog/PostCard.tsx
new file mode 100644
index 000000000..bb7abcf0b
--- /dev/null
+++ b/src/app/blog/PostCard.tsx
@@ -0,0 +1,26 @@
+import Link from "next/link"
+
+import styles from "./blog.module.css"
+import { Post, formatDate, isInternalTag } from "./posts"
+
+/**
+ * One post in the list: the date and category in mono above a serif title, the poster to
+ * the right (above, on phones). A hairline grows under the title on hover, the way the
+ * links in the nav do.
+ */
+const PostCard = ({ post, className }: { post: Post; className?: string }) => (
+
+
+
+ {formatDate(post.date)}
+ {!isInternalTag(post.type) && post.type ? ` · ${post.type}` : ""}
+
+
{post.title}
+ {post.summary &&
{post.summary}
}
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+)
+
+export default PostCard
diff --git a/src/app/blog/PostList.tsx b/src/app/blog/PostList.tsx
new file mode 100644
index 000000000..9b4569a40
--- /dev/null
+++ b/src/app/blog/PostList.tsx
@@ -0,0 +1,51 @@
+"use client"
+
+import { useState } from "react"
+
+import PostCard from "./PostCard"
+import styles from "./blog.module.css"
+import { Post } from "./posts"
+
+interface PostListProps {
+ posts: Post[]
+ categories: string[]
+ /** /blog?category=… — the ecosystem page has always linked straight to a category */
+ initialCategory: string
+}
+
+const ALL = "All"
+
+const PostList = ({ posts, categories, initialCategory }: PostListProps) => {
+ const [category, setCategory] = useState(categories.includes(initialCategory) ? initialCategory : ALL)
+ const shown = category === ALL ? posts : posts.filter(post => post.type === category)
+
+ return (
+ <>
+
+ {[ALL, ...categories].map(item => (
+ setCategory(item)}
+ className={`${styles.filter} ${item === category ? styles.filterActive : ""}`}
+ >
+ {item}
+
+ ))}
+
+ {shown.length ? (
+
+ {shown.map(post => (
+
+
+
+ ))}
+
+ ) : (
+ No posts in this category yet.
+ )}
+ >
+ )
+}
+
+export default PostList
diff --git a/src/app/blog/[blogId]/components/BackLink.tsx b/src/app/blog/[blogId]/components/BackLink.tsx
new file mode 100644
index 000000000..cdd0fb0ab
--- /dev/null
+++ b/src/app/blog/[blogId]/components/BackLink.tsx
@@ -0,0 +1,14 @@
+import Link from "next/link"
+
+import styles from "../../blog.module.css"
+
+const BackLink = ({ className }: { className?: string }) => (
+
+
+
+
+ All posts
+
+)
+
+export default BackLink
diff --git a/src/app/blog/[blogId]/components/tableOfContents.tsx b/src/app/blog/[blogId]/components/tableOfContents.tsx
new file mode 100644
index 000000000..4740938bf
--- /dev/null
+++ b/src/app/blog/[blogId]/components/tableOfContents.tsx
@@ -0,0 +1,80 @@
+"use client"
+
+import type { FC } from "react"
+import { useEffect, useState } from "react"
+
+import styles from "../../blog.module.css"
+
+interface Heading {
+ text: string
+ slug: string
+}
+
+const slugify = (text: string) =>
+ text
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-|-$/g, "")
+
+/**
+ * The post is markdown rendered on the server, so its headings carry no ids of their own:
+ * this walks the article's h2s once it is mounted, gives each one an id and lists them.
+ * The entry for the heading nearest the top of the viewport is the lit one.
+ */
+const TableOfContents: FC = () => {
+ const [headings, setHeadings] = useState([])
+ const [currentID, setCurrentID] = useState("")
+
+ useEffect(() => {
+ const found = [...document.querySelectorAll("article h2")]
+ const seen = new Map()
+ const list = found.map(heading => {
+ const text = heading.textContent ?? ""
+ const base = slugify(text) || "section"
+ const count = (seen.get(base) ?? 0) + 1
+ seen.set(base, count)
+ const slug = count > 1 ? `${base}-${count}` : base
+ heading.id = slug
+ return { text, slug }
+ })
+ setHeadings(list)
+
+ if (!list.length) return
+
+ const observer = new IntersectionObserver(
+ entries => {
+ for (const entry of entries) {
+ if (entry.isIntersecting) {
+ setCurrentID(entry.target.id)
+ break
+ }
+ }
+ },
+ // a heading counts as "current" once it is in the top third of the viewport
+ { rootMargin: "-100px 0px -66% 0px" },
+ )
+ found.forEach(heading => observer.observe(heading))
+
+ const hash = decodeURIComponent(window.location.hash.replace("#", ""))
+ if (hash) document.getElementById(hash)?.scrollIntoView()
+
+ return () => observer.disconnect()
+ }, [])
+
+ if (!headings.length) return null
+
+ return (
+ <>
+ On this page
+
+ >
+ )
+}
+
+export default TableOfContents
diff --git a/src/app/blog/[blogId]/page.tsx b/src/app/blog/[blogId]/page.tsx
new file mode 100644
index 000000000..37320a4da
--- /dev/null
+++ b/src/app/blog/[blogId]/page.tsx
@@ -0,0 +1,112 @@
+import { notFound } from "next/navigation"
+import ReactMarkdown from "react-markdown"
+import rehypeKatex from "rehype-katex"
+import rehypeRaw from "rehype-raw"
+import remarkGfm from "remark-gfm"
+import remarkMath from "remark-math"
+
+import { fetchBlogDetailURL } from "@/apis/blog"
+import { isSepolia } from "@/utils/common"
+import { genMeta } from "@/utils/route"
+
+import PostCard from "../PostCard"
+import styles from "../blog.module.css"
+import { findPost, formatDate, isInternalTag, publishedPosts } from "../posts"
+import BackLink from "./components/BackLink"
+import TOC from "./components/tableOfContents"
+
+export const generateMetadata = genMeta(async ({ params }) => {
+ const { blogId } = await params
+ const currentBlog = findPost(blogId?.toLowerCase())
+ const imgUrl = currentBlog?.ogImg || currentBlog?.posterImg || ""
+
+ return {
+ titleSuffix: currentBlog?.title,
+ relativeURL: currentBlog?.canonical || `https://scroll.io/blog/${currentBlog?.id}`,
+ description: currentBlog?.summary,
+ ogImg: imgUrl,
+ twitterImg: imgUrl,
+ alternates: {
+ canonical: currentBlog?.canonical,
+ },
+ }
+})
+
+/**
+ * The post itself is markdown served by the blog host (its first line is the title —
+ * `?title=1`). It used to be fetched in the browser behind a spinner; fetching it here puts
+ * the article in the HTML, so it is there for search engines and on the first paint. The
+ * hour's revalidation is what keeps an edit on the blog reaching the page.
+ */
+const fetchBlogContent = async (blogId: string) => {
+ const response = await fetch(fetchBlogDetailURL(blogId), { next: { revalidate: 3600 } })
+ if (!response.ok) return null
+ const content = await response.text()
+ return content || null
+}
+
+const BlogDetail = async ({ params }) => {
+ if (isSepolia) {
+ notFound()
+ }
+ const { blogId } = await params
+ const id = blogId?.toLowerCase()
+ const content = await fetchBlogContent(id)
+
+ if (!content) {
+ notFound()
+ }
+
+ const post = findPost(id)
+ const morePosts = publishedPosts()
+ .filter(other => other.id !== id)
+ .slice(0, 3)
+
+ return (
+
+ {/* the maths in the technical posts */}
+
+
+
+
+
+ {!!post && (
+
+ {formatDate(post.date)}
+ {!isInternalTag(post.type) && post.type ? ` · ${post.type}` : ""}
+
+ )}
+
+ {content}
+
+
+
+
+
+ {!!morePosts.length && (
+
+ More from Scroll
+
+ {morePosts.map(other => (
+
+
+
+ ))}
+
+
+ )}
+
+ )
+}
+
+export default BlogDetail
diff --git a/src/app/blog/blog.module.css b/src/app/blog/blog.module.css
new file mode 100644
index 000000000..d9f133e31
--- /dev/null
+++ b/src/app/blog/blog.module.css
@@ -0,0 +1,513 @@
+/* The blog in the new brand — Glen's tokens (scroll.html, 2026-09-10): the ink ramp, the
+ hairlines and the violet, Instrument Serif for the titles, Inter for the running text and
+ JetBrains Mono for the small labels. Everything is in px: globals.css puts the root font
+ size at 62.5%, so a rem here would not mean what it says. */
+.page,
+.pageWide {
+ --ink: #0a0a0a;
+ --ink-2: #4a4845;
+ --ink-3: #8b8781;
+ --ink-4: #b6b2ac;
+ --line: #e5e1dc;
+ --line-2: #efebe6;
+ --violet: #6d45e8;
+ --serif: var(--font-instrument-serif);
+ --mono: var(--font-jetbrains-mono);
+
+ margin-inline: auto;
+ color: var(--ink);
+}
+
+.page {
+ width: min(100% - clamp(32px, 8vw, 96px), 1100px);
+}
+
+/* a post needs room for the article at its full 740 with a rail either side of it */
+.pageWide {
+ width: min(100% - clamp(32px, 8vw, 96px), 1280px);
+}
+
+/* ---- index header ---- */
+.header {
+ padding: clamp(40px, 7vw, 80px) 0 clamp(32px, 5vw, 56px);
+ border-bottom: 1px solid var(--line-2);
+}
+
+.title {
+ font-family: var(--serif);
+ font-weight: 400;
+ font-size: clamp(44px, 8vw, 76px);
+ line-height: 1;
+ letter-spacing: -0.02em;
+ margin: 0;
+}
+
+.lede {
+ margin: 20px 0 0;
+ max-width: 560px;
+ font-size: 17px;
+ line-height: 1.6;
+ color: var(--ink-2);
+}
+
+/* ---- category filter ---- */
+.filters {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ padding: 28px 0 0;
+}
+
+.filter {
+ font-family: var(--mono);
+ font-size: 12px;
+ letter-spacing: 0.02em;
+ color: var(--ink-2);
+ padding: 7px 14px;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ background: none;
+ cursor: pointer;
+ transition:
+ color 0.25s,
+ background-color 0.25s,
+ border-color 0.25s;
+}
+
+.filter:hover {
+ color: var(--ink);
+ border-color: var(--ink-3);
+}
+
+.filterActive,
+.filterActive:hover {
+ color: #fff;
+ background: var(--ink);
+ border-color: var(--ink);
+}
+
+/* ---- post list ---- */
+.list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.row {
+ border-bottom: 1px solid var(--line-2);
+}
+
+.card {
+ display: grid;
+ grid-template-columns: 1fr minmax(0, 420px);
+ gap: clamp(24px, 4vw, 56px);
+ align-items: center;
+ padding: clamp(28px, 4vw, 44px) 0;
+}
+
+.cardInfo {
+ min-width: 0;
+}
+
+.meta {
+ font-family: var(--mono);
+ font-size: 12px;
+ letter-spacing: 0.02em;
+ color: var(--ink-3);
+ margin: 0 0 14px;
+}
+
+.cardTitle {
+ font-family: var(--serif);
+ font-weight: 400;
+ font-size: clamp(24px, 3vw, 32px);
+ line-height: 1.15;
+ letter-spacing: -0.01em;
+ margin: 0;
+ background-image: linear-gradient(currentColor, currentColor);
+ background-repeat: no-repeat;
+ background-position: 0 100%;
+ background-size: 0 1px;
+ transition: background-size 0.4s cubic-bezier(0.22, 0.61, 0.36, 1);
+}
+
+.card:hover .cardTitle {
+ background-size: 100% 1px;
+}
+
+.excerpt {
+ margin: 14px 0 0;
+ font-size: 15px;
+ line-height: 1.6;
+ color: var(--ink-2);
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+/* every poster the blog has is 3:1 (1332x447 out of Ghost), so the box is that shape and
+ anything else is cropped to it rather than stretching the row */
+.thumb {
+ display: block;
+ width: 100%;
+ aspect-ratio: 3 / 1;
+ object-fit: cover;
+ border-radius: 20px;
+ background: #faf9f8;
+ border: 1px solid var(--line-2);
+}
+
+.empty {
+ padding: 64px 0;
+ color: var(--ink-3);
+ font-size: 15px;
+}
+
+@media (max-width: 900px) {
+ .card {
+ grid-template-columns: 1fr;
+ gap: 20px;
+ }
+
+ .thumb {
+ grid-row: 1;
+ border-radius: 16px;
+ }
+
+ .excerpt {
+ display: none;
+ }
+}
+
+/* ---- article ---- */
+.articlePage {
+ display: flex;
+ justify-content: center;
+ gap: clamp(24px, 3vw, 48px);
+ padding-top: clamp(24px, 4vw, 48px);
+}
+
+.rail {
+ flex: 0 0 210px;
+ width: 210px;
+}
+
+@media (max-width: 1100px) {
+ .rail {
+ display: none;
+ }
+}
+
+.toc {
+ position: sticky;
+ top: 108px;
+}
+
+.back {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ font-family: var(--mono);
+ font-size: 12px;
+ letter-spacing: 0.02em;
+ color: var(--ink-2);
+ transition: color 0.25s;
+}
+
+.back:hover {
+ color: var(--ink);
+}
+
+.tocLabel {
+ font-family: var(--mono);
+ font-size: 11px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--ink-4);
+ margin: 32px 0 12px;
+}
+
+.tocList {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ border-left: 1px solid var(--line);
+}
+
+.tocList a {
+ display: block;
+ padding: 7px 0 7px 16px;
+ margin-left: -1px;
+ border-left: 1px solid transparent;
+ font-size: 13px;
+ line-height: 1.45;
+ color: var(--ink-3);
+ transition:
+ color 0.25s,
+ border-color 0.25s;
+}
+
+.tocList a:hover {
+ color: var(--ink);
+}
+
+.tocActive a {
+ color: var(--ink);
+ border-left-color: var(--violet);
+}
+
+.article {
+ flex: 1 1 740px;
+ max-width: 740px;
+ min-width: 0;
+ font-size: 17px;
+ line-height: 1.72;
+ color: #2a2a2a;
+}
+
+/* the date and category, above the title the markdown itself carries */
+.articleMeta {
+ font-family: var(--mono);
+ font-size: 12px;
+ letter-spacing: 0.02em;
+ color: var(--ink-3);
+ margin: 0;
+}
+
+/* the article's own back link, for the widths where the rail is gone. It sits inside the
+ prose, so it has to opt out of the link styling the prose gives everything else. */
+.backMobile {
+ margin-bottom: 28px;
+}
+
+.article .back {
+ color: var(--ink-2);
+ text-decoration: none;
+}
+
+.article .back:hover {
+ color: var(--ink);
+}
+
+@media (min-width: 1101px) {
+ .backMobile {
+ display: none;
+ }
+}
+
+.article > * + * {
+ margin-top: 22px;
+}
+
+.article h1,
+.article h2,
+.article h3,
+.article h4 {
+ font-family: var(--serif);
+ font-weight: 400;
+ color: var(--ink);
+ letter-spacing: -0.01em;
+ line-height: 1.15;
+}
+
+.article h1 {
+ font-size: clamp(34px, 5.5vw, 54px);
+ line-height: 1.05;
+ margin-bottom: 8px;
+}
+
+.articleMeta + h1 {
+ margin-top: 12px;
+}
+
+.article h2 {
+ font-size: clamp(26px, 3.4vw, 34px);
+ scroll-margin-top: 110px;
+}
+
+.article h3 {
+ font-size: 24px;
+}
+
+.article h4 {
+ font-size: 20px;
+}
+
+.article h2,
+.article h3,
+.article h4 {
+ margin-top: 48px;
+}
+
+.article a {
+ color: var(--violet);
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ text-decoration-thickness: 1px;
+}
+
+.article strong {
+ font-weight: 600;
+ color: var(--ink);
+}
+
+/* a bolded link keeps the link colour */
+.article a strong {
+ color: inherit;
+}
+
+.article ul,
+.article ol {
+ padding-left: 24px;
+}
+
+.article ul {
+ list-style: disc;
+}
+
+.article ol {
+ list-style: decimal;
+}
+
+.article li + li {
+ margin-top: 8px;
+}
+
+.article img,
+.article video {
+ display: block;
+ width: 100%;
+ height: auto;
+ border-radius: 16px;
+ margin-block: 36px;
+}
+
+.article figure {
+ margin: 36px 0;
+}
+
+.article figcaption {
+ margin-top: 12px;
+ font-size: 13px;
+ line-height: 1.5;
+ color: var(--ink-3);
+ text-align: center;
+}
+
+.article blockquote {
+ margin-inline: 0;
+ padding-left: 22px;
+ border-left: 2px solid var(--line);
+ color: var(--ink-2);
+}
+
+.article code {
+ font-family: var(--mono);
+ font-size: 0.88em;
+ background: #f4f2f0;
+ padding: 2px 6px;
+ border-radius: 6px;
+}
+
+.article pre {
+ background: #faf9f8;
+ border: 1px solid var(--line-2);
+ border-radius: 12px;
+ padding: 18px;
+ overflow-x: auto;
+ font-size: 13.5px;
+ line-height: 1.6;
+}
+
+.article pre code {
+ background: none;
+ padding: 0;
+ font-size: inherit;
+}
+
+.article table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 15px;
+ display: block;
+ overflow-x: auto;
+}
+
+.article th,
+.article td {
+ border: 1px solid var(--line);
+ padding: 10px 14px;
+ text-align: left;
+}
+
+.article th {
+ background: #faf9f8;
+ font-weight: 600;
+}
+
+.article hr {
+ border: 0;
+ border-top: 1px solid var(--line);
+ margin-block: 44px;
+}
+
+.article iframe {
+ display: block;
+ width: 100%;
+ border: 0;
+ border-radius: 16px;
+}
+
+/* ---- more posts: the same card stacked — the poster on top, the title under it. The
+ markup keeps the text first (the list's reading order), so the column runs in reverse. ---- */
+.more {
+ border-top: 1px solid var(--line-2);
+ margin-top: clamp(64px, 9vw, 104px);
+ padding-top: clamp(40px, 5vw, 64px);
+}
+
+.moreTitle {
+ font-family: var(--serif);
+ font-weight: 400;
+ font-size: clamp(26px, 3.4vw, 34px);
+ line-height: 1.15;
+ margin: 0 0 36px;
+}
+
+.moreGrid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: clamp(20px, 3vw, 36px);
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+@media (max-width: 900px) {
+ .moreGrid {
+ grid-template-columns: 1fr;
+ gap: 32px;
+ }
+}
+
+.moreCard {
+ display: flex;
+ flex-direction: column-reverse;
+ justify-content: flex-end;
+ gap: 0;
+ padding: 0;
+}
+
+.moreCard .thumb {
+ margin-bottom: 18px;
+ border-radius: 16px;
+}
+
+.moreCard .cardTitle {
+ font-size: 22px;
+}
+
+.moreCard .excerpt {
+ display: none;
+}
diff --git a/src/app/blog/layout.tsx b/src/app/blog/layout.tsx
new file mode 100644
index 000000000..fa604155d
--- /dev/null
+++ b/src/app/blog/layout.tsx
@@ -0,0 +1,38 @@
+import { notFound } from "next/navigation"
+
+import { isSepolia } from "@/utils/common"
+import { genMeta } from "@/utils/route"
+
+import LandingFooter from "../_components/LandingFooter"
+import LandingNav from "../_components/LandingNav"
+import { instrumentSerif, inter, jetbrainsMono } from "../_components/fonts"
+
+export const generateMetadata = genMeta(() => ({
+ titleSuffix: "Blog",
+ description: "Announcements, technical write-ups and ecosystem news from the Scroll team.",
+ relativeURL: "/blog",
+}))
+
+/**
+ * The blog wears the new brand: the same nav and footer as the front page and the legal
+ * pages, and the front page's three faces — Instrument Serif for the display lines, Inter
+ * for the running text, JetBrains Mono for the labels. The legal shell sets Geist instead;
+ * the blog sits next to the landing page, so it follows Glen's file (scroll.html,
+ * 2026-09-10) rather than the legal type.
+ */
+export default function Layout({ children }) {
+ if (isSepolia) {
+ notFound()
+ }
+ return (
+
+ )
+}
diff --git a/src/app/blog/page.tsx b/src/app/blog/page.tsx
new file mode 100644
index 000000000..5e1b403b1
--- /dev/null
+++ b/src/app/blog/page.tsx
@@ -0,0 +1,24 @@
+import PostList from "./PostList"
+import styles from "./blog.module.css"
+import { publishedPosts, usedCategories } from "./posts"
+
+// the order the blog has always listed its categories in (src/constants/blog.ts)
+const CATEGORY_ORDER = ["Announcement", "General", "Technical", "Ecosystem highlights"]
+
+const Blog = async ({ searchParams }) => {
+ const { category } = await searchParams
+ const posts = publishedPosts()
+ const initialCategory = Array.isArray(category) ? category[0] : category
+
+ return (
+
+ )
+}
+
+export default Blog
diff --git a/src/app/blog/posts.ts b/src/app/blog/posts.ts
new file mode 100644
index 000000000..d45ef4249
--- /dev/null
+++ b/src/app/blog/posts.ts
@@ -0,0 +1,37 @@
+import blogSource from "@/assets/blog/main.data.json"
+
+export type Post = {
+ id: string
+ title: string
+ summary: string
+ date: string
+ type: string
+ posterImg: string
+ ogImg?: string | null
+ canonical?: string | null
+ language?: string
+}
+
+const source = blogSource as unknown as Post[]
+
+/**
+ * A handful of posts carry an internal Ghost tag as their primary tag (#natalia,
+ * #shambhavi — the editor's own), which is not a category anyone should see. They stay in
+ * the list; only the label goes.
+ */
+export const isInternalTag = (type?: string) => !!type?.startsWith("#")
+
+/** every English post, newest first */
+export const publishedPosts = (): Post[] => source.filter(post => post.language === "en").sort((a, b) => (a.date < b.date ? 1 : -1))
+
+export const findPost = (id: string): Post | undefined => source.find(post => post.id === id)
+
+/** the categories that actually have posts, in the order the blog has always listed them */
+export const usedCategories = (posts: Post[], order: string[]): string[] => {
+ const present = new Set(posts.map(post => post.type).filter(type => !isInternalTag(type)))
+ return order.filter(category => present.has(category))
+}
+
+const dateFormat = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })
+
+export const formatDate = (date: string) => (date ? dateFormat.format(new Date(`${date}T00:00:00Z`)) : "")
diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts
index ba9d3aee1..3f7a3f0bd 100644
--- a/src/app/sitemap.ts
+++ b/src/app/sitemap.ts
@@ -1,8 +1,17 @@
import { MetadataRoute } from "next"
+import { publishedPosts } from "./blog/posts"
+
export default function sitemap(): MetadataRoute.Sitemap {
return [
{ url: "https://scroll.io", changeFrequency: "daily", priority: 0.8 },
+ { url: "https://scroll.io/blog", changeFrequency: "weekly", priority: 0.7 },
+ ...publishedPosts().map(post => ({
+ url: `https://scroll.io/blog/${post.id}`,
+ lastModified: post.date,
+ changeFrequency: "monthly" as const,
+ priority: 0.5,
+ })),
{ url: "https://scroll.io/privacy-policy", changeFrequency: "yearly", priority: 0.4 },
{ url: "https://scroll.io/app-privacy-policy", changeFrequency: "yearly", priority: 0.4 },
{ url: "https://scroll.io/support", changeFrequency: "monthly", priority: 0.5 },
diff --git a/src/components/ScrollToTop/index.tsx b/src/components/ScrollToTop/index.tsx
index 85c3df98f..969867afd 100644
--- a/src/components/ScrollToTop/index.tsx
+++ b/src/components/ScrollToTop/index.tsx
@@ -18,7 +18,8 @@ const ScrollToTop: React.FC = () => {
const [visible, setVisible] = useState(false)
// the redesigned landing pages use a minimal circle-arrow button instead of the orange fab
- const isCompassRoute = ["/", "/privacy-policy", "/terms-of-service", "/app-privacy-policy", "/support"].includes(pathname)
+ const isCompassRoute =
+ ["/", "/privacy-policy", "/terms-of-service", "/app-privacy-policy", "/support"].includes(pathname) || pathname.startsWith("/blog")
const checkScrollPosition = () => {
// Glen's scroll.html (2026-09-10) shows its back-to-top once 80% of a screen has gone by