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 stylesRoute 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:
- Build Phase: Next.js builds your application (
.nextdirectory) - 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
- 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 productionSupabase 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:
- Building a Scalable TypeScript Monorepo with pnpm Workspaces
- Cross-Platform Lexical Editor (coming soon)
- Database Schema Design with Drizzle and Supabase (coming soon)