DEV Community

sp0033212000
sp0033212000

Posted on

LINE LIFF in a Next.js Frontend: The Pitfalls I Hit

The booking site I work on is a Next.js app, and a lot of its users arrive by tapping a link in a shop's LINE Official Account. So we added LIFF early on, letting people sign in with LINE in one tap. Later we moved sign-in to an SSO SPA on a separate domain. That's when LINE's in-app browser and cross-domain redirects started fighting each other: pages stuck on "Loading...", sign-in loops, error popups nobody could make sense of. And many of them only happened on Android, or only on iPhone.

This post collects the LINE pitfalls I ran into, each with the symptom, the cause and the fix. If your site gets opened inside LINE, hopefully it saves you some time.

1. LIFF login in an external browser loops forever on iPhone

Symptom: A user opens the site in Safari on iPhone and taps "Sign in". They get bounced to the LINE app, then back to Safari, and the page still shows the "Sign in" button. Tap it again, same loop.

Cause: When you run the LIFF OAuth flow in an external browser, iOS hands the login over to the LINE app. When LINE hands control back to Safari, it opens a new tab instead of returning to the original one. Everything the OAuth flow needs, like the code verifier stored in the original tab, isn't visible from the new tab, so the flow just breaks.

I tried a few workarounds, including forcing the external-browser OAuth flow and building the OAuth URL by hand with a parameter to stop the app from taking over. None of them worked.

Fix: Only use LIFF login when the page is actually running inside the LINE app. External browsers get a different sign-in method (SMS in our case). For the check I didn't write my own User-Agent rules. LINE publishes @liff/is-in-client, which runs the same logic as liff.isInClient() and doesn't need liff.init() first:

import { isInClient } from '@liff/is-in-client';

export const isLiffInAppBrowser = (): boolean => {
  // It reads navigator and sessionStorage synchronously, so guard SSR
  if (typeof window === 'undefined') return false;
  return isInClient();
};
Enter fullscreen mode Exit fullscreen mode

One related gotcha: make the LIFF login redirectUri point back to the page the user was actually on. In one case we were sending them to the home page, so after signing in with LINE they lost the store page they'd been looking at.

2. liff.init() can reload the current URL

Symptom: After signing in, the user lands back on our callback page, and the request that exchanges the one-time code for a token goes out twice. The second one fails with a 400.

Cause: We ran liff.init() on every page load. Inside LINE's in-app browser, liff.init() can navigate to the current URL again. On the callback page, that means reloading with the same single-use code, so the code gets redeemed twice.

Fix: You need both of these:

  • Don't run liff.init() on the callback page. All that page does is trade a code for a token. It doesn't need LIFF at all.
  • Redeem each code exactly once. If a remount calls it again, join the request that's already in flight. If the page reloads, reuse the result you recorded instead of calling the API again.

The second point isn't really LINE-specific. Any page that handles a single-use code (OAuth, magic links and so on) should assume it might get loaded twice.

3. Changing location before liff.init() finishes, and Android never navigates

Symptom: Before redirecting to the sign-in page, we show a "Loading..." overlay. In some Android LINE builds that overlay just stays there forever, and the URL never changes.

Cause: LINE's own docs warn about this: if you touch location in the same tab while liff.init() is still running, some Android LIFF WebViews never actually navigate. Our redirect logic and liff.init() lived in different components, so they raced.

Fix: Wait for liff.init() before redirecting. There's a catch, though: liff.init() has no timeout of its own. If it hangs (say the SDK never finishes loading), waiting on it forever just moves the "stuck on Loading" bug somewhere else. So I wait at most 5 seconds, then redirect anyway:

const LIFF_INIT_WAIT_MS = 5_000;

useEffect(() => {
  // In LIFF, let liff.init() settle before touching location
  if (isLiffMode && !liffReady && !waitElapsed) {
    const timer = setTimeout(() => setWaitElapsed(true), LIFF_INIT_WAIT_MS);
    return () => clearTimeout(timer);
  }
  startRedirect();
}, [isLiffMode, liffReady, waitElapsed]);
Enter fullscreen mode Exit fullscreen mode

4. Android LIFF can swallow cross-domain redirects

