Startup-Ready Next.js Architecture: SEO, iOS Splash Screens & OG Images and PWA
📚 Quick Education: Core Building Blocks
After building 6 products with Next.js, this is how I handle the core pieces of every web app: SEO, OG Images, Splash Screens & Sitemap.
The Problem
Startups need speed and consistency—without debt. A typical Next.js app may have scattered metadata, OG images, and SEO across files.
- Cross-codebase: Reuse proven patterns to improve consistency and developer efficiency.
- A scaleable way to manage metadata, OG images, and SEO across your entire codebase.
app.config.ts
, can be extented for feature flags, A/B testing, and application data, pricing, faqs and more. - Traditional: Metadata on
page.tsx
, manual OG images, fragmented SEO configs, manual splash screens. - Reusability, a proven pattern to reuse across multiple projects for indie developers and startups.
The Solution
Centralize it. Three configs drive the entire surface:
_config/app.config.ts
_config/seo.config.ts
_config/splash-screens.config.ts
One change updates branding, SEO, OG images, and splash screens everywhere.
Why did you prefix the config files with an underscore?
Just so it shows up at the top of my editor. (personal preference)
The flow: _config/app.config.ts
→ _config/seo.config.ts
→ server/metadata.ts
. server/metadata.ts
returns the OG image URL and the splash‑screen configs, which the dynamic routes app/images/og/route.tsx
and app/images/splash/route.tsx
generate on demand.
_config/splash-screens.config.ts
defines iOS device specs. server/metadata.ts
reads it to populate appleWebApp.startupImage
, and the splash route generates the actual images automatically.
1. Core App Config (_config/app.config.ts
)
Single source of truth, consumed by _config/seo.config.ts
, server/metadata.ts
, app/manifest.ts
, app/images/splash/route.ts
, and app/images/og/route.tsx
.
Reuse it for
faqs
,features
,team
,pricing
. Layer in A/B copy when you need it.
Changing fonts? Update
fontVariant
inapp.config.ts
. OG and splash routes will pull any Google Font.
// _config/app.config.ts
export const app: SiteConfig = {
version: '0.1.0',
name: 'App Name',
abbreviation: 'R',
description: 'App Description',
fromEmail: '[email protected]',
font: 'JetBrains Mono',
fontVariant: '600',
logo: {
light: '',
dark: '',
},
icon: {
light: '/images/icon',
dark: '/images/icon?variant=dark',
},
colors: {
og: {
icon: '#335eea', // primary brand color
background: '#070707',
foreground: '#ffffff',
},
splash: {
background: '#070707',
foreground: '#ffffff',
},
pwa: {
background: '#070707',
themeColor: '#335eea', // primary brand color
},
},
gradients: {
primary: 'linear-gradient(90deg, #9b5de5, #ff6ec7);',
},
links: {
facebook: '#',
instagram: '#',
x: '#',
linkedin: '#',
},
}
export interface SiteConfig {
name: string
abbreviation: string
description: string
fromEmail: string
version: string
font: string
fontVariant: string
logo: {
light: string
dark: string
}
icon: {
light: string
dark: string
}
links: {
facebook: string
instagram: string
x: string
linkedin: string
}
colors: {
og: {
icon: string
background: string
foreground: string
}
splash: {
background: string
foreground: string
}
pwa: {
background: string
themeColor: string
}
}
gradients: {
primary: string
}
}
export default app
2. SEO Config (_config/seo.config.ts
)
Route-based type-safe SEO Config with fallbacks. This will then be used in server/metadata.ts
and app/sitemap.ts
powering your site's SEO and sitemap.
If you wish you could add additional properties -- say if you wanted different titles and descriptions on OG Images then what is used for SEO you can add additional properties and modify the
getMetadata
function to use them. (scalable 🚀)
// _config/seo.config.ts
import app from "@/_config/app.config";
interface SeoPageConfig {
title: string;
description: string;
changeFrequency: string;
priority: number;
}
const config = {
"/": {
title: `${app.name}`,
description: app.description,
changeFrequency: "monthly",
priority: 0.8,
},
"/about": {
title: `About Us | ${app.name}`,
description: `Learn about the ${app.name} team`,
changeFrequency: "monthly",
priority: 0.8,
},
"/terms": {
title: `Terms of Service | ${app.name}`,
description: `Read the Terms of Service for ${app.name}`,
changeFrequency: "monthly",
priority: 0.8,
},
"/auth/login": {
title: `Login | ${app.name}`,
description: `Sign in to your ${app.name} account`,
changeFrequency: "monthly",
priority: 0.8,
},
"/dashboard": {
title: `Dashboard | ${app.name}`,
description: `Your ${app.name} dashboard`,
changeFrequency: "monthly",
priority: 0.8,
},
// more routes!
} satisfies Record<string, SeoPageConfig>;
export type Paths = keyof typeof config;
export default config;
Technical Advantages:
- Type-safe configuration
- Automatic sitemap generation
- Consistent title patterns
- SEO priority management
- Change frequency optimization
3. iOS Splash Screen Utility (_config/splash-screens.config.ts
)
This is used to create the iOS splash screen configurations for the startupImage
property in the getMetadata
function.
Please note you will need to add this to layout.tsx
:
<meta name="apple-mobile-web-app-capable" content="yes"></meta>
This was removed in Next.js because Chrome was giving an error at the time. Trust me, you need this for your splash screens to show. Learn more
// _config/splash-screens.config.ts
// Resource Links
// https://www.ios-resolution.com/
// https://www.screensizes.app/
// https://developer.apple.com/design/human-interface-guidelines/layout
interface AppleWebAppSplashScreenConfig {
media: string; // CSS media query for device targeting
url: string; // URL for splash screen image
}
// Describes one distinct iOS splash/launch resolution class.
// Use numbers (not strings) so you can do math safely.
export type IOSSplashSpec = {
// Logical points (aka CSS px) for the device class
points: { width: number; height: number };
// Device pixel ratio
dpr: 2 | 3 | 4;
// Physical pixels for your splash asset (usually points * dpr)
px: { width: number; height: number };
// Optional metadata
label?: string; // e.g., "iPhone 16 Pro Max"
released?: string; // ISO date like "2024-09-09"
};
// Helper to build a spec from points + dpr
const spec = (wPts: number, hPts: number, dpr: 2 | 3 | 4, meta?: Omit<IOSSplashSpec, "points" | "dpr" | "px">): IOSSplashSpec => ({
points: { width: wPts, height: hPts },
dpr,
px: { width: wPts * dpr, height: hPts * dpr },
...meta,
});
// Newest → older, distinct classes only
export const iosSplashSchema: readonly IOSSplashSpec[] = [
// PHONES (modern)
spec(440, 956, 3, { label: "iPhone 16 Pro Max", released: "2024-09-09" }),
spec(402, 874, 3, { label: "iPhone 16 Pro", released: "2024-09-09" }),
spec(430, 932, 3, { label: '6.7" class (16 Plus / 15 Pro Max / 15 Plus / 14 Pro Max)', released: "2022-09-16" }),
// 6.1" split into two point grids (both @3x)
spec(393, 852, 3, { label: '6.1" @3x (16 / 15 Pro / 15 / 14 Pro)', released: "2022-09-16" }),
spec(390, 844, 3, { label: '6.1" @3x (16e / 14 / 13 / 12 / 12 Pro)', released: "2020-10-13" }),
spec(375, 812, 3, { label: '5.4" class (13 mini / 12 mini)', released: "2020-10-13" }),
spec(375, 667, 2, { label: '4.7" class (SE 2nd/3rd, 6/7/8)', released: "2014-09-19" }),
// Helpful legacy classes (often needed for asset matrices)
spec(414, 896, 3, { label: '6.5" class (11 Pro Max / XS Max)', released: "2018-09-21" }),
spec(414, 896, 2, { label: '6.1" LCD class (XR / 11)', released: "2018-10-26" }),
// TABLETS
spec(1032, 1376, 2, { label: 'iPad Pro 13" (M4, 2024), 2064×2752 px', released: "2024-05-15" }),
spec(834, 1210, 2, { label: 'iPad Pro 11" (M4, 2024), 1668×2420 px', released: "2024-05-15" }),
spec(1024, 1366, 2, { label: 'iPad 12.9" class (Air 13 / Pro 12.9 ≤ 2022), 2048×2732 px', released: "2015-11-11" }),
spec(820, 1180, 2, { label: "iPad Air 11 (M2) / iPad 10th, 1640×2360 px", released: "2022-10-26" }),
spec(834, 1194, 2, { label: "iPad Pro 11 (2018–2022), 1668×2388 px", released: "2018-11-07" }),
spec(810, 1080, 2, { label: "iPad 10.2 (7th–9th), 1620×2160 px", released: "2019-09-30" }),
spec(744, 1133, 2, { label: "iPad mini 8.3, 1488×2266 px", released: "2021-09-24" }),
] as const;
// Utility to emit the media query for <link rel="apple-touch-startup-image">
export const mediaQuery = (s: IOSSplashSpec, orientation: "portrait" | "landscape") =>
`(device-width: ${s.points.width}px) and (device-height: ${s.points.height}px) and (-webkit-device-pixel-ratio: ${s.dpr}) and (orientation: ${orientation})`;
/**
* Generates Apple Web App splash screen configurations for various iOS devices.
* Maps device dimensions and pixel ratios to appropriate image sizes for both orientations.
*
* @returns Array of splash screen configurations with media queries and image URLs
*/
export const generateAppleWebAppSplashScreenConfigs = (): AppleWebAppSplashScreenConfig[] => {
return iosSplashSchema.flatMap((spec: IOSSplashSpec) => {
const orientations: Array<"landscape" | "portrait"> = ["landscape", "portrait"];
return orientations.map((orientation) => {
const isPortraitMode = orientation === "portrait";
const splashImageWidth = isPortraitMode ? spec.px.width : spec.px.height;
const splashImageHeight = isPortraitMode ? spec.px.height : spec.px.width;
return {
media: `screen and ${mediaQuery(spec, orientation)}`,
url: `/images/splash?width=${splashImageWidth}&height=${splashImageHeight}`,
};
});
});
};
4. Metadata Generation (server/metadata.ts
)
Create a metadata abstraction layer that will be used in app/layout.tsx
, app/about/page.tsx
, etc.
// server/metadata.ts
import "server-only";
/**
* Metadata configuration for the application.
* Handles SEO, Open Graph, Twitter Cards, and Apple Web App settings.
*/
import { Metadata, Viewport } from "next";
import app from "@/_config/app.config";
import { generateAppleWebAppSplashScreenConfigs } from "@/_config/splash-screens.config";
// seo.ts
import seoConfig, { Paths } from "@/_config/seo.config";
/**
* Generates comprehensive metadata for the application including SEO tags,
* social media cards, and progressive web app configurations.
*
* @param routePath - The current route path for page-specific SEO
* @returns Next.js Metadata object
*/
export const getMetadata = (routePath: Paths = "/"): Metadata => {
// Get page-specific SEO configuration or fall back to app defaults
const { title: pageTitle, description: pageDescription } = seoConfig[routePath] || {
title: app.name,
description: app.description,
};
// Generate Open Graph image URL with encoded parameters
const encodeURIParam = encodeURIComponent;
const openGraphImageUrl = `/images/og/?title=${encodeURIParam(pageTitle)}&description=${encodeURIParam(pageDescription)}`;
return {
metadataBase: new URL(`https://${process.env.NEXT_PUBLIC_SITE_URL}`),
title: pageTitle,
description: pageDescription,
manifest: "/manifest.webmanifest",
robots: {
index: true, // Allow search engine indexing
follow: true, // Allow following page links
noarchive: true, // Prevent cached copies in search results
nosnippet: true, // Allow text snippets in search results
noimageindex: true, // Block image indexing for privacy
},
openGraph: {
title: pageTitle,
description: pageDescription,
url: process.env.NEXT_PUBLIC_SITE_URL,
siteName: app.name,
images: [
{
url: openGraphImageUrl,
width: 1200,
height: 630,
},
],
locale: "en_US",
type: "website",
},
twitter: {
card: "summary_large_image",
title: pageTitle,
description: pageDescription,
images: [openGraphImageUrl],
},
appleWebApp: {
capable: true,
title: app.name,
statusBarStyle: "default", // Options: default, black, black-translucent
startupImage: generateAppleWebAppSplashScreenConfigs(),
},
other: { "apple-mobile-web-app-capable": "yes" },
icons: {
icon: app.icon.light,
shortcut: app.icon.light,
apple: app.icon.light,
other: [
{
rel: "apple-touch-icon-precomposed",
url: app.icon.light,
},
],
},
};
};
/**
* Generates viewport configuration for responsive design and PWA optimization.
* Includes theme color definitions for light and dark mode support.
*
* @returns Viewport configuration object for Next.js
*/
// NODE_ENV="development" npm run start
export const getViewport = (): Viewport => {
return {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
// viewportFit: "cover",
themeColor: [
{ color: "#070707" },
// { media: "(prefers-color-scheme: light)", color: "#e9e9e9" },
// { media: "(prefers-color-scheme: dark)", color: "#070707" },
],
};
};
Metadata Abstraction Usage (layout.tsx & page.tsx, etc.)
Root Layout Setup app/layout.tsx
Adding to root layout is a fallback for all routes when you do not override the metadata on each page.
// app/layout.tsx
import { getMetadata, getViewport } from "@/server/metadata";
// Fallback for all routes uses app.config name and description
export const generateMetadata = () => {
return getMetadata("/");
};
export const generateViewport = () => getViewport();
Page-Specific Metadata app/about/page.tsx
// app/about/page.tsx
import { getMetadata } from "@/server/metadata";
export const generateMetadata = () => {
return getMetadata("/about");
};
Type-Safe Route Management:
The Paths
type from seo.config.ts
ensures you can only pass valid routes to getMetadata()
. TypeScript will catch invalid routes at compile time, preventing runtime errors and ensuring all your pages have proper SEO configuration.
5. Dynamic OG Image Route (app/images/og/route.tsx
)
Notice in the getMetadata
function we set the openGraph
and twitter
images. We need to create a route that will generate the OG image/social card image.
/images/og?title=Title&description=Desc
We load fonts from @/data/fonts.json
file. You will need to get the Google font response from the Google Fonts API & save it to data/fonts.json
file.
mkdir -p ./data && curl -s https://jhakim.com/data/fonts.json -o ./data/fonts.json
mkdir -p ./data && curl -s 'https://www.googleapis.com/webfonts/v1/webfonts?key=YOUR_API_KEY' -o ./data/fonts.json
// app/images/og/route.tsx
// Next.js imports
import { ImageResponse } from "next/og";
import { NextRequest } from "next/server";
// Config and data imports
import app from "@/_config/app.config";
import fonts from "@/data/fonts.json";
export const runtime = "edge";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const title = searchParams.get("title") || app.name;
console.log(title);
const description = searchParams.get("description") || app.description;
// Headlines font
const headlinesFont = fonts.items.find((item) => item.family == app.font) as {
family: string;
files: { [key: string]: string };
};
const headlinesFontDownload = fetch(headlinesFont.files[app.fontVariant], {
cache: "force-cache",
}).then((res) => res.arrayBuffer());
const headlinesFontData = await headlinesFontDownload;
try {
const imageElement = (
<div
style={{
display: "flex",
flexDirection: "column",
position: "relative",
width: "100%",
height: "100%",
justifyContent: "center",
padding: "20px",
background: `${app.colors.og.background}`,
}}
>
<div
style={{
background: app.colors.og.icon,
top: "20",
left: "20",
position: "absolute",
borderRadius: "10px",
width: "70px",
height: "70px",
objectFit: "contain",
fontSize: "50",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: app.colors.og.foreground,
}}
>
{app.abbreviation}
</div>
<div
style={{
fontSize: "75",
fontWeight: "bold",
color: app.colors.og.foreground,
}}
>
{title}
</div>
<div
style={{
fontSize: "24",
fontWeight: "bold",
color: app.colors.og.foreground,
}}
>
{description}
</div>
</div>
);
return new ImageResponse(imageElement, {
headers: {},
width: 1200,
height: 630,
fonts: [
{
name: headlinesFont.family,
data: headlinesFontData,
style: "normal",
},
],
});
} catch (e) {
console.error(e);
return new Response((e as Error).message || "Internal Server Error", {
status: 500,
});
}
}
Production Benefits
Development Speed:
- Single configuration file drives entire site
- No manual image creation
- Automatic SEO optimization
- Type-safe configuration
6. Dynamic Splash Screen Route (app/images/splash/route.tsx
)
/images/splash?width=1206&height=2622
Notice in the getMetadata
function we set the appleWebApp.startupImage
. We need to create a route that will generate the splash screen image where the width
and height
dimensions are passed in as query parameters to change the image size for iOS devices.
This is where the utility funtion
generateAppleWebAppSplashScreenConfigs
comes in. It created the urls that sets theappleWebApp.startupImage
property in thegetMetadata
function.
// app/images/splash/route.tsx
// Next.js imports
import { ImageResponse } from "next/og";
import { NextRequest } from "next/server";
// Config and data imports
import app from "@/_config/app.config";
import fonts from "@/data/fonts.json";
export const runtime = "edge";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const height = searchParams.get("height") || "1080";
const width = searchParams.get("width") || "1920";
// Headlines font
const headlinesFont = fonts.items.find((item) => item.family == app.font) as {
family: string;
files: { [key: string]: string };
};
const headlinesFontDownload = fetch(headlinesFont.files[app.fontVariant], {
cache: "force-cache",
}).then((res) => res.arrayBuffer());
const headlinesFontData = await headlinesFontDownload;
try {
const imageElement = (
<div
style={{
display: "flex",
color: "white",
background: `${app.colors.splash.background}`,
width: "100%",
height: "100%",
alignItems: "center",
justifyContent: "center",
}}
>
<div
style={{
fontSize: "120px",
fontWeight: "bold",
color: app.colors.splash.foreground,
}}
>
{app.name}
</div>
</div>
);
return new ImageResponse(imageElement, {
headers: {},
width: parseInt(width),
height: parseInt(height),
fonts: [
{
name: headlinesFont.family,
data: headlinesFontData,
style: "normal",
},
],
});
} catch (e) {
console.error(e);
return new Response((e as Error).message || "Internal Server Error", {
status: 500,
});
}
}
Production Benefits:
- Covers all iOS devices in the last 5 years
- Automatic portrait/landscape handling
- Type-safe device specifications
- Future-proof device addition
7. App Manifest Generation (app/manifest.ts
)
Dynamic app manifest based on app.config.ts
. Here is where the _config/app.config.ts
comes in very handy. This is like a "set it and forget it" file.
// app/manifest.ts
import app from '@/_config/app.config'
import type { MetadataRoute } from 'next'
export default function manifest(): MetadataRoute.Manifest {
return {
id: '/',
name: app.name,
short_name: app.name,
description: app.description,
start_url: '/account',
display: 'standalone', // fullscreen | standalone | minimal-ui | browser
background_color: app.colors.pwa.background,
theme_color: app.colors.pwa.themeColor,
icons: [
{
src: app.icon.light,
sizes: '512x512',
type: 'image/png',
},
],
orientation: 'portrait',
lang: 'en-US',
}
}
8. Sitemap Generation (app/sitemap.ts
)
Dynamic sitemap generation from seo.config.ts
. Your sitemap will be updated as you update the single source of truth for your routes. _config/seo.config.ts
will create the sitemap and supply your app with the proper metadata for your routes.
// app/sitemap.ts
import type { MetadataRoute } from "next";
import seoConfig from "@/_config/seo.config";
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://localhost:3000";
export default function sitemap(): MetadataRoute.Sitemap {
return Object.entries(seoConfig).map(([route, config]) => ({
url: `${BASE_URL}${route}`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.8,
}));
}
Scalability:
- Easy white-labeling
- Consistent branding across all pages
- Progressive web app ready
- Edge runtime performance
Maintenance:
- One file change updates everything
- Automatic device support
- Future-proof architecture
- Zero technical debt
Key Takeaways
✅ Centralized Configuration - _config/app.config.ts
drives everything
✅ Dynamic Image Generation - Vercel OG handles OG images and splash screens
✅ Type-Safe Architecture - Full TypeScript support
✅ Production-Ready - Covers all iOS devices and social platforms
✅ Zero Maintenance - Add routes, get SEO automatically
Result: Deploy faster, maintain easier, scale without breaking.
Like this approach? Run this in your Next.js project to get started.
# Create necessary directories
mkdir -p _config
mkdir -p server
mkdir -p app/images/og
mkdir -p app/images/splash
mkdir -p data
# Download config files
curl -s https://jhakim.com/blogs/data/app.config.ts -o _config/app.config.ts
curl -s https://jhakim.com/blogs/data/seo.config.ts -o _config/seo.config.ts
curl -s https://jhakim.com/blogs/data/splash-screens.config.ts -o _config/splash-screens.config.ts
# Download server files
curl -s https://jhakim.com/blogs/data/metadata.ts -o server/metadata.ts
# Download app route files
curl -s https://jhakim.com/blogs/data/og-route.tsx -o app/images/og/route.tsx
curl -s https://jhakim.com/blogs/data/splash-route.tsx -o app/images/splash/route.tsx
# Download app manifest and sitemap
curl -s https://jhakim.com/blogs/data/manifest.ts -o app/manifest.ts
curl -s https://jhakim.com/blogs/data/sitemap.ts -o app/sitemap.ts
# Download fonts data
curl -s https://jhakim.com/blogs/data/fonts.json -o data/fonts.json
For Next.js projects using src
directory:
# Create necessary directories
mkdir -p src/_config
mkdir -p src/server
mkdir -p src/app/images/og
mkdir -p src/app/images/splash
mkdir -p src/data
# Download config files
curl -s https://jhakim.com/blogs/data/app.config.ts -o src/_config/app.config.ts
curl -s https://jhakim.com/blogs/data/seo.config.ts -o src/_config/seo.config.ts
curl -s https://jhakim.com/blogs/data/splash-screens.config.ts -o src/_config/splash-screens.config.ts
# Download server files
curl -s https://jhakim.com/blogs/data/metadata.ts -o src/server/metadata.ts
# Download app route files
curl -s https://jhakim.com/blogs/data/og-route.tsx -o src/app/images/og/route.tsx
curl -s https://jhakim.com/blogs/data/splash-route.tsx -o src/app/images/splash/route.tsx
# Download app manifest and sitemap
curl -s https://jhakim.com/blogs/data/manifest.ts -o src/app/manifest.ts
curl -s https://jhakim.com/blogs/data/sitemap.ts -o src/app/sitemap.ts
# Download fonts data
curl -s https://jhakim.com/blogs/data/fonts.json -o src/data/fonts.json
Final thoughts: tailwind.config.ts
was cool. At one time I used that for colors. Now I just use app.config.ts
for colors. But being that no longer required with the v4 update I feel this is the best approach for any project.
Love to connect! Follow me on X