Startup-Ready Next.js Architecture: SEO, iOS Splash Screens & OG Images and PWA

📚 Quick Education: Core Building Blocks

Metadata: What shows in search results, browser tabs, and social previews
OG Images: Images that appear when URLs are shared on social platforms and messaging previews
PWA & Splash Screens: "Add to Home Screen" apps with native-like loading screens
Sitemap: Tells search engines which pages exist and how often they change

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.

Image of getMetadata abstraction


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.tsserver/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 in app.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:

Type-safe metadata configuration showing TypeScript autocomplete for route paths

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 the appleWebApp.startupImage property in the getMetadata 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

Resources