Repository: wakujs/waku
Pull request #1311: docs: Waku router under the hood and add guides to website
Author: login: tylersayshi
Created: 2025-03-17T04:21:35Z | Merged: 2025-03-20T12:00:31Z

This is an idea for a router post. I am happy to reword, restructure or whatever.

It was helpful for me to organize my thoughts to look a bit closer at what is loaded when on each route, so I think a post to talk through some of this has some value.

Excited to hear what y'all think :)


Diff:
diff --git a/docs/builder/aws-lambda.mdx b/docs/guides/aws-lambda.mdx
similarity index 99%
rename from docs/builder/aws-lambda.mdx
rename to docs/guides/aws-lambda.mdx
index 5a3435f0e2..af097f2915 100644
--- a/docs/builder/aws-lambda.mdx
+++ b/docs/guides/aws-lambda.mdx
@@ -1,11 +1,9 @@
 ---
-slug: builder/aws-lambda
+slug: aws-lambda
 title: AWS Lambda Builder
 description: Deploy a WAKU application to AWS.
 ---
 
-# AWS Lambda Builder
-
 The WAKU builder for AWS Lambda will provide the bundled output in the `dist` folder.
 The entry handler for the Lambda is `dist/serve-aws-lambda.handler`.
 
diff --git a/docs/guides/cloudflare.mdx b/docs/guides/cloudflare.mdx
index b0947ae5b3..61952991ae 100644
--- a/docs/guides/cloudflare.mdx
+++ b/docs/guides/cloudflare.mdx
@@ -1,5 +1,5 @@
 ---
-slug: guides/cloudflare
+slug: cloudflare
 title: Run Waku on Cloudflare
 description: How to integrate Waku with Cloudflare Workers and interact with Cloudflare bindings and other resources.
 ---
diff --git a/docs/guides/docker.mdx b/docs/guides/docker.mdx
index 4e80ffb20a..9add5809b7 100644
--- a/docs/guides/docker.mdx
+++ b/docs/guides/docker.mdx
@@ -1,5 +1,5 @@
 ---
-slug: guides/docker
+slug: docker
 title: Dockerise a Waku app
 description: How to package a Waku app into a Docker container.
 ---
diff --git a/docs/tutorials/getting-started.mdx b/docs/guides/getting-started.mdx
similarity index 87%
rename from docs/tutorials/getting-started.mdx
rename to docs/guides/getting-started.mdx
index d03fed9e11..a421c7c3b7 100644
--- a/docs/tutorials/getting-started.mdx
+++ b/docs/guides/getting-started.mdx
@@ -1,4 +1,8 @@
-# Chapter One
+---
+slug: getting-started
+title: Getting Started
+description: Let's build with Waku!
+---
 
 ## Getting Started
 
@@ -76,11 +80,11 @@ Either of these would render under the `/contact` route.
 
 **Layouts** are created with the special `_layout.tsx` filename and are how we create a shared layout that wraps a route and its descendents.
 