Symptom: Even after waiting for liff.init(), some Android devices still got stuck. Users who cancelled on the sign-in page also came back to a blank callback page that went nowhere.

Cause: Some Android LIFF browsers silently swallow a same-tab cross-domain location.assign() or location.replace(). No error, the tab stays alive, it just doesn't go anywhere.

Fix: I couldn't fix this at the root, so the code checks whether the redirect took, and tries again if it didn't:

const REDIRECT_RETRY_MS = 2_000;

export const redirectWithRetry = (url: string, onStuck?: () => void) => {
  window.location.assign(url);
  // Still on this page after 2 seconds? Try again
  setTimeout(() => {
    window.location.assign(url);
    // If that didn't work either, let the caller show a manual retry button
    if (onStuck) setTimeout(onStuck, REDIRECT_RETRY_MS);
  }, REDIRECT_RETRY_MS);
};
Enter fullscreen mode Exit fullscreen mode

If the redirect does go through, the page is gone and the timers never fire, so there's no need for an extra check. onStuck is the last line of defense: if there's a loading overlay on screen, the user at least gets a button to retry, instead of staring at it forever.

5. LIFF APIs only work after init() finishes

Symptom: After a booking, we use liff.sendMessages() to post a booking summary into the user's chat. Sentry kept reporting errors from that spot.

Cause: The code used liff.isLoggedIn() to decide whether it could send. But the liff object exists as soon as you import it. That doesn't mean it has been initialized. Before init() finishes, or if it never runs at all (say the user opened the site in an external browser), those APIs aren't usable.

Fix:

  • Don't use the presence of the liff object as your check. Track your own "init finished" flag and only call other LIFF APIs once it's true.
  • For nice-to-have features like sendMessages(), just log the failure. Don't let it interrupt the booking flow.

What debugging taught me

With all of these, the time went into finding the cause, not writing the fix. A few lessons worth writing down:

1. Don't alert() raw errors at users. For a while, users inside LINE kept seeing popups like "TypeError: Load failed" and "Network Error". My first theory was a LINE redirect timing issue, so I tried delaying redirects by a few seconds. That did nothing. Testing on a real device eventually showed the page was navigating away while liff.init() still had requests in flight. The interrupted request threw, and there happened to be an alert(e) in that code path. But a failed liff.init() already falls back to SMS sign-in, so the popup told users nothing useful. Removing it fixed it.

2. Check your own monitoring config. The "stuck on Loading" bug produced zero errors in Sentry. It turned out ignoreErrors was dropping Network Error, Load failed and friends in every environment, staging included. The errors we'd filtered out to cut noise were exactly the ones I was hunting. Now they're only ignored in production.

3. Some errors aren't yours. Sentry also had a steady stream of TypeErrors about window.webkit.messageHandlers being undefined. They came from a third-party script (most likely a tag in GTM) running inside Instagram's and Facebook's in-app browsers. It assumed it was inside a native app's WebView and tried to call a native bridge that doesn't exist on pagehide. By then the user has already left, it isn't our code, and there's nothing to fix, so it gets filtered in Sentry.

4. No DevTools on the device? Put the console on the page. Load vConsole or eruda outside production and you can read logs right inside LINE. In LINE's in-app browser, that's close to essential.

Checklist

If your site gets opened inside LINE, run through these:

  • Only use LIFF login inside the LINE app; give external browsers another way in, and check with @liff/is-in-client
  • Point the LIFF login redirectUri back at the page the user was on
  • Don't run liff.init() on a callback page that redeems a one-time code, and redeem each code exactly once
  • Wait for liff.init() before changing location, but cap the wait
  • Check that cross-domain redirects actually happened, retry if not, and give users a manual retry as a last resort
  • Only call other LIFF APIs after init, and don't let nice-to-have features break the main flow
  • Don't alert() raw errors, and make sure your monitoring isn't ignoring the errors you're looking for

LINE's in-app browser behaves differently from a normal browser in a lot of ways, and Android and iPhone differ from each other on top of that. My biggest takeaway: anything people will use inside LINE needs to be tested inside the real LINE app, on both kinds of phone.

References:

Top comments (0)