typeof lang === "string")
+ : [];
+
+ if (!formattedDate && availableLanguages.length <= 1) {
+ return null;
+ }
+
+ return (
+
+ {formattedDate ? (
+
{formattedDate}
+ ) : null}
+
+ {pathname && availableLanguages.length > 1 ? (
+
+ Language:
+ {availableLanguages.map((lang) => {
+ const isActive = lang === currentLang;
+ return (
+
+ {lang.toUpperCase()}
+
+ );
+ })}
+
+ ) : null}
+
+ );
+}
+
+export function PostTags({
+ tags,
+ className,
+}: {
+ tags?: unknown;
+ className?: string;
+}) {
+ if (!Array.isArray(tags)) {
+ return null;
+ }
+
+ const validTags = tags.filter(
+ (tag): tag is string => typeof tag === "string" && tag.length > 0,
+ );
+
+ if (validTags.length === 0) {
+ return null;
+ }
+
+ return (
+
+ Tags:
+ {validTags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ );
+}
+
+export function PostList({ description, posts, title }: PostListProps) {
+ return (
+
+
+
+ {title}
+
+ {description ? (
+ {description}
+ ) : null}
+
+
+
+ {posts.map((post) => {
+ return (
+
+
+
+ {post.metadata.description ? (
+
+ {post.metadata.description}
+
+ ) : null}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/frontend/src/content/en/_meta.ts b/frontend/src/content/en/_meta.ts
index c86a7ba7b..49b33a784 100644
--- a/frontend/src/content/en/_meta.ts
+++ b/frontend/src/content/en/_meta.ts
@@ -22,6 +22,9 @@ const meta: MetaRecord = {
workspace: {
type: "page",
},
+ blog: {
+ type: "page",
+ },
};
export default meta;
diff --git a/frontend/src/content/en/posts/_meta.ts b/frontend/src/content/en/posts/_meta.ts
new file mode 100644
index 000000000..801137098
--- /dev/null
+++ b/frontend/src/content/en/posts/_meta.ts
@@ -0,0 +1,9 @@
+import type { MetaRecord } from "nextra";
+
+const meta: MetaRecord = {
+ weekly: {
+ title: "Weekly",
+ },
+};
+
+export default meta;
diff --git a/frontend/src/content/en/posts/weekly/2026-04-06.mdx b/frontend/src/content/en/posts/weekly/2026-04-06.mdx
new file mode 100644
index 000000000..e9ec561ab
--- /dev/null
+++ b/frontend/src/content/en/posts/weekly/2026-04-06.mdx
@@ -0,0 +1,11 @@
+---
+title: Weekly - 2026-04-06
+description: Weekly updates in English.
+date: 2026-04-06
+tags:
+ - DeerFlow Weekly
+---
+
+## Weekly - 2026-04-06
+
+In this week, enenenen
diff --git a/frontend/src/content/zh/_meta.ts b/frontend/src/content/zh/_meta.ts
index c72559ebc..8bd4c5575 100644
--- a/frontend/src/content/zh/_meta.ts
+++ b/frontend/src/content/zh/_meta.ts
@@ -7,6 +7,9 @@ const meta: MetaRecord = {
workspace: {
type: "page",
},
+ blog: {
+ type: "page",
+ },
};
export default meta;
diff --git a/frontend/src/content/zh/posts/_meta.ts b/frontend/src/content/zh/posts/_meta.ts
new file mode 100644
index 000000000..2b0835f8b
--- /dev/null
+++ b/frontend/src/content/zh/posts/_meta.ts
@@ -0,0 +1,9 @@
+import type { MetaRecord } from "nextra";
+
+const meta: MetaRecord = {
+ weekly: {
+ title: "周报",
+ },
+};
+
+export default meta;
diff --git a/frontend/src/content/zh/posts/releases/2_0_rc.mdx b/frontend/src/content/zh/posts/releases/2_0_rc.mdx
new file mode 100644
index 000000000..98144b3b5
--- /dev/null
+++ b/frontend/src/content/zh/posts/releases/2_0_rc.mdx
@@ -0,0 +1,9 @@
+---
+title: DeerFlow 2.0 RC
+description: DeerFlow 2.0 RC is officially in RC. Here's what you need to know.
+date: 2026-04-08
+tags:
+ - Release
+---
+
+## DeerFlow 2.0 RC 发布
diff --git a/frontend/src/content/zh/posts/weekly/2026-04-06.mdx b/frontend/src/content/zh/posts/weekly/2026-04-06.mdx
new file mode 100644
index 000000000..c04985189
--- /dev/null
+++ b/frontend/src/content/zh/posts/weekly/2026-04-06.mdx
@@ -0,0 +1,11 @@
+---
+title: 周报 - 2026-04-06
+description: DeerFlow 中文周报。
+date: 2026-04-06
+tags:
+ - DeerFlow Weekly
+---
+
+## Weekly - 2026-04-06
+
+In this week, xxx
diff --git a/frontend/src/core/blog/index.ts b/frontend/src/core/blog/index.ts
new file mode 100644
index 000000000..557b7a9ad
--- /dev/null
+++ b/frontend/src/core/blog/index.ts
@@ -0,0 +1,357 @@
+import type { Folder, MdxFile, PageMapItem } from "nextra";
+import { getPageMap } from "nextra/page-map";
+import { cache } from "react";
+
+import { getLangByLocale, type Locale } from "@/core/i18n/locale";
+
+export const BLOG_LANGS = ["zh", "en"] as const;
+const RECENT_POST_LIMIT = 5;
+
+export type BlogLang = (typeof BLOG_LANGS)[number];
+
+export type BlogMetadata = {
+ date?: string;
+ description?: string;
+ item: MdxFile;
+ tags: string[];
+ title: string;
+};
+
+type BlogMdxFile = MdxFile & {
+ frontMatter?: {
+ date?: string;
+ description?: string;
+ tags?: unknown;
+ title?: string;
+ };
+ title?: string;
+};
+
+export type BlogPost = {
+ lang: BlogLang;
+ languages: BlogLang[];
+ metadata: BlogMetadata;
+ slug: string[];
+ title: string;
+};
+
+type LocalizedBlogPost = {
+ lang: BlogLang;
+ metadata: BlogMetadata;
+ slug: string[];
+ title: string;
+};
+
+export type BlogIndexData = {
+ pageMap: PageMapItem[];
+ posts: BlogPost[];
+ recentPosts: BlogPost[];
+ tags: Array<{ name: string; count: number; posts: BlogPost[] }>;
+};
+
+function isFolder(item: PageMapItem): item is Folder {
+ return "children" in item && Array.isArray(item.children);
+}
+
+function isMdxFile(item: PageMapItem): item is BlogMdxFile {
+ return "name" in item && "route" in item && !isFolder(item);
+}
+
+function normalizeBlogRoute(route: string): string {
+ // Posts are sourced from locale-specific content trees but exposed
+ // under the single public /blog route.
+ return route.replace(/^\/(en|zh)\/(?:posts|blog)(?=\/|$)/, "/blog");
+}
+
+export function getBlogRoute(slug: string[]): string {
+ return slug.length === 0 ? "/blog" : `/blog/${slug.join("/")}`;
+}
+
+function getSlugFromRoute(route: string): string[] {
+ return route
+ .replace(/^\/blog\/?/, "")
+ .split("/")
+ .filter(Boolean);
+}
+
+function getSlugKey(slug: string[]): string {
+ return slug.join("/");
+}
+
+function parseTags(tags: unknown): string[] {
+ if (!Array.isArray(tags)) {
+ return [];
+ }
+
+ return tags.filter(
+ (tag): tag is string => typeof tag === "string" && tag.length > 0,
+ );
+}
+
+function parseDate(value: string | undefined): number {
+ if (!value) {
+ return 0;
+ }
+
+ const time = new Date(value).getTime();
+ return Number.isNaN(time) ? 0 : time;
+}
+
+function selectPreferredLanguage(
+ languages: BlogLang[],
+ preferredLang?: BlogLang,
+): BlogLang | null {
+ if (preferredLang && languages.includes(preferredLang)) {
+ return preferredLang;
+ }
+
+ // Keep fallback order stable so merged posts resolve predictably
+ // when the preferred locale is unavailable.
+ for (const lang of BLOG_LANGS) {
+ if (languages.includes(lang)) {
+ return lang;
+ }
+ }
+
+ return null;
+}
+
+function collectLocalizedBlogPosts(
+ items: PageMapItem[],
+ lang: BlogLang,
+): LocalizedBlogPost[] {
+ const posts: LocalizedBlogPost[] = [];
+
+ for (const item of items) {
+ if (isFolder(item)) {
+ posts.push(...collectLocalizedBlogPosts(item.children, lang));
+ continue;
+ }
+
+ if (!isMdxFile(item)) {
+ continue;
+ }
+
+ const route = normalizeBlogRoute(item.route);
+ const slug = getSlugFromRoute(route);
+
+ if (slug.length === 0) {
+ continue;
+ }
+
+ const title = item.frontMatter?.title ?? item.title ?? item.name;
+
+ posts.push({
+ lang,
+ metadata: {
+ date: item.frontMatter?.date,
+ description:
+ typeof item.frontMatter?.description === "string"
+ ? item.frontMatter.description
+ : undefined,
+ item: {
+ ...item,
+ route,
+ },
+ tags: parseTags(item.frontMatter?.tags),
+ title,
+ },
+ slug,
+ title,
+ });
+ }
+
+ return posts;
+}
+
+function mergePostsBySlug(
+ posts: LocalizedBlogPost[],
+ preferredLang?: BlogLang,
+): BlogPost[] {
+ const postsBySlug = new Map
();
+
+ for (const post of posts) {
+ const key = getSlugKey(post.slug);
+ const group = postsBySlug.get(key) ?? [];
+ group.push(post);
+ postsBySlug.set(key, group);
+ }
+
+ return [...postsBySlug.values()]
+ .flatMap((group): BlogPost[] => {
+ const languages = group.map((post) => post.lang);
+ const selectedLang = selectPreferredLanguage(languages, preferredLang);
+ const primary =
+ group.find((post) => post.lang === selectedLang) ?? group[0];
+
+ if (!primary) {
+ return [];
+ }
+
+ const mergedTags = new Set();
+ for (const post of group) {
+ for (const tag of post.metadata.tags) {
+ mergedTags.add(tag);
+ }
+ }
+
+ return [
+ {
+ ...primary,
+ languages,
+ metadata: {
+ ...primary.metadata,
+ tags: [...mergedTags],
+ },
+ },
+ ];
+ })
+ .sort((a, b) => parseDate(b.metadata.date) - parseDate(a.metadata.date));
+}
+
+function createFolder(
+ name: string,
+ route: string,
+ title: string,
+ children: PageMapItem[],
+): Folder {
+ return {
+ children,
+ name,
+ route,
+ title,
+ } as Folder;
+}
+
+function createPostItem(post: BlogPost): MdxFile {
+ return {
+ ...post.metadata.item,
+ name: post.title,
+ route: getBlogRoute(post.slug),
+ };
+}
+
+export function normalizeTagSlug(tag: string): string {
+ return tag.toLowerCase().replace(/\s+/g, "-");
+}
+
+export function formatTagName(tag: string): string {
+ return tag
+ .split("-")
+ .filter(Boolean)
+ .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
+ .join(" ");
+}
+
+export function getPreferredBlogLang(locale: Locale): BlogLang | undefined {
+ const lang = getLangByLocale(locale);
+ return BLOG_LANGS.find((value) => value === lang);
+}
+
+function matchTags(tags: string[], slug: string): boolean {
+ for (const tag of tags) {
+ if (normalizeTagSlug(tag) === slug) {
+ return true;
+ }
+ }
+ return false;
+}
+
+export const getAllPosts = cache(async function getAllPosts(
+ preferredLang?: BlogLang,
+): Promise {
+ const localizedPageMaps = await Promise.all(
+ BLOG_LANGS.map(async (lang) => ({
+ items: await getPageMap(`/${lang}/posts`),
+ lang,
+ })),
+ );
+
+ const localizedPosts = localizedPageMaps.flatMap(({ items, lang }) =>
+ collectLocalizedBlogPosts(items, lang),
+ );
+
+ return mergePostsBySlug(localizedPosts, preferredLang);
+});
+
+export async function getBlogIndexData(
+ preferredLang?: BlogLang,
+ filters?: {
+ tag?: string;
+ },
+): Promise {
+ const posts = await getAllPosts(preferredLang);
+ const tagFilter = filters?.tag;
+ const filteredPosts = tagFilter
+ ? posts.filter((post) => matchTags(post.metadata.tags, tagFilter))
+ : posts;
+ const recentPosts = posts.slice(0, RECENT_POST_LIMIT);
+ const postsByTag = new Map();
+
+ for (const post of posts) {
+ for (const tag of post.metadata.tags) {
+ const group = postsByTag.get(tag) ?? [];
+ group.push(post);
+ postsByTag.set(tag, group);
+ }
+ }
+
+ const tags = [...postsByTag.entries()]
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([name, tagPosts]) => ({
+ count: tagPosts.length,
+ name,
+ posts: [...tagPosts].sort(
+ (a, b) => parseDate(b.metadata.date) - parseDate(a.metadata.date),
+ ),
+ }));
+
+ const pageMap: PageMapItem[] = [
+ {
+ data: {
+ posts: { title: "All Posts", type: "Page" },
+ recent_posts: { title: "Recent Posts" },
+ tags: { title: "Tags" },
+ },
+ },
+ {
+ name: "All Posts",
+ route: "/blog/posts",
+ title: "All Posts",
+ frontMatter: {
+ title: "All Posts",
+ filePath: "blog/index.mdx",
+ },
+ } as MdxFile,
+ createFolder(
+ "recent_posts",
+ "/blog/recent-posts",
+ "Recent Posts",
+ recentPosts.map(createPostItem),
+ ),
+ ];
+
+ if (tags.length > 0) {
+ pageMap.push(
+ createFolder(
+ "tags",
+ "/blog/tags",
+ "Tags",
+ tags.map((tag) => {
+ return {
+ name: tag.name,
+ title: `${tag.name} (${tag.count})`,
+ route: `/blog/tags/${normalizeTagSlug(tag.name)}`,
+ };
+ }),
+ ),
+ );
+ }
+
+ return {
+ pageMap,
+ posts: filteredPosts,
+ recentPosts,
+ tags,
+ };
+}
diff --git a/frontend/src/core/i18n/locale.ts b/frontend/src/core/i18n/locale.ts
index 249deb3c4..b2740a0b8 100644
--- a/frontend/src/core/i18n/locale.ts
+++ b/frontend/src/core/i18n/locale.ts
@@ -16,6 +16,14 @@ export function getLocaleByLang(lang: string): Locale {
return DEFAULT_LOCALE;
}
+export function getLangByLocale(locale: Locale): string {
+ const parts = locale.split("-");
+ if (parts.length > 0 && typeof parts[0] === "string") {
+ return parts[0];
+ }
+ return locale;
+}
+
export function normalizeLocale(locale: string | null | undefined): Locale {
if (!locale) {
return DEFAULT_LOCALE;