
A behind-the-scenes breakdown of this portfolio: Next.js 16.1.3 App Router, next-intl (he default + /en), Sanity CMS, Clerk auth for AI chat, Resend email, and full SEO with sitemap/robots/JSON-LD β optimized for static builds.
This portfolio is built as a static, SEO-first site with carefully-scoped dynamic functionality (AI chat + contact form). The goal is simple: fast UX, predictable rendering, and content managed from Sanity.
- Next.js 16.1.3 App Router + React 19
- Static generation (SSG) for all public pages
- Bilingual routing (he default without prefix, /en for English)
- CMS-driven content via Sanity (skills/services/projects/FAQ/blog)
- Clerk authentication only for the AI chat experience
- CI/CD + Preview deploys on Vercel
- Full SEO: metadata, OpenGraph/Twitter, JSON-LD, sitemap, robots
1) App Router structure keeps the site layout consistent, while each route segment can generate metadata.
2) next-intl handles locale routing β Hebrew as default without a prefix, English under /en.
3) Sanity is the source of truth; the UI renders whatβs in the CMS (no βdemo dataβ in code).
4) Clerk is used for auth and UI localization; AI chat is protected behind sign-in.
5) Performance: heavy UI widgets (chatkit/map/charts/animations) are loaded only when needed.
1import type { NextConfig } from 'next';
2import createNextIntlPlugin from 'next-intl/plugin';
3
4const withNextIntl = createNextIntlPlugin('./i18n/request.ts');
5
6const nextConfig: NextConfig = {
7 // Output mode for Vercel deployment
8 output: 'standalone',
9
10 // Enable experimental features
11 experimental: {
12 // Use Turbopack for faster dev builds
13 turbo: {
14 rules: {
15 '*.svg': {
16 loaders: ['@svgr/webpack'],
17 as: '*.js',
18 },
19 },
20 },
21 },
22
23 // Image optimization
24 images: {
25 remotePatterns: [
26 {
27 protocol: 'https',
28 hostname: 'cdn.sanity.io',
29 pathname: '/images/**',
30 },
31 ],
32 formats: ['image/avif', 'image/webp'],
33 },
34
35 // Security headers
36 async headers() {
37 return [
38 {
39 source: '/:path*',
40 headers: [
41 {
42 key: 'X-DNS-Prefetch-Control',
43 value: 'on',
44 },
45 {
46 key: 'Strict-Transport-Security',
47 value: 'max-age=63072000; includeSubDomains',
48 },
49 {
50 key: 'X-Frame-Options',
51 value: 'SAMEORIGIN',
52 },
53 {
54 key: 'X-Content-Type-Options',
55 value: 'nosniff',
56 },
57 ],
58 },
59 ];
60 },
61};
62
63export default withNextIntl(nextConfig);1import { createClient, type QueryParams } from 'next-sanity';
2import { cache } from 'react';
3
4const client = createClient({
5 projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
6 dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
7 apiVersion: '2025-01-15',
8 useCdn: false, // Disable CDN for build-time queries
9 perspective: 'published',
10 token: process.env.SANITY_SERVER_API_TOKEN, // Use server token for drafts
11 stega: {
12 enabled: false, // Disable visual editing in production
13 },
14});
15
16// β
Static fetch with React cache for deduplication
17export const sanityFetchStatic = cache(
18 async <QueryResponse>({
19 query,
20 params = {},
21 tags = [],
22 }: {
23 query: string;
24 params?: QueryParams;
25 tags?: string[];
26 }): Promise<QueryResponse> => {
27 return client.fetch<QueryResponse>(query, params, {
28 cache: 'force-cache', // Cache at build time
29 next: {
30 tags, // For ISR revalidation
31 },
32 });
33 }
34);
35
36// β
Usage in page components
37export async function generateStaticParams() {
38 const posts = await sanityFetchStatic<{ slug: string; language: string }[]>({
39 query: `*[_type == "blog" && defined(slug.current)]{
40 "slug": slug.current,
41 language
42 }`,
43 tags: ['blog'],
44 });
45
46 return posts.map((post) => ({
47 locale: post.language === 'he' ? 'he' : 'en',
48 slug: post.slug,
49 }));
50}1import createMiddleware from 'next-intl/middleware';
2import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
3import { NextResponse } from 'next/server';
4import type { NextRequest } from 'next/server';
5import { defaultLocale, locales } from './i18n/config';
6
7// Define public routes (no auth required)
8const isPublicRoute = createRouteMatcher([
9 '/',
10 '/blog(.*)',
11 '/projects(.*)',
12 '/:locale',
13 '/:locale/blog(.*)',
14 '/:locale/projects(.*)',
15 '/api/contact',
16 '/studio(.*)', // Sanity Studio has its own auth
17]);
18
19// Create i18n middleware
20const intlMiddleware = createMiddleware({
21 locales,
22 defaultLocale,
23 localePrefix: 'as-needed',
24});
25
26export default clerkMiddleware((auth, req: NextRequest) => {
27 // Skip Clerk auth for public routes
28 if (!isPublicRoute(req)) {
29 auth().protect();
30 }
31
32 // Apply i18n routing
33 return intlMiddleware(req);
34});
35
36export const config = {
37 // Match all routes except static files and API routes
38 matcher: [
39 '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
40 '/(api|trpc)(.*)',
41 ],
42};1import { notFound } from 'next/navigation';
2import { getTranslations, setRequestLocale } from 'next-intl/server';
3import { sanityFetchStatic } from '@/sanity/lib/staticFetch';
4import { defineQuery } from 'next-sanity';
5
6// β
Force static generation
7export const dynamic = 'force-static';
8export const revalidate = false;
9
10const BLOG_POST_QUERY = defineQuery(`
11 *[_type == "blog" && slug.current == $slug &&
12 ((language == $language) || ($language == "en" && !defined(language)))][0]{
13 title,
14 content,
15 publishedAt,
16 _updatedAt
17 }
18`);
19
20const BLOG_SLUGS_QUERY = defineQuery(`
21 *[_type == "blog" && defined(slug.current)]{
22 "slug": slug.current,
23 language
24 }
25`);
26
27// β
Generate all possible paths at build time
28export async function generateStaticParams(): Promise<
29 Array<{ locale: string; slug: string }>
30> {
31 const posts = await sanityFetchStatic({
32 query: BLOG_SLUGS_QUERY,
33 tags: ['blog'],
34 });
35
36 return posts
37 .filter((post: any) => post.slug)
38 .map((post: any) => ({
39 locale: post.language === 'he' ? 'he' : 'en',
40 slug: post.slug,
41 }));
42}
43
44export default async function BlogPostPage({
45 params,
46}: {
47 params: Promise<{ locale: string; slug: string }>;
48}) {
49 const { locale, slug } = await params;
50 setRequestLocale(locale);
51
52 const t = await getTranslations({ locale, namespace: 'blog' });
53
54 // β
Fetch at build time
55 const post = await sanityFetchStatic({
56 query: BLOG_POST_QUERY,
57 params: { slug, language: locale },
58 tags: [`blog:${slug}`],
59 });
60
61 if (!post) {
62 notFound();
63 }
64
65 return <article>{/* Render post */}</article>;
66}1import type { Metadata } from 'next';
2import { getTranslations } from 'next-intl/server';
3
4export async function generateMetadata({
5 params,
6}: {
7 params: Promise<{ locale: string; slug: string }>;
8}): Promise<Metadata> {
9 const { locale, slug } = await params;
10 const t = await getTranslations({ locale, namespace: 'meta' });
11
12 const post = await sanityFetchStatic({
13 query: BLOG_POST_QUERY,
14 params: { slug, language: locale },
15 });
16
17 if (!post) {
18 return {};
19 }
20
21 // β
SEO-optimized title (max 60 chars)
22 const title = truncateTitle(post.seo?.metaTitle || post.title, 60);
23
24 // β
SEO-optimized description (max 160 chars)
25 const description = truncateDescription(
26 post.seo?.metaDescription || post.excerpt,
27 160
28 );
29
30 // β
OpenGraph image
31 const ogImage = post.featuredImage
32 ? urlFor(post.featuredImage).width(1200).height(630).url()
33 : absoluteUrl('/og-image.jpg');
34
35 return {
36 title,
37 description,
38 keywords: post.seo?.keywords?.join(', '),
39 alternates: {
40 canonical: absoluteUrl(`/${locale}/blog/${slug}`),
41 languages: {
42 en: `/en/blog/${slug}`,
43 he: `/he/blog/${slug}`,
44 },
45 },
46 openGraph: {
47 type: 'article',
48 title,
49 description,
50 images: [{ url: ogImage, width: 1200, height: 630, alt: title }],
51 publishedTime: post.publishedAt,
52 modifiedTime: post._updatedAt,
53 },
54 twitter: {
55 card: 'summary_large_image',
56 title,
57 description,
58 images: [ogImage],
59 },
60 robots: {
61 index: true,
62 follow: true,
63 googleBot: {
64 index: true,
65 follow: true,
66 'max-image-preview': 'large',
67 },
68 },
69 };
70}1name: CI/CD Pipeline
2
3on:
4 push:
5 branches: [main]
6 pull_request:
7 branches: [main]
8
9env:
10 NODE_VERSION: '20'
11
12jobs:
13 lint:
14 runs-on: ubuntu-latest
15 steps:
16 - uses: actions/checkout@v4
17
18 - uses: pnpm/action-setup@v2
19 with:
20 version: 8
21
22 - uses: actions/setup-node@v4
23 with:
24 node-version: ${{ env.NODE_VERSION }}
25 cache: 'pnpm'
26
27 - name: Install dependencies
28 run: pnpm install --frozen-lockfile
29
30 - name: Run linter
31 run: pnpm lint
32
33 - name: Type check
34 run: pnpm type-check
35
36 build:
37 runs-on: ubuntu-latest
38 needs: [lint]
39 steps:
40 - uses: actions/checkout@v4
41
42 - uses: pnpm/action-setup@v2
43 with:
44 version: 8
45
46 - uses: actions/setup-node@v4
47 with:
48 node-version: ${{ env.NODE_VERSION }}
49 cache: 'pnpm'
50
51 - name: Install dependencies
52 run: pnpm install --frozen-lockfile
53
54 - name: Build application
55 env:
56 NEXT_PUBLIC_SANITY_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_SANITY_PROJECT_ID }}
57 NEXT_PUBLIC_SANITY_DATASET: ${{ secrets.NEXT_PUBLIC_SANITY_DATASET }}
58 SANITY_SERVER_API_TOKEN: ${{ secrets.SANITY_SERVER_API_TOKEN }}
59 run: pnpm build
60
61 - name: Upload build artifacts
62 uses: actions/upload-artifact@v4
63 with:
64 name: build
65 path: .next
66
67 deploy:
68 runs-on: ubuntu-latest
69 needs: [build]
70 if: github.ref == 'refs/heads/main'
71 steps:
72 - uses: actions/checkout@v4
73
74 - name: Deploy to Vercel
75 uses: amondnet/vercel-action@v25
76 with:
77 vercel-token: ${{ secrets.VERCEL_TOKEN }}
78 vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
79 vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
80 vercel-args: '--prod'
81 working-directory: ./Want a consultant? Click here to schedule a call.
Schedule a call
Patterns I use to keep a Next.js App Router project maintainable: strict TypeScript, CMS-driven content with Sanity, stable GROQ queries, and practical trade-offs when the CMS evolves faster than the code.

How I ship AI chat features safely: Clerk-gated access, OpenAI ChatKit sessions, prompt/response guardrails, and performance-minded client loading in a Next.js 16 App Router codebase.

A practical playbook for improving day-to-day delivery: connect Slack, Jira, Monday.com, and GitHub using MCP-based AI agents for notifications, triage, status sync, code review feedback loops, and automated follow-ups.
Want to see how AI chat can build you automation workflows?
Try AI Dashboard βWherever you are in the world, let's work together on your next project.
Israel
Prefer to talk directly? Schedule a call and we can discuss your project live.
Schedule a call