Files
portfolio/docs/plans/2026-05-07-url-driven-section-routing.md
T

25 KiB

URL-Driven Section Routing Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Replace the single-page client-state accordion with URL-driven routing — each section gets its own static URL (/intro, /bio, etc.), clicking a section heading navigates between pages.

Architecture: app/[[...slug]]/page.tsx is the single RSC that handles all routes. It resolves the active slug from URL params (defaulting to first section at /), then passes activeSlug down to SectionsAccordion (now a server component). SectionAccordion entity renders inactive sections as <Link> elements. SidebarNav uses usePathname() for active state.

Tech Stack: Next.js 16 App Router, React 19, Vitest + RTL, TypeScript strict, Biome, next/link, next/navigation.


Task 0: Commit next.config.ts fix

The output: 'export' was already made conditional on NODE_ENV === 'production' to allow route handlers in dev. Commit it.

Files:

  • Already modified: next.config.ts

Step 1: Verify file is correct

next.config.ts should read:

import type { NextConfig } from 'next';

const isExport = process.env.NODE_ENV === 'production';

const nextConfig: NextConfig = {
  /* output: 'export' only applies at build time — enabling it in dev mode
   * breaks route handlers (incompatible with force-dynamic in Next.js 16) */
  ...(isExport ? { output: 'export' } : {}),
  images: { unoptimized: true },
};

export default nextConfig;

Step 2: Commit

git add next.config.ts
git commit -m "fix: make output export build-only so dev route handlers work"

Task 1: Update SectionAccordion entity — onClick → href (TDD)

Replace the inactive <button onClick> with <Link href>. The entity already has tests — update them first.

Files:

  • Modify: src/entities/Section/ui/SectionAccordion/SectionAccordion.test.tsx
  • Modify: src/entities/Section/ui/SectionAccordion/SectionAccordion.tsx

Step 1: Update the tests

Replace src/entities/Section/ui/SectionAccordion/SectionAccordion.test.tsx entirely:

import { render, screen } from '@testing-library/react';
import { SectionAccordion } from './SectionAccordion';

const defaultProps = {
  number: '01',
  title: 'About',
  id: 'about',
  isActive: false,
  href: '/about',
  children: <p>Content here</p>,
};

describe('SectionAccordion', () => {
  describe('collapsed state (isActive=false)', () => {
    it('renders a section element with the given id', () => {
      const { container } = render(<SectionAccordion {...defaultProps} />);
      expect(container.querySelector('section#about')).toBeInTheDocument();
    });

    it('renders a link with number and title', () => {
      render(<SectionAccordion {...defaultProps} />);
      expect(screen.getByRole('link', { name: /01.*About/i })).toBeInTheDocument();
    });

    it('link points to the correct href', () => {
      render(<SectionAccordion {...defaultProps} />);
      expect(screen.getByRole('link', { name: /01.*About/i })).toHaveAttribute('href', '/about');
    });

    it('does not render children', () => {
      render(<SectionAccordion {...defaultProps} />);
      expect(screen.queryByText('Content here')).not.toBeInTheDocument();
    });

    it('does not render a button', () => {
      render(<SectionAccordion {...defaultProps} />);
      expect(screen.queryByRole('button')).not.toBeInTheDocument();
    });
  });

  describe('active state (isActive=true)', () => {
    const activeProps = { ...defaultProps, isActive: true };

    it('renders an h1 with number and title', () => {
      render(<SectionAccordion {...activeProps} />);
      expect(screen.getByRole('heading', { level: 1, name: /01.*About/i })).toBeInTheDocument();
    });

    it('renders children', () => {
      render(<SectionAccordion {...activeProps} />);
      expect(screen.getByText('Content here')).toBeInTheDocument();
    });

    it('does not render a link', () => {
      render(<SectionAccordion {...activeProps} />);
      expect(screen.queryByRole('link')).not.toBeInTheDocument();
    });

    it('content wrapper has animate-fadeIn class', () => {
      const { container } = render(<SectionAccordion {...activeProps} />);
      expect(container.querySelector('.animate-fadeIn')).toBeInTheDocument();
    });
  });
});