-The layout at `/pages/_layout.tsx` is the [root layout](https://waku.gg/#root-layout), which wraps the entire site. Layouts can be nested as well, which we’ll see in later chapters. We’ll also discover **segment routes**, **nested segment routes**, and **catch-all routes** as well as Waku’s two rendering methods: **static prerendering** at build time and **dynamic rendering** at request time.
+The layout at `/pages/_layout.tsx` is the [root layout](https://waku.gg/#root-layout), which wraps the entire site. Layouts can be nested as well, which we’ll see later. We’ll also discover **segment routes**, **nested segment routes**, and **catch-all routes** as well as Waku’s two rendering methods: **static prerendering** at build time and **dynamic rendering** at request time.
 
 ## And that’s all you need to get started
 
-In the next chapters we’ll dive deeper into everything to understand how powerful, but simple Waku is! 🙂
+Soon, we’ll dive deeper into everything to understand how powerful, but simple Waku is! 🙂
 
 ---
 
diff --git a/docs/guides/monorepo.mdx b/docs/guides/monorepo.mdx
index 64504409ef..50438f06ea 100644
--- a/docs/guides/monorepo.mdx
+++ b/docs/guides/monorepo.mdx
@@ -1,5 +1,5 @@
 ---
-slug: guides/monorepo
+slug: monorepo
 title: Waku in a Monorepo Setup
 description: Tips for Using Waku in a Monorepo Setup
 ---
diff --git a/docs/guides/react-compiler.mdx b/docs/guides/react-compiler.mdx
index 3ceaac5931..729ba2a70d 100644
--- a/docs/guides/react-compiler.mdx
+++ b/docs/guides/react-compiler.mdx
@@ -1,5 +1,5 @@
 ---
-slug: guides/react-compiler
+slug: react-compiler
 title: Enabling React Compiler in Waku
 description: Learn how to enable the React Compiler in your Waku project using the Vite Plugin to optimize your React applications.
 ---
diff --git a/docs/guides/router-fetch-strategy.mdx b/docs/guides/router-fetch-strategy.mdx
new file mode 100644
index 0000000000..ec7d4dfea2
--- /dev/null
+++ b/docs/guides/router-fetch-strategy.mdx
@@ -0,0 +1,37 @@
+---
+slug: router-fetch-strategy
+title: Waku Router Under the Hood
+description: A brief look into the mechanics of the Waku Router
+---
+
+When routing around a waku app, you may notice some surprisingly quick navigation speeds. This was my experience while starting development at least. Let's walk through the basic mechanics of the router to get a better shared understanding of how it works and maybe even how to speed things up even further 🚀.
+
+## Power of Client-Side Caching
+
+Let's say you load into `/` first and there is a `<Link to='/about' />` on the page.
+
+On initial load, the page from `pages/index.tsx` loads along with its route specific js bundle. As this loads, React does its hydration and we setup our RSC cache (we will revisit this later).
+
+Next, we press on the `/about` link and the router will fetch the RSC associated with the new page. Now that we are on the `/about` page and have read all about the project, we're ready to click the logo and navigate back to `/`.
+
+Here is where our first nice boost comes in! Since `/` is static, the client knows it can cache the RSC for this page, so we get our cache hit and immediately start rendering `/` without needing to go back to the server.
+
+## Speeding up Navigation with Dynamic Pages
+
+For dynamic pages, the expectation is that they should be re-requested from the server on each visit. So, links to a dynamic page will take the time that it takes for the server to render the page + the time spent over the network transmitting the RSC payload. This can make a navigation event to a dynamic page feel much slower.
+
+Fear not! We can take advantage of a very common trick for speeding the perceived load time of dynamic pages.
+
+```tsx
+<Link unstable_prefetchOnEnter to="/dynamic">
+  Dynamic
+</Link>
+```
+
+Now, when the user places their cursor over our link, we will fetch the RSC of that next page. Then when the user presses the link, if the promise for the RSC has resolved, we will switch to the next page, otherwise we will wait for the initial prefetch to finish. It's still a nice head start though!
+
+## Why is This Cool?
+
+None of this is specific to RSC, nor is it unique to Waku as a framework. However, the ergonomics of `prefetch` paired with the power of the client caching static RSC are quite nice features that will not be the default experience everywhere.
+
+We hope to cache only what is obviously cache-able with static RSC. Then we'll give you the tools to speed up your router interactions from there!
diff --git a/docs/guides/stream-intercept.mdx b/docs/guides/stream-intercept.mdx
index d57b1b0484..629fa14f22 100644
--- a/docs/guides/stream-intercept.mdx
+++ b/docs/guides/stream-intercept.mdx
@@ -1,5 +1,5 @@
 ---
-slug: guides/stream-intercept
+slug: stream-intercept
 title: SSR Stream Interception
 description: How to intercept SSR stream to inject content before it is sent to the client.
 ---
diff --git a/packages/website/src/components/icon.tsx b/packages/website/src/components/icon.tsx
index a92c1015eb..64d3bd5a6e 100644
--- a/packages/website/src/components/icon.tsx
+++ b/packages/website/src/components/icon.tsx
@@ -39,6 +39,24 @@ export const Icon = ({ icon, ...rest }: IconProps) => {
           <path d="M 19 3 C 13.488281 3 9 7.488281 9 13 C 9 15.394531 9.839844 17.589844 11.25 19.3125 L 3.28125 27.28125 L 4.71875 28.71875 L 12.6875 20.75 C 14.410156 22.160156 16.605469 23 19 23 C 24.511719 23 29 18.511719 29 13 C 29 7.488281 24.511719 3 19 3 Z M 19 5 C 23.429688 5 27 8.570313 27 13 C 27 17.429688 23.429688 21 19 21 C 14.570313 21 11 17.429688 11 13 C 11 8.570313 14.570313 5 19 5 Z" />
         </svg>
       );
+    case 'guide':
+      return (
+        <svg
+          xmlns="http://www.w3.org/2000/svg"
+          width="24"
+          height="24"
+          viewBox="0 0 24 24"
+          fill="none"
+          stroke="#000000"
+          strokeWidth="2"
+          strokeLinecap="round"
+          strokeLinejoin="round"
+          {...rest}
+        >
+          <path d="M22 10v6M2 10l10-5 10 5-10 5z" />
+          <path d="M6 12v5c3 3 9 3 12 0v-5" />
+        </svg>
+      );
     default:
       return null;
   }
diff --git a/packages/website/src/components/navigation.tsx b/packages/website/src/components/navigation.tsx
index a9ef9cc4d1..cc18406180 100644
--- a/packages/website/src/components/navigation.tsx
+++ b/packages/website/src/components/navigation.tsx
@@ -194,6 +194,7 @@ const docs = [
 
 const links = [
   { to: '/blog', icon: 'book', label: 'Blog' },
+  { to: '/guides', icon: 'guide', label: 'Guides' },
   { to: 'https://github.com/wakujs/waku', icon: 'github', label: 'GitHub' },
   { to: 'https://discord.gg/MrQdmzd', icon: 'discord', label: 'Discord' },
 ];
diff --git a/packages/website/src/components/post-list.tsx b/packages/website/src/components/post-list.tsx
new file mode 100644
index 0000000000..73fe27e660
--- /dev/null
+++ b/packages/website/src/components/post-list.tsx
@@ -0,0 +1,71 @@
+import { Link } from 'waku';
+
+type PostListItemProps = {
+  slug: string;
+  title: string;
+  description: string;
+  author?: {
+    name: string;
+  };
+  date?: string;
+  rawDate?: string;
+  release?: string | undefined;
+};
+
+const PostListItem = ({
+  postItem,
+  path,
+}: {
+  postItem: PostListItemProps;
+  path: 'blog' | 'guides';
+}) => (
+  <li
+    key={postItem.slug}
+    className="-mx-px first:-mt-4 sm:first:-mt-6 lg:first:-mt-12"
+  >
+    <Link
+      to={`/${path}/${postItem.slug}`}
+      className="bg-gray-950/90 group block w-full rounded-xl border border-gray-800 p-2 transition-colors duration-300 ease-in-out hover:border-secondary sm:p-4 lg:p-6"
+    >
+      <div className="flex items-center gap-2 whitespace-nowrap sm:gap-4">
+        {postItem.release && (
+          <div>
+            <div className="inline-block rounded-md bg-white px-2 py-1 text-[0.625rem] font-black tracking-wide text-black sm:text-xs">
+              <span className="hidden uppercase sm:inline">Waku</span>{' '}
+              {postItem.release}
+            </div>
+          </div>
+        )}
+        {(!!postItem.date || !!postItem.author) && (
+          <div className="inline-flex items-center gap-1 font-simple text-[11px] uppercase tracking-[0.125em] text-gray-400 sm:gap-4">
+            {!!postItem.date && <span>{postItem.date}</span>}
+            {!!postItem.date && !!postItem.author && (
+              <span className="text-gray-600">/</span>
+            )}
+            {!!postItem.author && <span>{postItem.author.name}</span>}
+          </div>
+        )}
+      </div>
+      <h3 className="mt-6 font-serif text-2xl font-extrabold leading-none sm:text-4xl">
+        {postItem.title}
+      </h3>
+      <div className="mt-2 text-sm font-normal leading-snug text-white/60 sm:mt-1 sm:text-base">
+        {postItem.description}
+      </div>
+    </Link>
+  </li>
+);
+
+export const PostList = ({
+  posts,
+  path,
+}: {
+  posts: PostListItemProps[];
+  path: 'blog' | 'guides';
+}) => (
+  <ul className="-mx-4 -mt-px flex flex-col gap-6 sm:-mx-6 md:-mx-12 lg:gap-12">
+    {posts.map((post) => (
+      <PostListItem key={post.slug} postItem={post} path={path} />
+    ))}
+  </ul>
+);
diff --git a/packages/website/src/components/post-page.tsx b/packages/website/src/components/post-page.tsx
new file mode 100644
index 0000000000..1e6e9ea096
--- /dev/null
+++ b/packages/website/src/components/post-page.tsx
@@ -0,0 +1,115 @@
+import { readFileSync } from 'node:fs';
+import { compileMDX } from 'next-mdx-remote/rsc';
+import { getFileName } from '../lib/get-file-name';
+import { components } from './mdx';
+import { getAuthor } from '../lib/get-author';
+import { Page } from './page';
+import { Meta } from './meta';
+
+export async function PostPage({
+  slug,
+  folder,
+}: {
+  slug: string;
+  folder: string;
+}) {
+  const fileName = await getFileName(folder, slug);
+
+  if (!fileName) {
+    return null;
+  }
+
+  const path = `${folder}/${fileName}`;
+  const source = readFileSync(path, 'utf8');
+  const mdx = await compileMDX({
+    source,
+    components,
+    options: { parseFrontmatter: true },
+  });
+  const { content } = mdx;
+  const frontmatter = mdx.frontmatter as {
+    slug: string;
+    title: string;
+    description: string;
+    date?: string;
+    author?: string;
+    release?: string;
+  };
+
+  const author = frontmatter.author ? getAuthor(frontmatter.author) : undefined;
+  const date = frontmatter.date
+    ? new Date(frontmatter.date).toLocaleDateString('en-US', {
+        month: 'long',
+        day: 'numeric',
+        year: 'numeric',
+      })
+    : undefined;
+
+  return (
+    <Page>
+      <Meta
+        title={`${frontmatter.title} — Waku`}
+        description={frontmatter.description}
+      />
+      <div className="relative z-10 mx-auto w-full max-w-[80ch] pt-16 text-white lg:pt-36 xl:-right-[calc(296px/2)] 2xl:right-auto">
+        <div className="mb-8 flex items-center gap-2 sm:gap-4">
+          {frontmatter.release && (
+            <div>
+              <div className="inline-block rounded-md bg-white px-2 py-1 text-[0.625rem] font-black tracking-wide text-black sm:text-xs">
+                <span className="hidden uppercase sm:inline">Waku</span>{' '}
+                {frontmatter.release}
+              </div>
+            </div>
+          )}
+          {date && (
+            <div className="font-simple text-[11px] uppercase tracking-[0.125em] text-gray-400">
+              {date}
+            </div>
+          )}
+        </div>
+        <h1 className="font-serif text-3xl font-extrabold leading-none sm:text-6xl">
+          {frontmatter.title}
+        </h1>
+        <h3 className="mt-2 text-lg font-normal leading-snug text-white/60 sm:mt-1 sm:text-xl sm:font-bold">
+          {frontmatter.description}
+        </h3>
+        {author && (
+          <a
+            href={author.url}
+            target="_blank"
+            rel="noreferrer"
+            className="group mx-auto mt-4 flex items-center gap-2 sm:mt-4"
+          >
+            <div className="relative size-8 overflow-clip rounded-full border border-gray-800 transition-colors duration-300 ease-in-out group-hover:border-white sm:size-6">
+              <img
+                src={author.avatar}
+                alt=""
+                className="absolute inset-0 h-full w-full object-cover"
+              />
+            </div>
+            <div className="font-simple text-[11px] uppercase tracking-[0.125em] text-gray-400 transition-colors duration-300 ease-in-out group-hover:text-white">
+              by {author.name}
+              <span className="hidden sm:inline">, </span>
+              <br className="sm:hidden" />
+              {author.biography}
+            </div>
+          </a>
+        )}
+        <hr className="mt-2 h-px border-none bg-gray-800" />
+      </div>
+      <div className="relative z-10 mx-auto w-full max-w-[80ch] pt-8 lg:pt-16 xl:-right-[calc(296px/2)] 2xl:right-auto">
+        {content}
+      </div>
+      <div className="relative z-10 mx-auto mb-8 mt-16 flex w-full max-w-[80ch] justify-center sm:mb-0 lg:mt-32 xl:-right-[calc(296px/2)] 2xl:right-auto">
+        <a
+          href="https://github.com/wakujs/waku"
+          target="_blank"
+          rel="noreferrer"
+          className="text-shadow inline-block! -rotate-[5deg] transform whitespace-nowrap text-center font-serif text-3xl font-extrabold leading-none text-white transition-colors duration-300 ease-in-out hover:text-primary sm:mr-4 sm:text-6xl"
+        >
+          star Waku on GitHub!
+        </a>
+      </div>
+    </Page>
+  );
+}
diff --git a/packages/website/src/lib/get-file-name.ts b/packages/website/src/lib/get-file-name.ts
new file mode 100644
index 0000000000..d1d800930b
--- /dev/null
+++ b/packages/website/src/lib/get-file-name.ts
@@ -0,0 +1,52 @@
+import { compileMDX } from 'next-mdx-remote/rsc';
+import { readdirSync, readFileSync } from 'node:fs';
+
+export const getFileName = async (folder: string, slug: string) => {
+  const blogFileNames: Array<string> = [];
+  const blogSlugToFileName: Record<string, string> = {};
+
+  readdirSync(folder).forEach((fileName) => {
+    if (fileName.endsWith('.mdx')) {
+      blogFileNames.push(fileName);
+    }
+  });
+
+  for await (const fileName of blogFileNames) {
+    const path = `${folder}/${fileName}`;
+    const source = readFileSync(path, 'utf8');
+    const mdx = await compileMDX({
+      source,
+      options: { parseFrontmatter: true },
+    });
+    const frontmatter = mdx.frontmatter as { slug: string };
+    blogSlugToFileName[frontmatter.slug] = fileName;
+  }
+
+  const fileName = blogSlugToFileName[slug];
+
+  return fileName;
+};
+
+export const getPostPaths = async (folder: string) => {
+  const blogPaths: Array<string> = [];
+  const blogFileNames: Array<string> = [];
+
+  readdirSync(folder).forEach((fileName) => {
+    if (fileName.endsWith('.mdx')) {
+      blogFileNames.push(fileName);
+    }
+  });
+
+  for await (const fileName of blogFileNames) {
+    const path = `${folder}/${fileName}`;
+    const source = readFileSync(path, 'utf8');
+    const mdx = await compileMDX({
+      source,
+      options: { parseFrontmatter: true },
+    });
+    const frontmatter = mdx.frontmatter as { slug: string };
+    blogPaths.push(frontmatter.slug);
+  }
+
+  return blogPaths;
+};
diff --git a/packages/website/src/pages/blog/[slug].tsx b/packages/website/src/pages/blog/[slug].tsx
index d344328dc8..60d2b010ed 100644
--- a/packages/website/src/pages/blog/[slug].tsx
+++ b/packages/website/src/pages/blog/[slug].tsx
@@ -1,160 +1,19 @@
-import { readdirSync, readFileSync } from 'node:fs';
-import { compileMDX } from 'next-mdx-remote/rsc';
-
-import { Page } from '../../components/page';
-import { Meta } from '../../components/meta';
-import { components } from '../../components/mdx';
-import { getAuthor } from '../../lib/get-author';
-import type { BlogFrontmatter } from '../../types';
+import { getPostPaths } from '../../lib/get-file-name';
+import { PostPage } from '../../components/post-page';
 
 type BlogArticlePageProps = {
   slug: string;
 };
 
 export default async function BlogArticlePage({ slug }: BlogArticlePageProps) {
-  const fileName = await getFileName(slug);
-
-  if (!fileName) {
-    return null;
-  }
-
-  const path = `./private/contents/${fileName}`;
-  const source = readFileSync(path, 'utf8');
-  const mdx = await compileMDX({
-    source,
-    components,
-    options: { parseFrontmatter: true },
-  });
-  const { content } = mdx;
-  const frontmatter = mdx.frontmatter as BlogFrontmatter;
-
-  const author = getAuthor(frontmatter.author);
-  const date = new Date(frontmatter.date).toLocaleDateString('en-US', {
-    month: 'long',
-    day: 'numeric',
-    year: 'numeric',
-  });
-
-  return (
-    <Page>
-      <Meta
-        title={`${frontmatter.title} — Waku`}
-        description={frontmatter.description}
-      />
-      <div className="relative z-10 mx-auto w-full max-w-[80ch] pt-16 text-white lg:pt-36 xl:-right-[calc(296px/2)] 2xl:right-auto">
-        <div className="mb-8 flex items-center gap-2 sm:gap-4">
-          {frontmatter.release && (
-            <div>
-              <div className="inline-block rounded-md bg-white px-2 py-1 text-[0.625rem] font-black tracking-wide text-black sm:text-xs">
-                <span className="hidden uppercase sm:inline">Waku</span>{' '}
-                {frontmatter.release}
-              </div>
-            </div>
-          )}
-          <div className="font-simple text-[11px] uppercase tracking-[0.125em] text-gray-400">
-            {date}
-          </div>
-        </div>
-        <h1 className="font-serif text-3xl font-extrabold leading-none sm:text-6xl">
-          {frontmatter.title}
-        </h1>
-        <h3 className="mt-2 text-lg font-normal leading-snug text-white/60 sm:mt-1 sm:text-xl sm:font-bold">
-          {frontmatter.description}
-        </h3>
-        <a
-          href={author.url}
-          target="_blank"
-          rel="noreferrer"
-          className="group mx-auto mt-4 flex items-center gap-2 sm:mt-4"
-        >
-          <div className="relative size-8 overflow-clip rounded-full border border-gray-800 transition-colors duration-300 ease-in-out group-hover:border-white sm:size-6">
-            <img
-              src={author.avatar}
-              alt=""
-              className="absolute inset-0 h-full w-full object-cover"
-            />
-          </div>
-          <div className="font-simple text-[11px] uppercase tracking-[0.125em] text-gray-400 transition-colors duration-300 ease-in-out group-hover:text-white">
-            by {author.name}
-            <span className="hidden sm:inline">, </span>
-            <br className="sm:hidden" />
-            {author.biography}
-          </div>
-        </a>
-        <hr className="mt-2 h-px border-none bg-gray-800" />
-      </div>
-      <div className="relative z-10 mx-auto w-full max-w-[80ch] pt-8 lg:pt-16 xl:-right-[calc(296px/2)] 2xl:right-auto">
-        {content}
-      </div>
-      <div className="relative z-10 mx-auto mb-8 mt-16 flex w-full max-w-[80ch] justify-center sm:mb-0 lg:mt-32 xl:-right-[calc(296px/2)] 2xl:right-auto">
-        <a
-          href="https://github.com/wakujs/waku"
-          target="_blank"
-          rel="noreferrer"
-          className="text-shadow inline-block! -rotate-[5deg] transform whitespace-nowrap text-center font-serif text-3xl font-extrabold leading-none text-white transition-colors duration-300 ease-in-out hover:text-primary sm:mr-4 sm:text-6xl"
-        >
-          star Waku on GitHub!
-        </a>
-      </div>
-    </Page>
-  );
+  return <PostPage slug={slug} folder="./private/contents" />;
 }
 
-const getFileName = async (slug: string) => {
-  const blogFileNames: Array<string> = [];
-  const blogSlugToFileName: Record<string, string> = {};
-
-  readdirSync('./private/contents').forEach((fileName) => {
-    if (fileName.endsWith('.mdx')) {
-      blogFileNames.push(fileName);
-    }
-  });
-
-  for await (const fileName of blogFileNames) {
-    const path = `./private/contents/${fileName}`;
-    const source = readFileSync(path, 'utf8');
-    const mdx = await compileMDX({
-      source,
-      options: { parseFrontmatter: true },
-    });
-    const frontmatter = mdx.frontmatter as BlogFrontmatter;
-    blogSlugToFileName[frontmatter.slug] = fileName;
-  }
-
-  const fileName = blogSlugToFileName[slug];
-
-  return fileName;
-};
-
 export const getConfig = async () => {
-  const blogPaths = await getBlogPaths();
+  const blogPaths = await getPostPaths('./private/contents');
 
   return {
     render: 'static',
     staticPaths: blogPaths,
   } as const;
 };
-
-const getBlogPaths = async () => {
-  const blogPaths: Array<string> = [];
-  const blogFileNames: Array<string> = [];
-
-  readdirSync('./private/contents').forEach((fileName) => {
-    if (fileName.endsWith('.mdx')) {
-      blogFileNames.push(fileName);
-    }
-  });
-
-  for await (const fileName of blogFileNames) {
-    const path = `./private/contents/${fileName}`;
-    const source = readFileSync(path, 'utf8');
-    const mdx = await compileMDX({
-      source,
-      options: { parseFrontmatter: true },
-    });
-    const frontmatter = mdx.frontmatter as BlogFrontmatter;
-    blogPaths.push(frontmatter.slug);
-  }
-
-  return blogPaths;
-};
diff --git a/packages/website/src/pages/blog/index.tsx b/packages/website/src/pages/blog/index.tsx
index 040d8cf07e..21696a58fe 100644
--- a/packages/website/src/pages/blog/index.tsx
+++ b/packages/website/src/pages/blog/index.tsx
@@ -1,4 +1,3 @@
-import { Link } from 'waku';
 import { readdirSync, readFileSync } from 'node:fs';
 import { compileMDX } from 'next-mdx-remote/rsc';
 
@@ -6,6 +5,7 @@ import { Page } from '../../components/page';
 import { Meta } from '../../components/meta';
 import { getAuthor } from '../../lib/get-author';
 import type { BlogFrontmatter } from '../../types';
+import { PostList } from '../../components/post-list';
 
 export default async function BlogIndexPage() {
   const articles = await getArticles();
@@ -13,50 +13,22 @@ export default async function BlogIndexPage() {
   return (
     <Page>
       <Meta title="Waku blog" description="The official Waku developer blog." />
-      <div className="relative z-10 mx-auto w-full max-w-[80ch] pt-16 text-white lg:pt-36 xl:-right-[calc(296px/2)] 2xl:right-auto">
-        <ul className="-mx-4 -mt-px flex flex-col gap-6 sm:-mx-6 md:-mx-12 lg:gap-12">
-          {articles.map((article) => (
-            <li
-              key={article.slug}
-              className="-mx-px first:-mt-4 sm:first:-mt-6 lg:first:-mt-12"
-            >
-              <Link
-                to={`/blog/${article.slug}`}
-                className="bg-gray-950/90 group block rounded-xl border border-gray-800 p-4 transition-colors duration-300 ease-in-out hover:border-secondary sm:p-6 lg:p-12"
-              >
-                <div className="flex items-center gap-2 whitespace-nowrap sm:gap-4">
-                  {article.release && (
-                    <div>
-                      <div className="inline-block rounded-md bg-white px-2 py-1 text-[0.625rem] font-black tracking-wide text-black sm:text-xs">
-                        <span className="hidden uppercase sm:inline">Waku</span>{' '}
-                        {article.release}
-                      </div>
-                    </div>
-                  )}
-                  <div className="inline-flex items-center gap-1 font-simple text-[11px] uppercase tracking-[0.125em] text-gray-400 sm:gap-4">
-                    <span>{article.date}</span>
-                    <span className="text-gray-600">/</span>
-                    <span>{article.author.name}</span>
-                  </div>
-                </div>
-                <h3 className="mt-6 font-serif text-2xl font-extrabold leading-none sm:text-4xl">
-                  {article.title}
-                </h3>
-                <div className="mt-2 text-sm font-normal leading-snug text-white/60 sm:mt-1 sm:text-base">
-                  {article.description}
-                </div>
-              </Link>
-            </li>
-          ))}
-        </ul>
-      </div>
+      <PostList posts={articles} path="blog" />
     </Page>
   );
 }
 
 const getArticles = async () => {
   const blogFileNames: Array<string> = [];
-  const blogArticles: Array<any> = [];
+  const blogArticles: Array<{
+    slug: string;
+    title: string;
+    description: string;
+    author: { name: string };
+    date: string;
+    rawDate: string;
+    release: string | undefined;
+  }> = [];
 
   readdirSync('./private/contents').forEach((fileName) => {
     if (fileName.endsWith('.mdx')) {
diff --git a/packages/website/src/pages/guides/[slug].tsx b/packages/website/src/pages/guides/[slug].tsx
new file mode 100644
index 0000000000..72b9fe1485
--- /dev/null
+++ b/packages/website/src/pages/guides/[slug].tsx
@@ -0,0 +1,19 @@
+import { getPostPaths } from '../../lib/get-file-name';
+import { PostPage } from '../../components/post-page';
+
+type BlogArticlePageProps = {
+  slug: string;
+};
+
+export default async function BlogArticlePage({ slug }: BlogArticlePageProps) {
+  return <PostPage slug={slug} folder="../../docs/guides" />;
+}
+
+export const getConfig = async () => {
+  const blogPaths = await getPostPaths('../../docs/guides');
+
+  return {
+    render: 'static',
+    staticPaths: blogPaths,
+  } as const;
+};
diff --git a/packages/website/src/pages/guides/index.tsx b/packages/website/src/pages/guides/index.tsx
new file mode 100644
index 0000000000..4d56ac9d32
--- /dev/null
+++ b/packages/website/src/pages/guides/index.tsx
@@ -0,0 +1,69 @@
+import { readdirSync, readFileSync } from 'node:fs';
+import { compileMDX } from 'next-mdx-remote/rsc';
+
+import { Page } from '../../components/page';
+import { Meta } from '../../components/meta';
+import type { BlogFrontmatter } from '../../types';
+import { PostList } from '../../components/post-list';
+
+export default async function BlogIndexPage() {
+  const articles = await getArticles();
+
+  return (
+    <Page>
+      <Meta
+        title="Waku guide"
+        description="The guides for working with Waku."
+      />
+      <div className="relative z-10 mx-auto w-full max-w-[80ch] pt-16 text-white lg:pt-36 xl:-right-[calc(296px/2)] 2xl:right-auto">
+        <p className="bg-gray-950/90 mb-16 rounded-xl border border-gray-800 p-4 text-white sm:p-6 lg:p-12">
+          Our guides walk through hosting instructions, framework behavior,
+          developer tooling, and more! We will talk through unstable APIs here,
+          so you can help experiment with our new and fun features.
+        </p>
+        <PostList posts={articles} path="guides" />
+      </div>
+    </Page>
+  );
+}
+
+const getArticles = async () => {
+  const blogFileNames: Array<string> = [];
+  const blogArticles: Array<{
+    slug: string;
+    title: string;
+    description: string;
+  }> = [];
+
+  readdirSync('../../docs/guides/').forEach((fileName) => {
+    if (fileName.endsWith('.mdx')) {
+      blogFileNames.push(fileName);
+    }
+  });
+
+  for await (const fileName of blogFileNames) {
+    const path = `../../docs/guides/${fileName}`;
+    const source = readFileSync(path, 'utf8');
+    const mdx = await compileMDX({
+      source,
+      options: { parseFrontmatter: true },
+    });
+    const frontmatter = mdx.frontmatter as BlogFrontmatter;
+
+    const article = {
+      slug: frontmatter.slug,
+      title: frontmatter.title,
+      description: frontmatter.description,
+    };
+
+    blogArticles.push(article);
+  }
+
+  return blogArticles.sort((a, b) => a.slug.localeCompare(b.slug));
+};
+
+export const getConfig = async () => {
+  return {
+    render: 'static',
+  } as const;
+};


Reviews:
user: login: hamlim
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T19:31:16Z
user: login: dai-shi
body: Merging this for now. I think there should be room for improvement for website, like ordering guides, and styling.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-20T12:00:25Z

Review comments:
diff_hunk: @@ -0,0 +1,37 @@
+---
+slug: router-fetch-strategy
+title: Waku Router Under the Hood
+description: A brief look into the mechanics of the Waku Router
+---
+
+When routing around a waku app, you may notice some surprisingly quick navigation speeds. This was my experience while starting development at least. Let's walk through the basic mechanics of the router to get a better shared understanding of how it works and maybe even how to speed things up even further 🚀.
+
+## Power of Client-Side Cacheing
path: docs/guides/router-fetch-strategy.mdx
user: login: hamlim
body: Small nit: I think the proper spelling is `Caching` (without the e)

```suggestion
## Power of Client-Side Caching
```
created_at: 2025-03-19T19:19:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,37 @@
+---
+slug: router-fetch-strategy
+title: Waku Router Under the Hood
+description: A brief look into the mechanics of the Waku Router
+---
+
+When routing around a waku app, you may notice some surprisingly quick navigation speeds. This was my experience while starting development at least. Let's walk through the basic mechanics of the router to get a better shared understanding of how it works and maybe even how to speed things up even further 🚀.
+
+## Power of Client-Side Cacheing
+
+Let's say you load into `/` first and there is a `<Link to='/about' />` on the page.
+
+On initial load, the page from `pages/index.tsx` loads along with its route specific js bundle. As this loads, React does its hydration and we setup our RSC cache (we will revisit this later).
+
+Next, we press on the `/about` link and the router will fetch the RSC associated with the new page. Now that we are on the `/about` page and have read all about the project, we're ready to click the logo and navigate back to `/`.
+
+Here is where our first nice boost comes in! Since `/` is static, the client knows it can cache the RSC for this page, so we get our cache hit and immediately start rendering `/` without needing to go back to the server.
+
+## Speeding up Navigation with Dynamic Pages
+
+For dynamic pages, the expectation is that they should be re-requested from the server on each visit. So, links to a dynamic page will take the time that it takes for the server to render the page + the time spent over the network transmitting the RSC payload. This can make a navigation event to a dynamic page feel much slower.
+
+Fear not! We can take advantage of a very common trick for speeding the perceived load time of dynamic pages.
+
+```tsx
+<Link unstable_prefetchOnEnter to="/dynamic">
+  Dynamic
+</Link>
+```
+
+Now, when the user places their cursor over our link, we will fetch the RSC of that next page. Then when the user presses the link, if the promise for the RSC has resolved, we will switch to the next page, otherwise we will wait for the initial prefetch to finish. It's still a nice head start though!
+
+## Why is This Cool?
+
+None of this is specific to RSC, nor is it unique to Waku as a framework. However, the ergonomics of `prefetch` paired with the power of the client cacheing static RSC are quite nice features that will not be the default experience everywhere.
path: docs/guides/router-fetch-strategy.mdx
user: login: hamlim
body: ```suggestion
None of this is specific to RSC, nor is it unique to Waku as a framework. However, the ergonomics of `prefetch` paired with the power of the client caching static RSC are quite nice features that will not be the default experience everywhere.
```
created_at: 2025-03-19T19:19:56Z
author_association: CONTRIBUTOR

PR comments:
user: login: vercel[bot]
created_at: 2025-03-17T04:21:39Z
body: [vc]: #8wWcQY1ZX2508uUwEOVD3vf7Q6wd4tpL3aW53Fgk0aU=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJ3YWt1IiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2RhaXNoaXMtcHJvamVjdHMvd2FrdS9GdE5jMVpzaUJXRXVCQThqOXd0TXA4dDJEZWNnIiwicHJldmlld1VybCI6Indha3UtZ2l0LWJsb2ctcm91dGVyLWRhaXNoaXMtcHJvamVjdHMudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJJR05PUkVEIiwicm9vdERpcmVjdG9yeSI6bnVsbH1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)


<details><summary>1 Skipped Deployment</summary>

| Name | Status | Preview | Updated (UTC) |
| :--- | :----- | :------ | :------ |
| **waku** | ⬜️ Ignored ([Inspect](https://vercel.com/daishis-projects/waku/FtNc1ZsiBWEuBA8j9wtMp8t2Decg)) | [Visit Preview](https://waku-git-blog-router-daishis-projects.vercel.app) | Mar 20, 2025 7:07am |
</details>


author_association: NONE
user: login: rmarscher
created_at: 2025-03-20T15:45:58Z
body: Maybe we should mention that Suspense and promises passed from the server can be used in layouts or pages to be able to display content while the rest is still being streamed down from the server. We could link out to the React docs about Suspense for people to learn more about it - https://react.dev/reference/react/Suspense

By default, a `useTransition` hook is used when navigating with the <Link> component. That causes the stale content inside Suspense to continue to display until the new content has loaded. There's an experimental `unstable_startTransition` to supply a different function to call. This can be the startTransition from your own useTransition hook call which can give you access to the isPending state. Or it's possible to pass a no-op function to skip useTransition entirely.
author_association: MEMBER
user: login: dai-shi
created_at: 2025-03-20T23:54:47Z
body: > By default, a `useTransition` hook is used when navigating with the component. 

That's no longer the case. #1257

author_association: MEMBER