
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.
Tel Aviv, Israel
Prefer to talk directly? Schedule a call and we can discuss your project live.
Schedule a call