Step 2: Run tests — verify they fail

yarn test src/entities/Section/ui/SectionAccordion --run

Expected: FAIL — tests expecting link role but component still renders button.

Step 3: Update the component

Replace src/entities/Section/ui/SectionAccordion/SectionAccordion.tsx:

import Link from 'next/link';
import type { ReactNode } from 'react';

interface SectionAccordionProps {
  /**
   * Display number prefix (e.g. "01")
   */
  number: string;
  /**
   * Section title
   */
  title: string;
  /**
   * HTML id for anchor navigation
   */
  id: string;
  /**
   * Whether this section is expanded
   */
  isActive: boolean;
  /**
   * Navigation URL for the collapsed heading link
   */
  href: string;
  /**
   * Section content, shown when active
   */
  children: ReactNode;
}

/**
 * Accordion-style section that collapses to a navigation link when inactive.
 */
export function SectionAccordion({ number, title, id, isActive, href, children }: SectionAccordionProps) {
  return (
    <section id={id} className="scroll-mt-8">
      {isActive ? (
        <div className="mb-12">
          <div className="mb-16">
            <h1
              className="font-heading font-black text-5xl leading-[1.2] mb-0"
              style={{ fontVariationSettings: "'WONK' 1, 'SOFT' 0" }}
            >
              {number}. {title}
            </h1>
          </div>
          <div className="animate-fadeIn">{children}</div>
        </div>
      ) : (
        <Link
          href={href}
          className="block w-full text-left mb-3 py-3 transition-all duration-200 hover:opacity-60 group"
        >
          <h2
            className="font-heading font-black text-2xl sm:text-3xl opacity-30 group-hover:opacity-50 transition-opacity"
            style={{ fontVariationSettings: "'WONK' 1, 'SOFT' 0" }}
          >
            {number}. {title}
          </h2>
        </Link>
      )}
    </section>
  );
}

Step 4: Run tests — verify they pass

yarn test src/entities/Section/ui/SectionAccordion --run

Expected: all PASS.

Step 5: Commit

git add src/entities/Section/ui/SectionAccordion/
git commit -m "feat: SectionAccordion inactive state uses Link href instead of button onClick"

Task 2: Update SectionsAccordion widget — drop client state, add activeSlug prop (TDD)

The widget becomes a server component. activeSlug is passed as a prop from the page. children is a single RSC slot for the active section content only.

Files:

  • Modify: src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.test.tsx
  • Modify: src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx

Step 1: Rewrite the tests

Replace src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.test.tsx:

import { render, screen } from '@testing-library/react';
import type { SectionRecord } from '$entities/Section';
import { SectionsAccordion } from './SectionsAccordion';

const baseRecord = { collectionId: 'c1', collectionName: 'sections', created: '', updated: '' };

const sections: SectionRecord[] = [
  { ...baseRecord, id: '1', slug: 'intro', title: 'Intro', number: '01', order: 1 },
  { ...baseRecord, id: '2', slug: 'bio', title: 'Bio', number: '02', order: 2 },
  { ...baseRecord, id: '3', slug: 'skills', title: 'Skills', number: '03', order: 3 },
];

