Building Modern Presentations with React Router 7: A Deep Dive into RR7-Slides

react-router-7

A technical exploration of a server-optimized slideshow application that leverages React Router 7's framework mode, MDX, and modern web technologies

The Presentation Tool Struggle is Real

I've given a lot of technical talks over the years, and let me tell you - the presentation tool struggle is real. PowerPoint feels clunky when you're trying to show code. Google Slides breaks when you're offline at a conference. And don't get me started on trying to embed live demos.

So naturally, being a developer, I thought "I'll just build my own." How hard could it be, right? (Spoiler alert: harder than I expected.)

Three failed attempts and two months later, I finally landed on something that actually works with RR7-Slides—a modern slideshow application that brings the full power of React Router 7's framework mode, TypeScript, and MDX to presentations. Here's what I learned about building presentations that don't suck.

What Makes This Different

The breakthrough came when I stopped fighting the framework and started embracing React Router 7's server-first philosophy. My first attempts were all about runtime file discovery, dynamic imports, and client-side caching. You know that feeling when you're writing more infrastructure code than actual features? That was me for weeks.

Then I discovered React Router 7's framework mode, and everything clicked.

The Server-Side Revolution

Here's the thing that really changed my perspective: instead of discovering slides at runtime, what if we just configured them on the server? Sounds obvious now, but it took me embarrassingly long to get there.

The old approach looked like this mess:

typescript
app/routes/slides/
├── 01-intro.mdx
├── 02-alfa.mdx  
├── 03-beta.mdx
├── 04-charlie.mdx
├── 05-delta.mdx
└── 06-end.mdx

And then I'd have this gnarly useEffect that would scan for files, dynamically import them, manage loading states... you get the picture.

The new approach eliminated all that complexity with server-side configuration:

typescript
// app/utils/slides.server.ts - Server-only slide configuration
export interface SlideConfig {
  id: string;
  title: string;
  order: number;
  filename: string;
}

// Static slide configuration - no runtime discovery needed
export const SLIDES_CONFIG: Record<string, SlideConfig> = {
  intro: { id: 'intro', title: 'Introduction', order: 0, filename: '01-intro.mdx' },
  alfa: { id: 'alfa', title: 'Alpha Features', order: 1, filename: '02-alfa.mdx' },
  beta: { id: 'beta', title: 'Beta Release', order: 2, filename: '03-beta.mdx' },
  charlie: { id: 'charlie', title: 'Charlie Updates', order: 3, filename: '04-charlie.mdx' },
  delta: { id: 'delta', title: 'Delta Changes', order: 4, filename: '05-delta.mdx' },
  end: { id: 'end', title: 'Conclusion', order: 5, filename: '06-end.mdx' },
} as const;

// Server-side slide resolution - runs at request time
export function getSlideNavigation(slideId: string): SlideNavigation {
  const currentIndex = SLIDE_ORDER.indexOf(slideId);
  
  return {
    slideId,
    currentIndex,
    totalSlides: SLIDE_ORDER.length,
    nextSlide: SLIDE_ORDER[currentIndex + 1] || null,
    prevSlide: SLIDE_ORDER[currentIndex - 1] || null,
    title: SLIDES_CONFIG[slideId].title,
  };
}

Static component imports meant React Router 7 could handle code splitting automatically:

javascript
// app/utils/slide-components.ts - Build-time component mapping
import Intro from '../routes/slides/01-intro.mdx';
import Alfa from '../routes/slides/02-alfa.mdx';
import Beta from '../routes/slides/03-beta.mdx';
import Charlie from '../routes/slides/04-charlie.mdx';
import Delta from '../routes/slides/05-delta.mdx';
import End from '../routes/slides/06-end.mdx';

// Static component mapping - no dynamic imports needed
export const SLIDE_COMPONENTS: Record<string, React.ComponentType> = {
  intro: Intro,
  alfa: Alfa,
  beta: Beta,
  charlie: Charlie,
  delta: Delta,
  end: End,
} as const;

The difference was immediately obvious. No more runtime overhead, no file discovery, no loading states. React Router 7 handled all the optimization for me, and slides were pre-rendered as HTML on the server.

Deep Linking Changed Everything

You know what's frustrating? When you're in the middle of a presentation Q&A and someone asks about slide 7, but you can't just jump there. Traditional presentation tools make this surprisingly hard.

With React Router 7, every slide gets its own URL: /slides/intro, /slides/alfa, etc. This isn't just a nice-to-have—it completely transformed how I present.

Now I can bookmark specific slides for quick access during Q&A, share direct links to relevant sections with stakeholders, and even resume presentations exactly where I left off. The audience can reference specific sections after the presentation and share key slides with colleagues who missed the session.

For development teams, this is game-changing. You can review presentations asynchronously, integrate slide links into project wikis, and create presentation libraries organized by topic with persistent URLs.

