Introduction
Installing an app is friction. Users scanning a QR code at a cafe want to order coffee, not download a 150MB app, create an account, and verify their email. By the time the app installs, they have ordered at the counter.
App Clips (iOS) and Instant Apps (Android) solve this by delivering focused app experiences that launch in seconds without installation. Scan a code, tap a link, and the experience starts immediately. Users who find value can then install the full app.
This guide covers building, deploying, and optimising these lightweight experiences to convert casual interactions into engaged users.
When to Use App Clips and Instant Apps
Ideal Use Cases
GOOD FIT | POOR FIT
----------------------------------|----------------------------------
Payment at point of sale | Social networking
Restaurant ordering | Games with large assets
Bike/scooter rental | Media streaming
Event check-in | Productivity suites
Product demos | Apps requiring extensive setup
Parking meters | Content-heavy applications
Loyalty card scanning | Apps with complex onboarding
Business Impact Metrics
Typical improvements seen with App Clips/Instant Apps:
- 50-70% reduction in drop-off vs. app install flow
- 3-5x higher conversion for spontaneous use cases
- 30-40% of App Clip users later install full app
- 20-30% increase in first-time transactions
iOS App Clip
s Implementation
Project Setup
// 1. Add App Clip target in Xcode
// File > New > Target > App Clip
// 2. Configure Info.plist for App Clip
// NSAppClip dictionary with experience URLs
// 3. Share code between main app and App Clip using shared frameworks
App Clip Target Configuration
// AppClipApp.swift
import SwiftUI
import AppClip
@main
struct CoffeeOrderAppClip: App {
@StateObject private var orderManager = OrderManager()
@State private var invocationURL: URL?
var body: some Scene {
WindowGroup {
ContentView(orderManager: orderManager)
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
handleInvocation(activity)
}
}
}
private func handleInvocation(_ activity: NSUserActivity) {
guard let url = activity.webpageURL else { return }
invocationURL = url
// Parse location from URL
if let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let locationId = components.queryItems?.first(where: { $0.name == "location" })?.value {
orderManager.setLocation(locationId)
}
}
}
Focused User Experience
// App Clip must be under 15MB and focused on a single task
struct ContentView: View {
@ObservedObject var orderManager: OrderManager
@State private var showingFullAppBanner = false
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Location header
LocationHeader(location: orderManager.currentLocation)
// Menu (streamlined for App Clip)
MenuView(orderManager: orderManager)
// Cart summary
if !orderManager.cart.isEmpty {
CartSummary(orderManager: orderManager)
}
}
.navigationTitle("Order Coffee")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Get Full App") {
showingFullAppBanner = true
}
}
}
.appStoreOverlay(isPresented: $showingFullAppBanner) {
SKOverlay.AppClipConfiguration(position: .bottom)
}
}
}
}
// Streamlined menu - no browsing, quick selection
struct MenuView: View {
@ObservedObject var orderManager: OrderManager
var body: some View {
ScrollView {
LazyVStack(spacing: 16) {
// Popular items first
Section("Popular at \(orderManager.currentLocation?.name ?? "this location")") {
ForEach(orderManager.popularItems) { item in
QuickOrderItem(item: item, onAdd: {
orderManager.addToCart(item)
})
}
}
// Quick categories
Section("Menu") {
ForEach(orderManager.categories) { category in
CategoryRow(category: category, orderManager: orderManager)
}
}
}
.padding()
}
}
}
App Clip Card Configuration
// Configure App Clip experiences in App Store Connect
// Each experience needs:
// - URL pattern (e.g., https://coffee.app/order?location=*)
// - Image (1800x1200 pixels)
// - Title, subtitle, call-to-action
// Advanced App Clip Card (dynamic metadata)
struct AppClipMetadata {
// Server endpoint returns metadata based on location
static func fetchMetadata(for url: URL) async -> AppClipCardInfo {
let locationId = extractLocationId(from: url)
// Fetch location-specific info
let location = try await api.getLocation(locationId)
return AppClipCardInfo(
title: "Order at \(location.name)",
subtitle: location.address,
callToAction: "Order Now",
headerImage: location.headerImageURL
)
}
}
Location Verification
import CoreLocation
import AppClip
class LocationVerifier: NSObject, ObservableObject {
@Published var verificationStatus: VerificationStatus = .pending
private let locationManager = CLLocationManager()
enum VerificationStatus {
case pending
case verified
case failed(String)
}
func verifyLocation(for activity: NSUserActivity) {
guard let payload = activity.appClipActivationPayload else {
verificationStatus = .failed("No activation payload")
return
}
// Get expected location from URL
guard let url = activity.webpageURL,
let expectedRegion = extractRegion(from: url) else {
verificationStatus = .failed("Invalid URL")
return
}
// Verify user is at expected location
payload.confirmAcquired(in: expectedRegion) { inRegion, error in
DispatchQueue.main.async {
if let error = error {
self.verificationStatus = .failed(error.localizedDescription)
} else if inRegion {
self.verificationStatus = .verified
} else {
self.verificationStatus = .failed("You don't appear to be at this location")
}
}
}
}
private func extractRegion(from url: URL) -> CLRegion? {
// Parse coordinates from URL or fetch from server
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let lat = components.queryItems?.first(where: { $0.name == "lat" })?.value,
let lng = components.queryItems?.first(where: { $0.name == "lng" })?.value,
let latitude = Double(lat),
let longitude = Double(lng) else {
return nil
}
let center = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
return CLCircularRegion(center: center, radius: 100, identifier: "location")
}
}
Sign in with Apple Integration
// App Clips can use Sign in with Apple for frictionless authentication
import AuthenticationServices
struct SignInView: View {
@Environment(\.colorScheme) var colorScheme
@ObservedObject var authManager: AuthManager
var body: some View {
VStack(spacing: 20) {
Text("Sign in to save your order")
.font(.headline)
SignInWithAppleButton(
onRequest: { request in
request.requestedScopes = [.email]
},
onCompletion: { result in
handleSignIn(result)
}
)
.signInWithAppleButtonStyle(
colorScheme == .dark ? .white : .black
)
.frame(height: 50)
// Continue without signing in
Button("Continue as Guest") {
authManager.continueAsGuest()
}
.foregroundColor(.secondary)
}
.padding()
}
private func handleSignIn(_ result: Result<ASAuthorization, Error>) {
switch result {
case .success(let authorization):
if let credential = authorization.credential as? ASAuthorizationAppleIDCredential {
authManager.signIn(with: credential)
}
case .failure(let error):
print("Sign in failed: \(error)")
}
}
}
Android Instant Apps Im
plementation
Module Configuration
// settings.gradle.kts
include(":app")
include(":instantapp")
include(":base") // Shared code
include(":features:order") // Feature module
// app/build.gradle.kts (installed app)
plugins {
id("com.android.application")
kotlin("android")
}
android {
namespace = "com.example.coffeeorder"
dynamicFeatures += setOf(":features:order")
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
}
}
}
dependencies {
implementation(project(":base"))
}
// instantapp/build.gradle.kts
plugins {
id("com.android.instantapp")
}
dependencies {
implementation(project(":features:order"))
implementation(project(":base"))
}
// features/order/build.gradle.kts
plugins {
id("com.android.dynamic-feature")
kotlin("android")
}
android {
namespace = "com.example.coffeeorder.order"
// Configure as instant-enabled feature
buildTypes {
release {
isMinifyEnabled = false // Handled by base module
}
}
}
dependencies {
implementation(project(":app"))
implementation(project(":base"))
}
AndroidManifest Configuration
{/* features/order/src/main/AndroidManifest.xml */}
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:dist="http://schemas.android.com/apk/distribution">
<dist:module
dist:instant="true"
dist:title="@string/title_order">
<dist:delivery>
<dist:install-time />
</dist:delivery>
<dist:fusing dist:include="true" />
</dist:module>
<application>
<activity
android:name=".OrderActivity"
android:exported="true"
android:launchMode="singleTask">
{/* App Links for instant app */}
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="coffee.app"
android:pathPattern="/order.*" />
</intent-filter>
{/* Deep link for instant app */}
<meta-data
android:name="default-url"
android:value="https://coffee.app/order" />
</activity>
</application>
</manifest>
Instant App Entry Point
// features/order/src/main/java/OrderActivity.kt
class OrderActivity : ComponentActivity() {
private val viewModel: OrderViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Parse deep link
val locationId = intent.data?.getQueryParameter("location")
locationId?.let { viewModel.setLocation(it) }
setContent {
CoffeeOrderTheme {
OrderScreen(
viewModel = viewModel,
onInstallFullApp = { promptInstall() }
)
}
}
}
private fun promptInstall() {
// Show install prompt for full app
val intent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse("market://details?id=${packageName}")
setPackage("com.android.vending")
}
// Use Play Core library for seamless install
if (InstantApps.isInstantApp(this)) {
InstantApps.showInstallPrompt(
this,
intent,
REQUEST_INSTALL,
"Get the full app for rewards and order history"
)
}
}
companion object {
private const val REQUEST_INSTALL = 1001
}
}
Streamlined UI for Instant Experience
@Composable
fun OrderScreen(
viewModel: OrderViewModel,
onInstallFullApp: () -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current
val isInstantApp = remember { InstantApps.isInstantApp(context) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Order Coffee") },
actions = {
if (isInstantApp) {
TextButton(onClick = onInstallFullApp) {
Text("Get Full App")
}
}
}
)
},
bottomBar = {
if (uiState.cart.isNotEmpty()) {
CartBottomBar(
cart = uiState.cart,
onCheckout = { viewModel.checkout() }
)
}
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
// Location info
uiState.location?.let { location ->
LocationCard(location = location)
}
// Quick order section - most popular items
QuickOrderSection(
items = uiState.popularItems,
onAddItem = { viewModel.addToCart(it) }
)
// Simple menu categories
LazyColumn {
items(uiState.categories) { category ->
CategorySection(
category = category,
onAddItem = { viewModel.addToCart(it) }
)
}
}
}
}
}
@Composable
fun QuickOrderSection(
items: List<MenuItem>,
onAddItem: (MenuItem) -> Unit
) {
Column {
Text(
text = "Quick Order",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp)
)
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
items(items) { item ->
QuickOrderCard(
item = item,
onAdd = { onAddItem(item) }
)
}
}
}
}
Size Optimization
// Keep instant app under 15MB by:
// 1. Using vector drawables instead of PNGs
// 2. Deferring image loading
// 3. Using Play Feature Delivery for on-demand assets
// build.gradle.kts
android {
bundle {
language {
enableSplit = true
}
density {
enableSplit = true
}
abi {
enableSplit = true
}
}
// Shrink resources aggressively
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
// Use R8 full mode for maximum size reduction
// gradle.properties
android.enableR8.fullMode=true
Invocation Methods
QR Codes and NFC Tags
// iOS: Generate App Clip Code (visual codes)
// App Clip Codes are generated in App Store Connect
// They encode the URL and can be scanned by camera
// For standard QR codes, encode your App Clip URL
func generateQRCode(for locationId: String) -> UIImage? {
let urlString = "https://coffee.app/order?location=\(locationId)"
guard let data = urlString.data(using: .utf8) else { return nil }
let filter = CIFilter.qrCodeGenerator()
filter.setValue(data, forKey: "inputMessage")
filter.setValue("H", forKey: "inputCorrectionLevel")
guard let ciImage = filter.outputImage else { return nil }
let transform = CGAffineTransform(scaleX: 10, y: 10)
let scaledImage = ciImage.transformed(by: transform)
return UIImage(ciImage: scaledImage)
}
// Android: NFC tag writing for Instant App
class NfcWriter {
fun writeInstantAppTag(tag: Tag, locationId: String): Boolean {
val url = "https://coffee.app/order?location=$locationId"
val record = NdefRecord.createUri(url)
val message = NdefMessage(arrayOf(record))
return try {
val ndef = Ndef.get(tag)
ndef?.connect()
ndef?.writeNdefMessage(message)
ndef?.close()
true
} catch (e: Exception) {
Log.e("NfcWriter", "Failed to write tag", e)
false
}
}
}
Smart App Banners
{/* Website: Smart App Banner for iOS */}
<meta name="apple-itunes-app" content="
app-id=123456789,
app-clip-bundle-id=com.example.coffeeorder.Clip,
app-clip-display=card
">
{/* Android: Instant App link */}
<a href="https://coffee.app/order?location=123" rel="alternate">
Order Coffee
</a>
Conversion Optimization
Encouraging Full App Install
// iOS: Strategic install prompts
class InstallPromptManager {
private let defaults = UserDefaults.standard
private let orderCountKey = "app_clip_order_count"
func shouldShowInstallPrompt() -> Bool {
let orderCount = defaults.integer(forKey: orderCountKey)
// Show after 2nd successful order
return orderCount >= 2
}
func recordOrder() {
let current = defaults.integer(forKey: orderCountKey)
defaults.set(current + 1, forKey: orderCountKey)
}
func showInstallPrompt(in viewController: UIViewController) {
let config = SKOverlay.AppClipConfiguration(position: .bottom)
let overlay = SKOverlay(configuration: config)
overlay.present(in: viewController.view.window!.windowScene!)
}
}
// Value proposition view
struct GetFullAppView: View {
var body: some View {
VStack(spacing: 16) {
Image(systemName: "star.fill")
.font(.system(size: 40))
.foregroundColor(.yellow)
Text("Get More with the Full App")
.font(.headline)
VStack(alignment: .leading, spacing: 8) {
BenefitRow(icon: "gift", text: "Earn rewards on every order")
BenefitRow(icon: "clock", text: "Save your favorites")
BenefitRow(icon: "bell", text: "Get notified about deals")
BenefitRow(icon: "creditcard", text: "Faster checkout")
}
Button("Download Free") {
// Show App Store overlay
}
.buttonStyle(.borderedProminent)
}
.padding()
}
}
Data Handoff to Full App
// Android: Transfer data when user installs full app
class DataTransferManager(private val context: Context) {
private val prefs = context.getSharedPreferences("instant_app_data", Context.MODE_PRIVATE)
// Save data in instant app
fun saveForTransfer(userId: String?, orderHistory: List<Order>) {
val data = TransferData(
userId = userId,
recentOrders = orderHistory.take(10),
savedLocations = getSavedLocations()
)
prefs.edit()
.putString("transfer_data", Json.encodeToString(data))
.apply()
// Also use Cookie API for cross-install transfer
if (InstantApps.isInstantApp(context)) {
val cookieData = Json.encodeToString(data).toByteArray()
InstantApps.getPackageManagerCompat(context)
.instantAppCookie = cookieData
}
}
// Read data in installed app
fun readTransferredData(): TransferData? {
// First check Cookie API
val cookieData = InstantApps.getPackageManagerCompat(context)
.instantAppCookie
if (cookieData.isNotEmpty()) {
return try {
Json.decodeFromString(String(cookieData))
} catch (e: Exception) {
null
}
}
// Fall back to SharedPreferences
val json = prefs.getString("transfer_data", null)
return json?.let { Json.decodeFromString(it) }
}
}
@Serializable
data class TransferData(
val userId: String?,
val recentOrders: List<Order>,
val savedLocations: List<Location>
)
Analytics and Measurement
Tracking App Clip Performance
// Track key metrics
class AppClipAnalytics {
func trackLaunch(source: LaunchSource, locationId: String?) {
Analytics.track("app_clip_launched", properties: [
"source": source.rawValue,
"location_id": locationId ?? "unknown",
"is_first_launch": isFirstLaunch
])
}
func trackConversion(type: ConversionType, value: Double?) {
Analytics.track("app_clip_conversion", properties: [
"type": type.rawValue,
"value": value ?? 0,
"session_duration": sessionDuration
])
}
func trackInstallPromptShown() {
Analytics.track("app_clip_install_prompt_shown")
}
func trackInstallInitiated() {
Analytics.track("app_clip_install_initiated")
}
enum LaunchSource: String {
case qrCode = "qr_code"
case nfc = "nfc"
case appClipCode = "app_clip_code"
case safari = "safari"
case messages = "messages"
case maps = "maps"
}
enum ConversionType: String {
case order = "order"
case signup = "signup"
case appInstall = "app_install"
}
}
Conclusion
App Clips and Instant Apps remove the biggest barrier to mobile engagement: installation. Users get immediate value, and you get a chance to demonstrate that value before asking for a larger commitment.
Key success factors:
- Focus ruthlessly - One task, done exceptionally well
- Optimise size - Under 15MB, ideally under 10MB
- Minimise friction - No account required for first interaction
- Provide clear value - Solve the immediate need
- Convert strategically - Show full app benefits at the right moment
Start with your highest-frequency, lowest-commitment use case. Measure conversion through the funnel. Iterate on the experience based on where users drop off.
The goal is not just instant access - it is building a bridge from casual user to loyal customer.
Planning to implement App Clips or Instant Apps for your Australian business? We have deployed instant experiences for retail, hospitality, and service businesses. Contact us to discuss your use case.