describe('SectionsAccordion', () => {
  describe('active section rendering', () => {
    it('renders the active section as h1', () => {
      render(
        <SectionsAccordion sections={sections} activeSlug="bio">
          <div>Bio content</div>
        </SectionsAccordion>,
      );
      expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('02. Bio');
    });

    it('renders active section children', () => {
      render(
        <SectionsAccordion sections={sections} activeSlug="bio">
          <div>Bio content</div>
        </SectionsAccordion>,
      );
      expect(screen.getByText('Bio content')).toBeInTheDocument();
    });
  });

  describe('inactive section rendering', () => {
    it('renders inactive sections as links', () => {
      render(
        <SectionsAccordion sections={sections} activeSlug="bio">
          <div>Bio content</div>
        </SectionsAccordion>,
      );
      const links = screen.getAllByRole('link');
      expect(links).toHaveLength(2);
    });

    it('inactive links point to correct hrefs', () => {
      render(
        <SectionsAccordion sections={sections} activeSlug="bio">
          <div>Bio content</div>
        </SectionsAccordion>,
      );
      expect(screen.getByRole('link', { name: /01.*Intro/i })).toHaveAttribute('href', '/intro');
      expect(screen.getByRole('link', { name: /03.*Skills/i })).toHaveAttribute('href', '/skills');
    });

    it('does not render children for inactive sections', () => {
      render(
        <SectionsAccordion sections={sections} activeSlug="bio">
          <div>Bio content</div>
        </SectionsAccordion>,
      );
      expect(screen.getAllByText('Bio content')).toHaveLength(1);
    });
  });

  describe('first section default', () => {
    it('shows first section as active when activeSlug matches first', () => {
      render(
        <SectionsAccordion sections={sections} activeSlug="intro">
          <div>Intro content</div>
        </SectionsAccordion>,
      );
      expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('01. Intro');
    });
  });
});

Step 2: Run tests — verify they fail

yarn test src/widgets/SectionsAccordion --run

Expected: FAIL — component still uses useState and doesn't accept activeSlug prop.

Step 3: Rewrite the component

Replace src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx:

import type { ReactNode } from 'react';
import type { SectionRecord } from '$entities/Section';
import { SectionAccordion } from '$entities/Section';

type Props = {
  /**
   * Ordered section metadata — drives navigation labels and IDs
   */
  sections: SectionRecord[];
  /**
   * Slug of the currently active section
   */
  activeSlug: string;
  /**
   * Content for the active section — rendered inside the expanded accordion item
   */
  children: ReactNode;
};

/**
 * Renders all portfolio sections as an accordion list.
 * Active section is determined by URL (activeSlug from page params).
 * Inactive sections render as navigation links.
 */
export function SectionsAccordion({ sections, activeSlug, children }: Props) {
  return (
    <div className="space-y-2">
      {sections.map((section) => (
        <SectionAccordion
          key={section.slug}
          id={section.slug}
          number={section.number}
          title={section.title}
          isActive={activeSlug === section.slug}
          href={`/${section.slug}`}
        >
          {activeSlug === section.slug ? children : null}
        </SectionAccordion>
      ))}
    </div>
  );
}

Step 4: Run tests — verify they pass

yarn test src/widgets/SectionsAccordion --run

Expected: all PASS.

Step 5: Commit

git add src/widgets/SectionsAccordion/
git commit -m "refactor: SectionsAccordion server component, activeSlug prop replaces useState"

Task 3: Update SidebarNav — IntersectionObserver → usePathname (TDD)

Items become <Link> elements. Active state driven by usePathname(). The first section is also active at /.

Files:

  • Modify: src/widgets/Navigation/ui/SidebarNav.test.tsx
  • Modify: src/widgets/Navigation/ui/SidebarNav.tsx

Step 1: Rewrite the tests

Replace src/widgets/Navigation/ui/SidebarNav.test.tsx:

import { render, screen } from '@testing-library/react';
import type { NavItem } from '../model/types';
import { SidebarNav } from './SidebarNav';

vi.mock('next/navigation', () => ({
  usePathname: vi.fn(),
}));

import { usePathname } from 'next/navigation';

const ITEMS: NavItem[] = [
  { id: 'bio', label: 'Bio', number: '01' },
  { id: 'work', label: 'Work', number: '02' },
];

