Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
40 changes: 40 additions & 0 deletions apps/web/public/app-store-assets/adrien-target-users-refusal.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions apps/web/src/components/Footer.astro
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ const navigation: Record<string, NavigationItem[]> = {
name: 'App store publishing costs',
href: getRelativeLocaleUrl(Astro.locals.locale, 'cost/cost-to-publish-app-on-app-store'),
},
{
name: 'App store refusal stories',
href: getRelativeLocaleUrl(Astro.locals.locale, 'app-store-refusal-horror-story'),
},
{ name: m.blog({}, { locale: Astro.locals.locale }), href: getRelativeLocaleUrl(Astro.locals.locale, 'blog') },
{
name: () => m.all_systems_normal({}, { locale: Astro.locals.locale }),
Expand Down
153 changes: 153 additions & 0 deletions apps/web/src/data/appStoreRefusalStories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
export interface RefusalStoryImage {
src: string
alt: string
}

export interface RefusalStory {
id: string
title: string
sharedBy?: string
platform: 'Apple App Store' | 'Google Play'
severity: 'Painful' | 'Absurd' | 'Expensive' | 'Launch blocker'
appType: string
delay: string
outcome: string
quote: string
story: string
images: RefusalStoryImage[]
}

export const refusalStories: RefusalStory[] = [
{
id: 'adrien-target-users-unclear',
title: 'The app with users Apple could not identify',
sharedBy: 'Adrien',
platform: 'Apple App Store',
severity: 'Absurd',
appType: 'Version 1.0 iPad app',
delay: 'Review paused on May 29, 2026',
outcome: 'Apple asked for a target-user explanation before continuing review.',
quote: 'Who will be the users of this app?',
story:
'Adrien submitted version 1.0 and Apple stopped the review under Guideline 2.1, Information Needed. There was no crash report, no broken feature, and no requested binary fix in the message. The only blocker was that Apple wanted a detailed answer explaining who the app was for before review could continue.',
images: [
{
src: '/app-store-assets/adrien-target-users-refusal.svg',
alt: 'Apple App Review message asking who will be the users of Adrien app',
},
],
},
{
id: 'adrien-saturated-category-spam',
title: 'The app Apple decided was not different enough',
sharedBy: 'Adrien',
platform: 'Apple App Store',
severity: 'Painful',
appType: 'Entertainment sound app',
delay: 'Rejected on May 15, 2026',
outcome: 'Apple rejected it under Guideline 4.3(b), saying the app duplicated a saturated category.',
quote: 'There are already enough of these apps on the App Store.',
story:
'Adrien got a Design - Spam refusal because Apple did not see enough distinct value compared with similar apps. The review said the app was primarily a fart or burp app, and even if it had features that distinguished it, that functionality was prominent enough for Apple to treat the whole app as duplicate content in a saturated category.',
images: [
{
src: '/app-store-assets/adrien-saturated-category-refusal.svg',
alt: 'Apple App Review message saying Adrien app did not differ enough from similar apps',
},
],
},
{
id: 'metadata-loop-before-launch',
title: 'The metadata loop that ate launch week',
platform: 'Apple App Store',
severity: 'Launch blocker',
appType: 'Consumer productivity app',
delay: '9 days',
outcome: 'Approved after rewriting screenshots, subtitles, and review notes without a binary change.',
quote: 'The build was fine. The rejection kept moving from the app to the words around the app.',
story:
'The team shipped a clean build, then spent more than a week cycling through metadata objections. Each resubmission answered the previous note, but the next reply focused on another phrase, screenshot, or explanation. No code was changed. The launch calendar, press window, and paid acquisition plan were all held hostage by review copy.',
images: [
{
src: '/app-review-guide.webp',
alt: 'App review guide screen used to represent an Apple App Store rejection workflow',
},
{
src: '/apple_appstore.webp',
alt: 'App Store publishing interface used to represent a delayed Apple review',
},
],
},
{
id: 'compliance-question-after-approval',
title: 'Approved, then blocked by one more compliance question',
platform: 'Apple App Store',
severity: 'Absurd',
appType: 'B2B dashboard companion app',
delay: '4 days',
outcome: 'Released after answering export compliance again and waiting for the next review pass.',
quote: 'The approval email landed before the blocker did.',
story:
'The build reached approval, but release was still blocked by a compliance prompt the team thought had already been answered. The release owner had to stop rollout, gather legal wording, update the App Store Connect response, and wait again. Customers saw the announcement before the app was actually available.',
images: [
{
src: '/native-build-assets/appstore-connect-manage-build.webp',
alt: 'App Store Connect build management screen representing a post-approval release blocker',
},
{
src: '/native-build-assets/appstore-connect-manage-build-compliance.webp',
alt: 'App Store Connect compliance screen representing an extra compliance review step',
},
],
},
{
id: 'permission-policy-time-sink',
title: 'The permission policy time sink',
platform: 'Google Play',
severity: 'Expensive',
appType: 'Field operations app',
delay: '13 days',
outcome: 'Approved after removing a permission, recording a new demo, and rewriting the store declaration.',
quote: 'The app needed the permission for one screen, but the review treated it like the whole product.',
story:
'A narrow Android permission triggered a broad policy review. The team documented the feature, added reviewer instructions, recorded a demo path, and still had to remove the permission from the main release to unblock customers. The final build shipped with a degraded workflow while the team prepared a cleaner permission split.',
images: [
{
src: '/native-build-assets/google-play-console-releases-button.webp',
alt: 'Google Play Console releases screen representing a blocked release',
},
{
src: '/native-build-assets/google-play-console-select-apk-file.webp',
alt: 'Google Play Console artifact upload screen representing repeated Android submissions',
},
{
src: '/native-build-assets/google-play-console-save-and-publish.webp',
alt: 'Google Play Console save and publish screen representing a delayed publication',
},
],
},
{
id: 'production-hotfix-in-review',
title: 'The hotfix that waited behind a policy queue',
platform: 'Google Play',
severity: 'Painful',
appType: 'Ecommerce app',
delay: '6 days',
outcome: 'The native store fix arrived after the team had already mitigated the incident elsewhere.',
quote: 'The broken checkout was urgent for users, but not urgent for the review queue.',
story:
'A checkout bug needed a fast mobile fix, but the store release entered review at the worst possible time. Support tickets climbed while the team watched the same pending status. They eventually mitigated the issue server-side, then watched the binary approval arrive after the emergency had already burned through the weekend.',
images: [
{
src: '/native-build-assets/google-play-console-confirm-publication.webp',
alt: 'Google Play Console confirmation screen representing a delayed hotfix publication',
},
{
src: '/app_demo.webp',
alt: 'Mobile app interface representing a production hotfix waiting for store review',
},
],
},
]

export const storySubmissionFilePath = 'apps/web/src/data/appStoreRefusalStories.ts'
5 changes: 3 additions & 2 deletions apps/web/src/layouts/Layout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import globalStylesHref from '../css/global.css?url'

const content = Astro.props.content ?? {}
const disableThirdPartyScripts = Astro.props.disableThirdPartyScripts ?? false
const hideChrome = Astro.props.hideChrome ?? false

const isLocalhost = Astro.url.origin.includes('localhost:')
const enableThirdPartyScripts = !isLocalhost && !disableThirdPartyScripts
Expand All @@ -31,11 +32,11 @@ const enableThirdPartyScripts = !isLocalhost && !disableThirdPartyScripts
Skip to main content
</a>
<div class="min-h-dvh overflow-x-hidden">
<Header />
{!hideChrome && <Header />}
<main id="main-content">
<slot />
</main>
<Footer />
{!hideChrome && <Footer />}
</div>
{
enableThirdPartyScripts && (
Expand Down
Loading
Loading