React Router 7 Framework Mode: The Secret Sauce

Here's where React Router 7 really shines. The application fully leverages framework mode with server-side rendering:

typescript
// app/routes.ts - Modern route configuration
import { type RouteConfig, index, route } from '@react-router/dev/routes';

export default [
    index('routes/home.tsx'),
    route('slides/:slideId', 'routes/stage.tsx')
] satisfies RouteConfig;
typescript
// react-router.config.ts
import type { Config } from "@react-router/dev/config";

export default {
  ssr: true, // Server-side rendering enabled
} satisfies Config;

This setup delivers everything I was trying to build manually: server-side rendering, automatic code splitting, link prefetching for smooth navigation, and progressive enhancement that works even with JavaScript disabled.

From Complexity to Simplicity

My biggest breakthrough was realizing how much simpler the presentation controller could be when I moved complexity to the server:

javascript
// Server-side loader - all slide resolution happens on the server
export async function loader({ params }: Route.LoaderArgs) {
    const { slideId = getFirstSlideId() } = params;

    // Server-side slide validation and navigation data
    if (!slideExists(slideId)) {
        throw new Response('', {
            status: 302,
            headers: { Location: `/slides/${getFirstSlideId()}` }
        });
    }

    // Get all navigation data on the server
    const navigation = getSlideNavigation(slideId);
    const allSlideIds = getAllSlideIds();
    
    return { ...navigation, allSlideIds };
}

The component became dramatically simpler with no loading states needed:

jsx
export default function StageRoute({ loaderData }: Route.ComponentProps) {
    const navigate = useNavigate();
    const { slideId, currentIndex, totalSlides, nextSlide, prevSlide, title, allSlideIds } = loaderData;
    
    // Get the slide component - resolved at build time, not runtime
    const SlideComponent = getSlideComponent(slideId);

    // Minimal client-side keyboard navigation
    useEffect(() => {
        const handleKeyDown = (event: KeyboardEvent) => {
            if (event.key === 'ArrowLeft' && prevSlide) {
                navigate(`/slides/${prevSlide}`);
            } else if (event.key === 'ArrowRight' && nextSlide) {
                navigate(`/slides/${nextSlide}`);
            }
        };

        window.addEventListener('keydown', handleKeyDown);
        return () => window.removeEventListener('keydown', handleKeyDown);
    }, [navigate, nextSlide, prevSlide]);

    // No loading states needed - component is available immediately
    if (!SlideComponent) {
        return <div>Slide not found</div>;
    }

    return (
        <div className="w-screen h-screen relative bg-gray-900 text-white overflow-hidden">
            <title>{title} - React Router 7 Slides</title>
            
            <div className="px-32 py-16 pb-32 xl:px-24 md:px-16 h-full flex items-center justify-center">
                <div className="max-w-4xl w-full">
                    {/* Server-rendered slide component - no Suspense needed */}
                    <SlideComponent />
                </div>
            </div>

            {/* Navigation with Link prefetching */}
            {prevSlide && (
                <Link
                    to={`/slides/${prevSlide}`}
                    className="absolute left-8 top-1/2 transform -translate-y-1/2 bg-gray-800 bg-opacity-90 hover:bg-opacity-100 text-white p-4 rounded-full transition-all border border-gray-600 hover:border-gray-400 shadow-lg"
                    prefetch="intent"
                >
                    {/* Previous arrow SVG */}
                </Link>
            )}

            {nextSlide && (
                <Link
                    to={`/slides/${nextSlide}`}
                    className="absolute right-8 top-1/2 transform -translate-y-1/2 bg-gray-800 bg-opacity-90 hover:bg-opacity-100 text-white p-4 rounded-full transition-all border border-gray-600 hover:border-gray-400 shadow-lg"
                    prefetch="intent"
                >
                    {/* Next arrow SVG */}
                </Link>
            )}

            {/* Slide indicator with prefetching */}
            <div className="absolute bottom-8 left-1/2 transform -translate-x-1/2">
                <div className="flex space-x-2">
                    {allSlideIds.map((targetSlideId, slideIndex) => (
                        <Link
                            key={slideIndex}
                            to={`/slides/${targetSlideId}`}
                            className={`w-3 h-3 rounded-full transition-all cursor-pointer hover:scale-110 ${
                                slideIndex === currentIndex
                                    ? 'bg-white opacity-100'
                                    : 'bg-white opacity-25 hover:opacity-50'
                            }`}
                            prefetch="intent"
                        />
                    ))}
                </div>
            </div>

            <div className="absolute top-8 right-8 text-sm opacity-70">
                {currentIndex + 1} / {totalSlides}
            </div>
        </div>
    );
}

Content Authoring with MDX

Writing slides became actually enjoyable when I switched to MDX. Instead of fighting with presentation software, I could combine Markdown simplicity with React component power:

html
---
title: Key Performance Metrics
---

<div className="h-full flex flex-col justify-center items-center text-center">
  <h1 className="text-9xl font-bold text-white mb-8">
    100%
  </h1>
  
  <div className="bg-gray-800 bg-opacity-50 rounded-2xl p-8">
    <p className="text-xl text-gray-200">
      Performance improvement from server-side rendering
    </p>
  </div>
</div>

This meant I could include interactive components, live code examples, and custom styling while still writing in a format that felt natural.

The Performance Story

The numbers don't lie. My old client-heavy approach was embarrassing:

Before (Client-Heavy):

  • Runtime file discovery with import.meta.glob()
  • Dynamic component imports in useEffect
  • Client-side caching and loading states
  • Multiple async operations for navigation
  • Large client JavaScript bundle

After (Server-Optimized):

  • Static server-side configuration
  • Pre-rendered HTML sent to client
  • Automatic code splitting by React Router 7
  • Link prefetching for smooth navigation
  • Minimal client JavaScript

The improvement was dramatic. Faster initial loads, smaller bundle sizes (eliminated 94 lines of discovery code), better SEO since search engines could index slide content, improved accessibility since it worked without JavaScript, and smoother navigation with prefetching eliminating loading delays.

Technical Architecture That Actually Works

The file structure ended up being beautifully simple:

typescript
app/
├── routes/
│   ├── slides/           # MDX slide files
│   ├── stage.tsx         # Simplified presentation controller
│   └── home.tsx          # Landing page
├── utils/
│   ├── slides.server.ts  # Server-only slide configuration
│   └── slide-components.ts # Static component imports
└── routes.ts             # React Router 7 configuration

The data flow became predictable: user navigates to /slides/intro, server loader validates slide and gets navigation data, server renders slide component to HTML, client hydrates with minimal JavaScript for navigation, and prefetching preloads adjacent slides for smooth transitions.

Form Handling and Actions

One thing I learned was how powerful React Router 7's action system could be for presentation management. While the core slideshow didn't need forms, I added a simple slide editing interface:

jsx
// app/routes/slides/edit.tsx
export async function action({ request, params }: ActionFunctionArgs) {
  const formData = await request.formData();
  const content = formData.get("content") as string;
  const slideId = params.slideId;
  
  // Update slide content
  await updateSlideContent(slideId, content);
  
  // Redirect back to the slide
  return redirect(`/slides/${slideId}`);
}

export default function EditSlide({ loaderData }: Route.ComponentProps) {
  return (
    <Form method="post" className="edit-form">
      <textarea 
        name="content" 
        defaultValue={loaderData.content}
        className="slide-editor"
      />
      <button type="submit">Save Slide</button>
    </Form>
  );
}

This pattern meant I could edit slides live during presentations if needed, with the form submission automatically revalidating and updating the slide content.

Why This Approach Works

The server-optimized approach succeeds because it leverages React Router 7's strengths instead of fighting them. It eliminates unnecessary complexity by avoiding runtime discovery, optimizes for performance with server rendering and automatic code splitting, improves developer experience through simpler code and faster builds, and enhances user experience with instant loading and smooth navigation.

When I look back at my original attempts, I was essentially trying to rebuild what React Router 7 provides out of the box. The framework mode gives you SSR, code splitting, data loading, and navigation management. Why would you want to implement all that yourself?

Getting Started with Your Own

If you want to build something similar, here's what I'd recommend:

First, set up React Router 7 with server-side rendering enabled. The CLI makes this straightforward, and you'll want framework mode from the start.

Next, create static slide configuration in a .server.ts file. Don't try to be clever with runtime discovery—explicit is better than implicit here.

Then, use static imports for slide components so React Router 7 can optimize them automatically.

Finally, implement Link prefetching for smooth navigation. The prefetch="intent" prop makes navigation feel instant.

The result is a presentation system that feels instant, works everywhere, and scales beautifully with React Router 7's modern architecture.

Lessons Learned

Building RR7-Slides taught me that embracing server-side rendering and React Router 7's framework mode can dramatically simplify applications while improving performance and user experience. Sometimes the best solution is to stop fighting the framework and start working with it.

The key insight was moving beyond simple page routing to building a comprehensive presentation platform. React Router 7's patterns made this possible without the complexity I was expecting.

If you're building anything that involves content management, navigation, or user experiences that need to feel fast, React Router 7's framework mode is worth exploring. The investment in learning server-first patterns pays off quickly.


RR7-Slides demonstrates how embracing server-side rendering and React Router 7's framework mode can dramatically simplify applications while improving performance and user experience. The source code and detailed implementation guide are available on GitHub.


Thank you for reading! If you enjoyed this article, feel free to share it on social media to help others discover it. Stay tuned for more updates and insights!


Additional articles