describe('SidebarNav', () => {
  describe('rendering', () => {
    beforeEach(() => {
      vi.mocked(usePathname).mockReturnValue('/bio');
    });

    it('renders a nav element', () => {
      render(<SidebarNav items={ITEMS} />);
      expect(screen.getByRole('navigation')).toBeInTheDocument();
    });

    it('renders "Index" heading', () => {
      render(<SidebarNav items={ITEMS} />);
      expect(screen.getByText('Index')).toBeInTheDocument();
    });

    it('renders "Digital Monograph" subtitle', () => {
      render(<SidebarNav items={ITEMS} />);
      expect(screen.getByText('Digital Monograph')).toBeInTheDocument();
    });

    it('renders each item label and number', () => {
      render(<SidebarNav items={ITEMS} />);
      expect(screen.getByText('Bio')).toBeInTheDocument();
      expect(screen.getByText('01')).toBeInTheDocument();
      expect(screen.getByText('Work')).toBeInTheDocument();
      expect(screen.getByText('02')).toBeInTheDocument();
    });

    it('renders "Quick Links" section', () => {
      render(<SidebarNav items={ITEMS} />);
      expect(screen.getByText('Quick Links')).toBeInTheDocument();
    });

    it('renders Email quick link', () => {
      render(<SidebarNav items={ITEMS} />);
      expect(screen.getByRole('link', { name: 'Email' })).toBeInTheDocument();
    });

    it('renders a link for each item', () => {
      render(<SidebarNav items={ITEMS} />);
      expect(screen.getByRole('link', { name: /Bio/i })).toBeInTheDocument();
      expect(screen.getByRole('link', { name: /Work/i })).toBeInTheDocument();
    });
  });

  describe('active state', () => {
    it('marks matching pathname item as active', () => {
      vi.mocked(usePathname).mockReturnValue('/bio');
      render(<SidebarNav items={ITEMS} />);
      const activeLink = screen.getByRole('link', { name: /Bio/i });
      expect(activeLink).not.toHaveClass('opacity-40');
    });

    it('marks non-matching item as inactive', () => {
      vi.mocked(usePathname).mockReturnValue('/bio');
      render(<SidebarNav items={ITEMS} />);
      const inactiveLink = screen.getByRole('link', { name: /Work/i });
      expect(inactiveLink).toHaveClass('opacity-40');
    });

    it('marks first item active at root path', () => {
      vi.mocked(usePathname).mockReturnValue('/');
      render(<SidebarNav items={ITEMS} />);
      const firstLink = screen.getByRole('link', { name: /Bio/i });
      expect(firstLink).not.toHaveClass('opacity-40');
    });
  });
});

Step 2: Run tests — verify they fail

yarn test src/widgets/Navigation/ui/SidebarNav.test.tsx --run

Expected: FAIL — component still uses IntersectionObserver and renders buttons, not links.

Step 3: Rewrite the component

Replace src/widgets/Navigation/ui/SidebarNav.tsx:

'use client';

import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { CONTACT_LINKS, cn } from '$shared/lib';
import type { NavItem } from '../model/types';

interface Props {
  /**
   * Navigation items to render
   */
  items: NavItem[];
}

/**
 * Fixed sidebar navigation, visible on lg+ screens.
 * Active section determined by current URL pathname.
 */
export function SidebarNav({ items }: Props) {
  const pathname = usePathname();

  /**
   * An item is active when its slug matches the current pathname,
   * or when the pathname is root and it is the first item.
   */
  function isActive(item: NavItem): boolean {
    if (pathname === `/${item.id}`) return true;
    if (pathname === '/' && items[0]?.id === item.id) return true;
    return false;
  }

  return (
    <nav className="fixed top-0 left-0 h-screen w-full lg:w-1/3 bg-ochre-clay brutal-border-right hidden lg:block overflow-y-auto z-50">
      <div className="px-8 py-12 space-y-2">
        <div className="mb-12">
          <h2>Index</h2>
          <div className="brutal-border-top pt-4">
            <p className="text-sm opacity-60">Digital Monograph</p>
          </div>
        </div>

        {items.map((item) => (
          <Link
            key={item.id}
            href={`/${item.id}`}
            className={cn(
              'block w-full text-left brutal-border bg-ochre-clay px-6 py-4 transition-all duration-300',
              isActive(item)
                ? 'shadow-[12px_12px_0_var(--carbon-black)] opacity-100 translate-x-0'
                : 'opacity-40 shadow-none hover:opacity-60',
            )}
          >
            <div className="flex items-baseline gap-4">
              <span className="text-sm opacity-60">{item.number}</span>
              <span className="font-heading text-xl font-black">{item.label}</span>
            </div>
          </Link>
        ))}

        <div className="mt-12 pt-12 brutal-border-top">
          <p className="text-sm uppercase tracking-wider mb-4 opacity-60">Quick Links</p>
          <div className="space-y-3">
            <a href={`mailto:${CONTACT_LINKS.email}`} className="block">
              Email
            </a>
            <a href={CONTACT_LINKS.linkedin} className="block">
              LinkedIn
            </a>
            <a href={CONTACT_LINKS.instagram} className="block">
              Instagram
            </a>
            <a href={CONTACT_LINKS.arena} className="block">
              Are.na
            </a>
          </div>
        </div>
      </div>
    </nav>
  );
}

