Optimize blog performance with Next.js 16 features and video conversion
## Performance Improvements ### Next.js 16 Features - Enable Partial Prerendering (PPR) via cacheComponents - Add Turbopack for 4-5x faster development builds - Implement loading states and error boundaries - Configure static asset caching (1 year max-age) ### Bundle Size Reduction - Replace Framer Motion with CSS-only animations (~50KB reduction) - Dynamic import for SearchModal component (lazy loaded) - Optimize scroll reveals using IntersectionObserver - Remove loading attribute from OptimizedVideo (not supported on video elements) ### Image & Video Optimization - Add responsive sizes attributes to all Image components - Implement lazy loading for below-fold images - Add priority loading for hero images - Convert large GIFs to MP4/WebM formats (80-95% file size reduction) - Create OptimizedVideo component for efficient video playback ### Search Optimization - Configure Pagefind to index only essential content - Add data-pagefind-body wrapper for main content - Add data-pagefind-meta for tags metadata - Add data-pagefind-ignore for navigation and related posts - Result: Cleaner search results, smaller index size ### SEO & Social Media - Add dynamic OG image generation using @vercel/og - Enhance metadata with OpenGraph and Twitter Cards - Generate 1200x630 social images for all posts ### Documentation - Update README with comprehensive performance optimizations section - Document Pagefind configuration - Add GIF to video conversion details ## Technical Details Video file size reduction: - AddNewThings3.gif (2.4MB) → WebM (116KB) = 95% reduction - Things3.gif (1.5MB) → WebM (170KB) = 89% reduction - Total: 3.9MB → 286KB = 93% reduction Build output: 49 pages indexed, 5370 words searchable
This commit is contained in:
52
README.md
52
README.md
@@ -11,9 +11,61 @@ Recent updates include upgrading to Next.js 16 with Turbopack, migrating to Cont
|
|||||||
- **Runtime**: React 19
|
- **Runtime**: React 19
|
||||||
- **Styling**: Tailwind CSS + Typography plugin
|
- **Styling**: Tailwind CSS + Typography plugin
|
||||||
- **Content**: Markdown via Contentlayer2 (`contentlayer2/source-files`)
|
- **Content**: Markdown via Contentlayer2 (`contentlayer2/source-files`)
|
||||||
|
- **Search**: Pagefind for full-text search
|
||||||
- **Theming**: `next-themes` (light/dark), env‑driven accent color system
|
- **Theming**: `next-themes` (light/dark), env‑driven accent color system
|
||||||
- **Content source**: Git submodule `content` → [`personal-blog`](https://gitea.gbanyan.net/gbanyan/personal-blog.git)
|
- **Content source**: Git submodule `content` → [`personal-blog`](https://gitea.gbanyan.net/gbanyan/personal-blog.git)
|
||||||
|
|
||||||
|
## Performance Optimizations
|
||||||
|
|
||||||
|
This blog is optimized for performance using Next.js 16 features and best practices:
|
||||||
|
|
||||||
|
### Next.js 16 Features
|
||||||
|
|
||||||
|
- **Partial Prerendering (PPR)** enabled via `cacheComponents: true` for faster page loads
|
||||||
|
- **Turbopack** enabled in development for 4-5x faster builds
|
||||||
|
- **Static site generation** for all blog posts and pages
|
||||||
|
- **Loading states** and error boundaries for better UX
|
||||||
|
|
||||||
|
### Bundle Size Reduction
|
||||||
|
|
||||||
|
- **CSS-only animations** replacing Framer Motion (~50KB reduction)
|
||||||
|
- **Dynamic imports** for SearchModal component (lazy loaded when needed)
|
||||||
|
- **Optimized scroll reveals** using IntersectionObserver instead of React state
|
||||||
|
- **Tree-shaking** with Next.js compiler removing unused code
|
||||||
|
|
||||||
|
### Image & Video Optimization
|
||||||
|
|
||||||
|
- **Responsive images** with proper `sizes` attributes for all Next.js Image components
|
||||||
|
- **Lazy loading** for below-fold images, priority loading for hero images
|
||||||
|
- **AVIF/WebP formats** for better compression
|
||||||
|
- **GIF to video conversion**: Large animated GIFs converted to MP4/WebM for 80-95% file size reduction
|
||||||
|
- `AddNewThings3.gif` (2.4MB) → WebM (116KB) = 95% reduction
|
||||||
|
- `Things3.gif` (1.5MB) → WebM (170KB) = 89% reduction
|
||||||
|
|
||||||
|
### SEO & Social Media
|
||||||
|
|
||||||
|
- **Dynamic OG image generation** using `@vercel/og`
|
||||||
|
- **Enhanced metadata** with OpenGraph and Twitter Cards for all posts
|
||||||
|
- **1200x630 social images** with post title, description, and tags
|
||||||
|
|
||||||
|
### Search Optimization
|
||||||
|
|
||||||
|
Pagefind is configured to index only essential content:
|
||||||
|
- **Indexed**: Post titles, tags, and article body content
|
||||||
|
- **Excluded**: Navigation, related posts, footer, and UI elements
|
||||||
|
- This improves search relevance and reduces index size
|
||||||
|
|
||||||
|
Configuration in `app/blog/[slug]/page.tsx`:
|
||||||
|
- `data-pagefind-body` wraps main content area
|
||||||
|
- `data-pagefind-meta="tags"` marks tags as metadata
|
||||||
|
- `data-pagefind-ignore` excludes navigation and related posts
|
||||||
|
|
||||||
|
### Caching Strategy
|
||||||
|
|
||||||
|
- **Static assets** cached for 1 year (`max-age=31536000, immutable`)
|
||||||
|
- **PPR** caches static shells while streaming dynamic content
|
||||||
|
- **Font optimization** with Next.js font loading
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
- `app/` – Next.js App Router
|
- `app/` – Next.js App Router
|
||||||
|
|||||||
@@ -81,9 +81,11 @@ export default async function BlogPostPage({ params }: Props) {
|
|||||||
<ReadingProgress />
|
<ReadingProgress />
|
||||||
<PostLayout hasToc={hasToc}>
|
<PostLayout hasToc={hasToc}>
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<SectionDivider>
|
{/* Main content area for Pagefind indexing */}
|
||||||
<ScrollReveal>
|
<div data-pagefind-body>
|
||||||
<header className="mb-6 space-y-4 text-center">
|
<SectionDivider>
|
||||||
|
<ScrollReveal>
|
||||||
|
<header className="mb-6 space-y-4 text-center">
|
||||||
{post.published_at && (
|
{post.published_at && (
|
||||||
<p className="type-small text-slate-500 dark:text-slate-500">
|
<p className="type-small text-slate-500 dark:text-slate-500">
|
||||||
{new Date(post.published_at).toLocaleDateString(
|
{new Date(post.published_at).toLocaleDateString(
|
||||||
@@ -95,7 +97,7 @@ export default async function BlogPostPage({ params }: Props) {
|
|||||||
{post.title}
|
{post.title}
|
||||||
</h1>
|
</h1>
|
||||||
{post.tags && (
|
{post.tags && (
|
||||||
<div className="flex flex-wrap justify-center gap-2 pt-2">
|
<div className="flex flex-wrap justify-center gap-2 pt-2" data-pagefind-meta="tags">
|
||||||
{post.tags.map((t) => (
|
{post.tags.map((t) => (
|
||||||
<Link
|
<Link
|
||||||
key={t}
|
key={t}
|
||||||
@@ -133,23 +135,26 @@ export default async function BlogPostPage({ params }: Props) {
|
|||||||
</article>
|
</article>
|
||||||
</ScrollReveal>
|
</ScrollReveal>
|
||||||
</SectionDivider>
|
</SectionDivider>
|
||||||
|
</div>
|
||||||
|
|
||||||
<FooterCue />
|
<FooterCue />
|
||||||
|
|
||||||
<SectionDivider>
|
{/* Exclude navigation and related posts from search indexing */}
|
||||||
<ScrollReveal>
|
<div data-pagefind-ignore>
|
||||||
<PostStorylineNav
|
|
||||||
current={post}
|
|
||||||
newer={neighbors.newer}
|
|
||||||
older={neighbors.older}
|
|
||||||
/>
|
|
||||||
</ScrollReveal>
|
|
||||||
</SectionDivider>
|
|
||||||
|
|
||||||
{relatedPosts.length > 0 && (
|
|
||||||
<SectionDivider>
|
<SectionDivider>
|
||||||
<ScrollReveal>
|
<ScrollReveal>
|
||||||
<section className="space-y-6 rounded-2xl border border-slate-200/60 bg-slate-50/50 p-8 dark:border-slate-800 dark:bg-slate-900/30">
|
<PostStorylineNav
|
||||||
|
current={post}
|
||||||
|
newer={neighbors.newer}
|
||||||
|
older={neighbors.older}
|
||||||
|
/>
|
||||||
|
</ScrollReveal>
|
||||||
|
</SectionDivider>
|
||||||
|
|
||||||
|
{relatedPosts.length > 0 && (
|
||||||
|
<SectionDivider>
|
||||||
|
<ScrollReveal>
|
||||||
|
<section className="space-y-6 rounded-2xl border border-slate-200/60 bg-slate-50/50 p-8 dark:border-slate-800 dark:bg-slate-900/30">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<h2 className="type-subtitle font-semibold text-slate-900 dark:text-slate-50">
|
<h2 className="type-subtitle font-semibold text-slate-900 dark:text-slate-50">
|
||||||
相關文章
|
相關文章
|
||||||
@@ -166,7 +171,8 @@ export default async function BlogPostPage({ params }: Props) {
|
|||||||
</section>
|
</section>
|
||||||
</ScrollReveal>
|
</ScrollReveal>
|
||||||
</SectionDivider>
|
</SectionDivider>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PostLayout>
|
</PostLayout>
|
||||||
</>
|
</>
|
||||||
|
|||||||
68
components/optimized-video.tsx
Normal file
68
components/optimized-video.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { HTMLAttributes } from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
interface OptimizedVideoProps extends Omit<HTMLAttributes<HTMLVideoElement>, 'src'> {
|
||||||
|
src: string;
|
||||||
|
alt?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
autoPlay?: boolean;
|
||||||
|
loop?: boolean;
|
||||||
|
muted?: boolean;
|
||||||
|
playsInline?: boolean;
|
||||||
|
controls?: boolean;
|
||||||
|
poster?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optimized video component that provides:
|
||||||
|
* - Multiple format support (WebM and MP4) for better browser compatibility
|
||||||
|
* - Proper accessibility attributes
|
||||||
|
* - Automatic GIF-like behavior when autoPlay is enabled
|
||||||
|
* - Lightweight alternative to GIF files with 80-95% file size reduction
|
||||||
|
*/
|
||||||
|
export function OptimizedVideo({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
autoPlay = true,
|
||||||
|
loop = true,
|
||||||
|
muted = true,
|
||||||
|
playsInline = true,
|
||||||
|
controls = false,
|
||||||
|
poster,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: OptimizedVideoProps) {
|
||||||
|
// Remove file extension to get base path
|
||||||
|
const basePath = src.replace(/\.(mp4|webm|gif)$/i, '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
autoPlay={autoPlay}
|
||||||
|
loop={loop}
|
||||||
|
muted={muted}
|
||||||
|
playsInline={playsInline}
|
||||||
|
controls={controls}
|
||||||
|
poster={poster}
|
||||||
|
className={clsx('inline-block', className)}
|
||||||
|
aria-label={alt}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* WebM for better compression (Chrome, Firefox, Edge) */}
|
||||||
|
<source src={`${basePath}.webm`} type="video/webm" />
|
||||||
|
|
||||||
|
{/* MP4 for Safari and older browsers */}
|
||||||
|
<source src={`${basePath}.mp4`} type="video/mp4" />
|
||||||
|
|
||||||
|
{/* Fallback message for browsers that don't support video */}
|
||||||
|
<p className="text-slate-500 dark:text-slate-400">
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
{alt && <span className="block mt-2">{alt}</span>}
|
||||||
|
</p>
|
||||||
|
</video>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
content
2
content
Submodule content updated: c728118ba1...58c056fcf0
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
Reference in New Issue
Block a user