Deploying Next.js 16 to AWS with SST: App Router, Server Components, and ISR

Storyie Engineering Team
8 min read

Learn how we deploy our Next.js 16 application to AWS using SST, leveraging App Router architecture, Server Components, and Incremental Static Regeneration for optimal performance.

Deploying Next.js 16 to AWS with SST: App Router, Server Components, and ISR

Next.js 16 represents a major leap forward for React-based web applications, introducing the App Router as the recommended approach, making Server Components the default, and improving Incremental Static Regeneration (ISR). At Storyie, we migrated our entire web application to Next.js 16 and deploy it to AWS using SST (Serverless Stack). In this post, we'll share our architecture, deployment strategy, and the patterns we use to build a fast, scalable diary application.

Why Next.js 16 and SST?

Our choice of Next.js 16 and SST deployment was driven by specific technical requirements:

Next.js 16 Features We Leverage:

  • App Router: File-based routing with nested layouts, loading states, and error boundaries
  • Server Components by Default: Reduced client-side JavaScript, better SEO, faster initial loads
  • Turbopack: Significantly faster development builds compared to Webpack
  • Enhanced ISR: More granular control over revalidation strategies

SST Deployment Benefits:

  • Zero-Config AWS: Deploy to CloudFront, Lambda, and S3 without manual AWS configuration
  • Custom Domains: Built-in support for Route53 DNS and SSL certificates
  • OpenNext Integration: Automatic server-side rendering and static optimization
  • Local Development: Test serverless functions locally before deploying

Our web application handles user authentication, diary creation with a rich text editor, public/private content sharing, and analytics—all requiring a balance between performance (static/ISR) and freshness (dynamic rendering). Next.js 16's rendering strategies perfectly match these needs.

App Router Architecture

The App Router in Next.js 16 introduced a powerful file-based routing system with route groups, nested layouts, and colocation of components. Here's how we structure our application:

File-Based Routing Structure

Our apps/web/app/ directory uses route groups to organize related routes:

app/
├── (auth)/              # Authentication routes
│   └── login/page.tsx
├── (dashboard)/         # Protected user dashboard
│   ├── diaries/
│   │   ├── [slug]/page.tsx
│   │   └── new/page.tsx
│   ├── profile/page.tsx
│   └── settings/page.tsx
├── (public)/            # Public diary browsing
│   ├── d/[slug]/page.tsx      # Public diary details
│   ├── u/[slug]/page.tsx      # User profiles
│   └── tags/[tag]/page.tsx    # Tag-based filtering
├── (landing)/           # Marketing pages
│   └── page.tsx
├── (legal)/             # Legal pages
│   ├── privacy/page.tsx
│   └── terms/page.tsx
├── api/                 # API routes
│   ├── auth/callback/route.ts
│   └── diaries/route.ts
├── layout.tsx           # Root layout
└── globals.css          # Global styles

Route Groups (wrapped in parentheses like (auth)) allow us to:

  • Organize routes logically without affecting the URL structure
  • Apply shared layouts to specific route groups
  • Colocate related pages and components
  • Separate public and protected routes at the layout level

For example, (auth)/login/page.tsx renders at /login (the group name doesn't appear in the URL), and all routes within (dashboard)/ share a common layout with navigation and auth checks.

Nested Layouts

Layouts in Next.js 16 enable shared UI across multiple routes. Here's our root layout:

// apps/web/app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: 'Storyie - Your Personal Diary',
  description: 'Create and share your stories with Storyie',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang='en'>
      <body className={inter.className}>{children}</body>
    </html>
  );
}

And our dashboard layout adds authentication checks and navigation:

// apps/web/app/(dashboard)/layout.tsx
import { redirect } from 'next/navigation';
import { auth } from '@/lib/auth';
import { DashboardNav } from '@/components/dashboard/DashboardNav';

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await auth();

  if (!session) {
    redirect('/login');
  }

  return (
    <div className='min-h-screen bg-background'>
      <DashboardNav />
      <main className='container mx-auto px-4 py-8'>{children}</main>
    </div>
  );
}