Step 4: Run tests — verify they pass

yarn test src/widgets/Navigation/ui/SidebarNav.test.tsx --run

Expected: all PASS.

Step 5: Commit

git add src/widgets/Navigation/ui/SidebarNav.tsx src/widgets/Navigation/ui/SidebarNav.test.tsx
git commit -m "refactor: SidebarNav uses usePathname and Link instead of IntersectionObserver"

Section items become <Link> elements that close the menu onClick. The scrollToSection function is removed.

Files:

  • Modify: src/widgets/Navigation/ui/MobileNav.test.tsx
  • Modify: src/widgets/Navigation/ui/MobileNav.tsx

Step 1: Update the tests

Replace src/widgets/Navigation/ui/MobileNav.test.tsx:

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { NavItem } from '../model/types';
import { MobileNav } from './MobileNav';

const ITEMS: NavItem[] = [{ id: 'about', label: 'About', number: '01' }];

describe('MobileNav', () => {
  describe('rendering', () => {
    it('renders title "allmy.work"', () => {
      render(<MobileNav items={ITEMS} />);
      expect(screen.getByText('allmy.work')).toBeInTheDocument();
    });

    it('renders toggle button with text "Menu" initially', () => {
      render(<MobileNav items={ITEMS} />);
      expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument();
    });

    it('menu items are hidden initially', () => {
      render(<MobileNav items={ITEMS} />);
      expect(screen.queryByRole('link', { name: /About/i })).not.toBeInTheDocument();
    });
  });

  describe('interactions', () => {
    it('click toggle shows item links and changes label to "Close"', async () => {
      render(<MobileNav items={ITEMS} />);
      await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
      expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
      expect(screen.getByRole('link', { name: /About/i })).toBeInTheDocument();
    });

    it('item links point to correct href', async () => {
      render(<MobileNav items={ITEMS} />);
      await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
      expect(screen.getByRole('link', { name: /About/i })).toHaveAttribute('href', '/about');
    });

    it('click item link closes the menu', async () => {
      render(<MobileNav items={ITEMS} />);
      await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
      await userEvent.click(screen.getByRole('link', { name: /About/i }));
      expect(screen.queryByText('Close')).not.toBeInTheDocument();
      expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument();
    });
  });
});

Step 2: Run tests — verify they fail

yarn test src/widgets/Navigation/ui/MobileNav.test.tsx --run

Expected: FAIL — component still uses button for items, not link.

Step 3: Update the component

Replace src/widgets/Navigation/ui/MobileNav.tsx:

'use client';

import Link from 'next/link';
import { useState } from 'react';
import { cn } from '$shared/lib';
import type { NavItem } from '../model/types';

interface Props {
  /**
   * Navigation items to render
   */
  items: NavItem[];
}

/**
 * Mobile navigation overlay, hidden on lg+ screens.
 * Section items are links that close the menu on navigate.
 */
export function MobileNav({ items }: Props) {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div className="lg:hidden fixed top-0 left-0 right-0 bg-ochre-clay brutal-border-bottom z-50">
      <div className="px-6 py-4 flex items-center justify-between">
        <h4>allmy.work</h4>
        <button
          type="button"
          onClick={() => setIsOpen((prev) => !prev)}
          className="brutal-border px-4 py-2 bg-carbon-black text-ochre-clay"
        >
          {isOpen ? 'Close' : 'Menu'}
        </button>
      </div>
      {isOpen && (
        <div className="px-6 py-6 brutal-border-top space-y-2 max-h-[80vh] overflow-y-auto">
          {items.map((item) => (
            <Link
              key={item.id}
              href={`/${item.id}`}
              onClick={() => setIsOpen(false)}
              className="block w-full text-left brutal-border bg-ochre-clay px-4 py-3"
            >
              <div className={cn('flex items-baseline gap-3')}>
                <span className="text-sm opacity-60 font-body">{item.number}</span>
                <span
                  className="font-heading text-lg font-black"
                  style={{ fontVariationSettings: '"WONK" 1, "SOFT" 0' }}
                >
                  {item.label}
                </span>
              </div>
            </Link>
          ))}
        </div>
      )}
    </div>
  );
}

Step 4: Run tests — verify they pass

yarn test src/widgets/Navigation/ui/MobileNav.test.tsx --run

Expected: all PASS.

Step 5: Commit

git add src/widgets/Navigation/ui/MobileNav.tsx src/widgets/Navigation/ui/MobileNav.test.tsx
git commit -m "refactor: MobileNav section items use Link instead of scrollToSection"

Task 5: Create ...slug/page.tsx and delete app/page.tsx

Wire everything together with generateStaticParams for SSG.

Files:

  • Create: app/[[...slug]]/page.tsx
  • Delete: app/page.tsx

Step 1: Create the route file

Create app/[[...slug]]/page.tsx:

import { notFound } from 'next/navigation';
import type { SectionRecord } from '$entities/Section';
import { getCollection } from '$shared/api';
import type { NavItem } from '$widgets/Navigation';
import { MobileNav, SidebarNav } from '$widgets/Navigation';
import { SectionFactory } from '$widgets/SectionFactory';
import { SectionsAccordion } from '$widgets/SectionsAccordion';

/**
 * Generates static params for all section pages plus the root.
 */
export async function generateStaticParams() {
  const { items: sections } = await getCollection<SectionRecord>('sections', {
    sort: 'order',
  });
  return [
    {},
    ...sections.map((s) => ({ slug: [s.slug] })),
  ];
}

/**
 * Portfolio page — handles all section routes via optional catchall.
 *
 * `/`         → first section shown as active
 * `/{slug}`   → that section shown as active
 */
export default async function SectionPage({
  params,
}: {
  params: Promise<{ slug?: string[] }>;
}) {
  const { slug } = await params;

  const { items: sections } = await getCollection<SectionRecord>('sections', {
    sort: 'order',
  });

  if (!sections.length) notFound();

  const activeSlug = slug?.[0] ?? sections[0].slug;

  if (!sections.find((s) => s.slug === activeSlug)) notFound();

  const navItems: NavItem[] = sections.map((s) => ({
    id: s.slug,
    label: s.title,
    number: s.number,
  }));

  return (
    <div className="min-h-screen lg:flex">
      <SidebarNav items={navItems} />
      <main className="flex-1 lg:ml-[33.333%] px-8 py-12 lg:py-16 lg:px-16">
        <MobileNav items={navItems} />
        <SectionsAccordion sections={sections} activeSlug={activeSlug}>
          <SectionFactory slug={activeSlug} />
        </SectionsAccordion>
      </main>
    </div>
  );
}

Step 2: Delete app/page.tsx

rm app/page.tsx

Step 3: TypeScript check

yarn tsc --noEmit

Expected: no errors.

Step 4: Commit

git add app/
git commit -m "feat: URL-driven section routing via optional catchall with generateStaticParams"

Task 6: Final Verification

Step 1: Full test suite

yarn test --run

Expected: all tests PASS.

Step 2: TypeScript check

yarn tsc --noEmit

Expected: no errors.

Step 3: Lint check

yarn check

Expected: no errors. If auto-fixable issues: yarn check:fix then re-run.

Step 4: Dev server smoke test

yarn dev

Open http://localhost:3000 — should show first section (intro) as active, others as links. Clicking a section link should change the URL and show that section's content.