This pattern means authentication logic lives in one place, and every page within (dashboard)/ automatically inherits it. No need to duplicate auth checks across dozens of pages.

Server Components vs Client Components

One of the most significant changes in Next.js 16 is that Server Components are now the default. This means every component renders on the server unless you explicitly add "use client" at the top of the file.

When to Use Server Components

Server Components should be your default choice because they:

  • Reduce client-side JavaScript bundle size (components don't ship to the browser)
  • Enable direct database queries and server-side data fetching
  • Improve SEO by rendering HTML on the server
  • Eliminate the need for client-side loading states for initial data

Here's a server component example from our diary list:

// apps/web/components/diary/DiaryCard.tsx
import Link from 'next/link';
import { formatDistanceToNow } from 'date-fns';

export function DiaryCard({ diary }) {
  return (
    <Link href={`/diaries/${diary.slug}`}>
      <article className='p-4 rounded-lg bg-card hover:bg-accent transition-colors'>
        <h3 className='text-lg font-semibold'>{diary.title}</h3>
        <p className='text-sm text-muted-foreground'>
          {formatDistanceToNow(new Date(diary.createdAt), { addSuffix: true })}
        </p>
        {diary.excerpt && (
          <p className='mt-2 text-sm line-clamp-2'>{diary.excerpt}</p>
        )}
      </article>
    </Link>
  );
}

This component has no client-side JavaScript. It renders HTML on the server, and the browser only receives static markup. The Link component handles navigation without requiring JavaScript (progressive enhancement).

When to Use Client Components

You need "use client" when your component:

  • Uses React hooks (useState, useEffect, useContext, etc.)
  • Handles browser events (onClick, onSubmit, onChange)
  • Accesses browser APIs (window, localStorage, navigator)
  • Uses third-party libraries that require client-side JavaScript (e.g., chart libraries)

Here's our Lexical rich text editor, which must run on the client:

// apps/web/components/lexical/LexicalEditor.tsx
'use client';

import { useState } from 'react';
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { ImageNode } from '@storyie/lexical-common/nodes/ImageNode';
import { DefaultTheme } from '@storyie/lexical-common/themes/DefaultTheme';

export function LexicalEditor({ initialContent, onContentChange }) {
  const [editorState, setEditorState] = useState(initialContent);

  const editorConfig = {
    namespace: 'storyie-web',
    theme: DefaultTheme,
    nodes: [ImageNode],
    onError: (error) => console.error(error),
  };

  return (
    <LexicalComposer initialConfig={editorConfig}>
      <div className='relative'>
        <RichTextPlugin
          contentEditable={<ContentEditable className='editor-input' />}
          placeholder={<div>Start writing your story...</div>}
          ErrorBoundary={null}
        />
      </div>
    </LexicalComposer>
  );
}

Composition Patterns

The most powerful pattern is composing server and client components together. Server components can import and render client components, passing server-fetched data as props:

// apps/web/app/(dashboard)/diaries/[slug]/page.tsx (Server Component)
import { getDiary } from '@/lib/db/queries/diaries';
import { LexicalEditor } from '@/components/lexical/LexicalEditor';

export default async function DiaryPage({ params }) {
  // Server-side data fetching
  const diary = await getDiary(params.slug);

  // Pass server data to client component
  return (
    <div>
      <h1>{diary.title}</h1>
      <LexicalEditor initialContent={diary.content} />
    </div>
  );
}

This pattern ensures:

  • Data fetching happens on the server (no client-side loading states)
  • Only interactive UI ships JavaScript to the browser
  • The initial page load is fast and SEO-friendly

Incremental Static Regeneration (ISR)

ISR allows us to update static content without rebuilding the entire site. We use ISR strategically across different content types.

ISR Configuration

To enable ISR, export a revalidate constant from your page or route:

// apps/web/app/sitemap.xml/route.ts
export const revalidate = 3600; // Revalidate every 1 hour

export async function GET() {
  const diaries = await getDiaries();
  const sitemap = generateSitemap(diaries);

  return new Response(sitemap, {
    headers: {
      'Content-Type': 'application/xml',
    },
  });
}

For protected diary pages, we use a shorter revalidation period:

// apps/web/app/(dashboard)/diaries/[slug]/page.tsx
export const revalidate = 900; // 15 minutes

export async function generateMetadata({ params }) {
  const diary = await getDiary(params.slug);
  return {
    title: diary.title,
    description: diary.excerpt || 'Read this diary entry on Storyie',
  };
}

export default async function DiaryPage({ params }) {
  const diary = await getDiary(params.slug);
  // ... render diary
}

Caching Strategy

We apply different caching strategies based on content type:

Static Routes (no revalidation):

  • /privacy - Privacy policy (rarely changes)
  • /terms - Terms of service (rarely changes)
  • /login - Login page (static form)

ISR Routes (time-based revalidation):

  • /sitemap.xml - 1 hour revalidation (search engines update periodically)
  • /diaries/[slug] (protected) - 15 minutes (balance freshness and performance)

Force-Dynamic Routes (always fresh):

  • All public routes (/, /d/[slug], /u/[slug], /tags/*) - User-generated content must be fresh
  • Protected dashboards (/diaries, /diaries/new) - Real-time user data

The caching configuration lives in layout files:

// apps/web/app/(public)/layout.tsx
export const dynamic = 'force-dynamic'; // All public routes are dynamic

export default function PublicLayout({ children }) {
  return <>{children}</>;
}

Trade-offs and Performance

Our caching strategy prioritizes content freshness for user-generated content over maximum performance. Here's why:

  • Public Diaries: Users expect to see updates immediately when they publish or edit a diary
  • User Profiles: Profile information (name, bio, diary count) should reflect the latest state
  • Tag Pages: Tag listings need to include newly published diaries

The trade-off is that public pages can't be fully cached at the edge, resulting in slightly slower initial loads. However, we mitigate this with:

  • Efficient database queries using Drizzle ORM
  • Database connection pooling via Supabase
  • Selective static rendering for legal and marketing pages

SST Deployment to AWS

SST (Serverless Stack) simplifies deploying Next.js to AWS by handling the infrastructure setup automatically.

SST Configuration

Our sst.config.ts at the monorepo root defines the entire deployment:

// sst.config.ts
/// <reference path="./.sst/platform/config.d.ts" />

export default $config({
  app(input) {
    return {
      name: 'storyie',
      removal: input?.stage === 'production' ? 'retain' : 'remove',
      home: 'aws',
    };
  },
  async run() {
    const web = new sst.aws.Nextjs('Web', {
      path: 'apps/web',
      domain: {
        name: 'storyie.com',
        dns: sst.aws.dns({ zone: 'storyie.com' }),
      },
    });

    return {
      url: web.url,
    };
  },
});

This 20-line configuration:

  • Deploys our Next.js app to AWS Lambda and CloudFront
  • Sets up a custom domain with Route53 DNS
  • Configures SSL certificates automatically
  • Manages environment variables per stage (dev, staging, production)

OpenNext Integration

SST uses OpenNext under the hood to transform Next.js applications for serverless deployment. Here's what happens when you run npx sst deploy:

  1. Build Phase: Next.js builds your application (.next directory)
  2. Transform Phase: OpenNext splits the build into serverless-compatible parts:
  • Server Functions: Server components and API routes → AWS Lambda functions
  • Static Assets: Images, CSS, client components → S3 bucket
  • Middleware: Routing logic → Lambda@Edge functions
  1. Deploy Phase: SST provisions AWS resources:
  • CloudFront distribution for global CDN
  • Lambda functions for server-side rendering
  • S3 buckets for static file storage
  • Route53 DNS records and ACM certificates

The architecture looks like this:

User Request
    ↓
CloudFront (CDN)
    ↓
Lambda@Edge (Routing)
    ↓ (Dynamic)        ↓ (Static)
Lambda Functions    S3 Bucket
(SSR, API Routes)   (HTML, CSS, JS, Images)
    ↓
Supabase (Database & Auth)

CI/CD Pipeline

We deploy automatically via GitHub Actions on every push to main:

# .github/workflows/deploy-web.yml
name: Deploy Web App

on:
  push:
    branches: [main]
    paths:
      - 'apps/web/**'
      - 'packages/**'
      - 'sst.config.ts'

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '20'

      - name: Install pnpm
        uses: pnpm/action-setup@v2
        with:
          version: 8

      - name: Install dependencies
        run: pnpm install

      - name: Build packages
        run: pnpm build:packages

      - name: Deploy to AWS
        run: npx sst deploy --stage production
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

Environment variables are managed via SST secrets:

# Set secrets for production stage
npx sst secret set NEXT_PUBLIC_SUPABASE_URL <url> --stage production
npx sst secret set SUPABASE_SERVICE_ROLE_KEY <key> --stage production
npx sst secret set AUTH_SECRET <secret> --stage production

Supabase Integration Patterns

We use Supabase for PostgreSQL database, authentication, and storage. Next.js 16's Server Components enable powerful server-side patterns.

Server-Side Client

Server components can directly query the database using a server-side Supabase client:

// apps/web/lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';

export async function createClient() {
  const cookieStore = await cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll: () => cookieStore.getAll(),
        setAll: (cookiesToSet) => {
          cookiesToSet.forEach(({ name, value, options }) =>
            cookieStore.set(name, value, options)
          );
        },
      },
    }
  );
}

This client:

  • Uses the cookies() API from Next.js to access request cookies
  • Handles authentication state via HTTP-only cookies (more secure than localStorage)
  • Works in server components, route handlers, and server actions

Example usage in a server component:

// apps/web/app/(dashboard)/diaries/page.tsx
import { createClient } from '@/lib/supabase/server';

export default async function DiariesPage() {
  const supabase = await createClient();

  const { data: diaries } = await supabase
    .from('diaries')
    .select('id, title, slug, created_at')
    .order('created_at', { ascending: false })
    .limit(20);

  return (
    <div>
      <h1>My Diaries</h1>
      {diaries.map((diary) => (
        <DiaryCard key={diary.id} diary={diary} />
      ))}
    </div>
  );
}

Client-Side Client

For client components that need real-time updates or browser-based auth, we use a client-side Supabase client:

// apps/web/lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr';

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  );
}

Example usage with real-time subscriptions:

// apps/web/components/diary/RealtimeDiaryList.tsx
'use client';

import { useEffect, useState } from 'react';
import { createClient } from '@/lib/supabase/client';

export function RealtimeDiaryList({ initialDiaries }) {
  const [diaries, setDiaries] = useState(initialDiaries);
  const supabase = createClient();

  useEffect(() => {
    const channel = supabase
      .channel('diaries')
      .on(
        'postgres_changes',
        {
          event: '*',
          schema: 'public',
          table: 'diaries',
        },
        (payload) => {
          // Update diaries when changes occur
          if (payload.eventType === 'INSERT') {
            setDiaries((prev) => [payload.new, ...prev]);
          }
        }
      )
      .subscribe();

    return () => {
      supabase.removeChannel(channel);
    };
  }, []);

  return (
    <div>
      {diaries.map((diary) => (
        <DiaryCard key={diary.id} diary={diary} />
      ))}
    </div>
  );
}

Server Actions with Drizzle ORM

For mutations, we use Next.js Server Actions combined with Drizzle ORM for type-safe database operations:

// apps/web/actions/diary-actions.ts
'use server';

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { db } from '@storyie/database';
import { diaries } from '@storyie/database/schema';
import { eq } from 'drizzle-orm';
import { auth } from '@/lib/auth';

export async function createDiary(formData: FormData) {
  const session = await auth();

  if (!session) {
    throw new Error('Unauthorized');
  }

  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  const [diary] = await db
    .insert(diaries)
    .values({
      userId: session.user.id,
      title,
      content: JSON.parse(content), // Lexical EditorState
      slug: generateSlug(title),
      visibility: 'private',
    })
    .returning();

  revalidatePath('/diaries');
  redirect(`/diaries/${diary.slug}`);
}

This server action:

  • Runs entirely on the server (never exposed to the client)
  • Uses Drizzle ORM for type-safe queries
  • Leverages Supabase Auth for user context
  • Automatically revalidates cached pages after mutation
  • Redirects to the new diary page

Performance Optimization

Our deployment achieves excellent performance through several optimizations:

Build Performance

Turbopack (Next.js 16's new bundler) dramatically speeds up local development:

// package.json
{
  "scripts": {
    "dev": "next dev --turbo"
  }
}

Our monorepo build (7 packages + web app) now completes in ~30 seconds, down from ~2 minutes with Webpack.

Production Build Optimization:

  • Tree shaking removes unused code
  • Code splitting generates smaller bundles per route
  • Image optimization via next/image (WebP, responsive sizes)

Runtime Performance

Our Lighthouse scores (from storyie.com in production):

  • Performance: 92 (mobile) / 98 (desktop)
  • Accessibility: 100
  • Best Practices: 100
  • SEO: 95

Key optimizations:

  • Server Components: 70% reduction in client-side JavaScript (compared to client-only React)
  • ISR: Sitemap and protected pages served from cache (sub-100ms response times)
  • Image Optimization: Automatic WebP conversion, lazy loading, responsive sizes via next/image
  • Font Optimization: Self-hosted fonts with next/font (no external requests)

Metadata API for SEO

Next.js 16's Metadata API enables dynamic SEO tags for each page. Here's how we generate metadata for public diary pages:

// apps/web/app/(public)/d/[slug]/page.tsx
import type { Metadata } from 'next';
import { getDiary } from '@/lib/db/queries/diaries';

export async function generateMetadata({ params }): Promise<Metadata> {
  const diary = await getDiary(params.slug);

  if (!diary) {
    return {
      title: 'Diary Not Found',
    };
  }

  return {
    title: diary.title,
    description: diary.excerpt || 'Read this diary entry on Storyie',
    openGraph: {
      title: diary.title,
      description: diary.excerpt || 'Read this diary entry on Storyie',
      type: 'article',
      publishedTime: diary.createdAt,
      authors: [diary.author.name],
      images: [
        {
          url: diary.coverImage || '/og-default.jpg',
          width: 1200,
          height: 630,
          alt: diary.title,
        },
      ],
    },
    twitter: {
      card: 'summary_large_image',
      title: diary.title,
      description: diary.excerpt || 'Read this diary entry on Storyie',
      images: [diary.coverImage || '/og-default.jpg'],
    },
  };
}

export default async function DiaryPage({ params }) {
  const diary = await getDiary(params.slug);
  // ... render diary
}

The generateMetadata function:

  • Runs on the server before the page renders
  • Generates Open Graph tags for social sharing (Facebook, LinkedIn)
  • Creates Twitter Card metadata for rich tweet previews
  • Sets canonical URLs automatically

Conclusion

Deploying Next.js 16 with SST to AWS has been transformative for Storyie's web application. The combination of App Router's file-based routing, Server Components' performance benefits, ISR's caching flexibility, and SST's zero-config deployment creates a powerful stack for building modern web applications.

Key Takeaways:

  • App Router: Route groups, nested layouts, and colocation simplify application structure
  • Server Components: Default to server rendering, use "use client" only when necessary
  • ISR: Balance freshness and performance with strategic revalidation periods
  • SST Deployment: One config file deploys to CloudFront, Lambda, and S3 automatically
  • Supabase Integration: Server-side clients for data fetching, client-side for real-time

Next Steps:

  • Explore our monorepo architecture to see how we share code between web and mobile
  • Learn about our cross-platform Lexical editor (coming soon)
  • Dive into our database schema design with Drizzle ORM (coming soon)

For questions or feedback, reach out to us on Twitter @StoryieApp.

---

Related Posts: